UPDATE
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,269 @@
|
|||||||
|
# Yield/Harvest Prediction API Changes
|
||||||
|
|
||||||
|
این فایل تغییرات 3 API زیر را توضیح میدهد:
|
||||||
|
|
||||||
|
- `POST /api/yield-harvest/harvest-prediction/`
|
||||||
|
- `POST /api/yield-harvest/yield-prediction/`
|
||||||
|
- `POST /api/yield-harvest/current-farm-chart/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه تغییرات
|
||||||
|
|
||||||
|
تغییر اصلی در هر 3 endpoint این است که backend حالا context موردنیاز AI را خودش از روی مزرعه و planهای انتخابی میسازد.
|
||||||
|
|
||||||
|
### قبل
|
||||||
|
|
||||||
|
در استفاده قدیمی، معمولاً فرض میشد client باید context بیشتری برای AI بفرستد.
|
||||||
|
|
||||||
|
### الآن
|
||||||
|
|
||||||
|
- `farm_uuid` ورودی اصلی و الزامی است.
|
||||||
|
- `plant_name` اگر هم توسط client ارسال شود، مبنای نهایی backend نیست و از روی مزرعه بازنویسی/resolve میشود.
|
||||||
|
- در صورت نیاز، `irrigation_plan_id` و `fertilization_plan_id` هم میتوانند ارسال شوند.
|
||||||
|
- اگر plan انتخابی معتبر و متعلق به همان مزرعه کاربر باشد، backend محتوای آن را به payload ارسالی به AI اضافه میکند.
|
||||||
|
- خروجی backend بهصورت یکدست با فرمت `code / msg / data` برگردانده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Contract جدید
|
||||||
|
|
||||||
|
هر 3 API از این قرارداد ورودی استفاده میکنند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"irrigation_plan_id": 12,
|
||||||
|
"fertilization_plan_id": 34
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدها
|
||||||
|
|
||||||
|
- `farm_uuid` اجباری
|
||||||
|
- `irrigation_plan_id` اختیاری
|
||||||
|
- `fertilization_plan_id` اختیاری
|
||||||
|
|
||||||
|
### نکته مهم
|
||||||
|
|
||||||
|
اگر client `plant_name` بفرستد، در این APIها مبنای نهایی backend نیست؛ backend نام گیاه را از مزرعه استخراج میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) POST `/api/yield-harvest/current-farm-chart/`
|
||||||
|
|
||||||
|
### تغییرات
|
||||||
|
|
||||||
|
- ورودی endpoint عملاً بر پایه `farm_uuid` کار میکند و `plant_name` از context مزرعه تعیین میشود.
|
||||||
|
- backend بهصورت خودکار `plant_name` را از مزرعه پیدا میکند.
|
||||||
|
- در صورت ارسال `irrigation_plan_id`، اطلاعات برنامه آبیاری داخل payload ارسالی به AI قرار میگیرد.
|
||||||
|
- در صورت ارسال `fertilization_plan_id`، اطلاعات برنامه کودی هم اضافه میشود.
|
||||||
|
|
||||||
|
### نمونه request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### payload ارسالی backend به AI
|
||||||
|
|
||||||
|
نمونه مفهومی:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"plant_name": "گوجهفرنگی"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### در صورت انتخاب plan
|
||||||
|
|
||||||
|
نمونه مفهومی:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"plant_name": "گوجهفرنگی",
|
||||||
|
"irrigation_plan": {
|
||||||
|
"id": 12,
|
||||||
|
"plan_payload": {
|
||||||
|
"plan": {
|
||||||
|
"durationMinutes": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"plant_name": "گوجهفرنگی",
|
||||||
|
"scenario_id": 1,
|
||||||
|
"categories": ["day1"],
|
||||||
|
"series": {
|
||||||
|
"biomass": [1.2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) POST `/api/yield-harvest/harvest-prediction/`
|
||||||
|
|
||||||
|
### تغییرات
|
||||||
|
|
||||||
|
- ورودی endpoint عملاً بر پایه `farm_uuid` کار میکند و `plant_name` توسط backend تعیین میشود.
|
||||||
|
- امکان ارسال `fertilization_plan_id` و `irrigation_plan_id` برای enrich کردن context اضافه/پشتیبانی شده است.
|
||||||
|
- پاسخ AI بعد از extract شدن در `data.result`، به شکل مستقیم در `data` برگردانده میشود.
|
||||||
|
|
||||||
|
### نمونه request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"fertilization_plan_id": 34
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### payload ارسالی backend به AI
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"plant_name": "گوجهفرنگی",
|
||||||
|
"fertilization_plan": {
|
||||||
|
"id": 34,
|
||||||
|
"plan_payload": {
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_code": "npk-151515"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"date": "2026-07-15",
|
||||||
|
"dateFormatted": "15 Jul 2026",
|
||||||
|
"daysUntil": 96,
|
||||||
|
"gddDetails": {
|
||||||
|
"current": 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) POST `/api/yield-harvest/yield-prediction/`
|
||||||
|
|
||||||
|
### تغییرات
|
||||||
|
|
||||||
|
- مثل دو endpoint دیگر، `plant_name` از روی مزرعه resolve میشود.
|
||||||
|
- در نبود محصول مستقیم روی مزرعه، backend از fallback مناسب مزرعه استفاده میکند.
|
||||||
|
- امکان ارسال `irrigation_plan_id` و `fertilization_plan_id` برای فرستادن context planها به AI اضافه/پشتیبانی شده است.
|
||||||
|
- پاسخ نهایی با ساختار یکنواخت `code / msg / data` برگردانده میشود.
|
||||||
|
|
||||||
|
### نمونه request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"irrigation_plan_id": 12,
|
||||||
|
"fertilization_plan_id": 34
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### payload ارسالی backend به AI
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"plant_name": "گوجهفرنگی",
|
||||||
|
"irrigation_plan": {
|
||||||
|
"id": 12,
|
||||||
|
"plan_payload": {
|
||||||
|
"plan": {
|
||||||
|
"durationMinutes": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fertilization_plan": {
|
||||||
|
"id": 34,
|
||||||
|
"plan_payload": {
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_code": "npk-202020"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"predictedYieldTons": 8.4,
|
||||||
|
"scenarioId": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خطاها و Validation
|
||||||
|
|
||||||
|
### 1) مزرعه نامعتبر یا متعلق به کاربر دیگر
|
||||||
|
|
||||||
|
در این حالت endpoint خطای دسترسی/یافتنشدن مزرعه برمیگرداند.
|
||||||
|
|
||||||
|
### 2) plan نامعتبر یا متعلق به مزرعه دیگر
|
||||||
|
|
||||||
|
اگر `irrigation_plan_id` یا `fertilization_plan_id` متعلق به همان مزرعه کاربر نباشد، درخواست با خطا رد میشود.
|
||||||
|
|
||||||
|
نمونه:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "error",
|
||||||
|
"data": {
|
||||||
|
"irrigation_plan_id": [
|
||||||
|
"Irrigation plan not found."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) خطای validation ورودی
|
||||||
|
|
||||||
|
اگر `farm_uuid` ارسال نشود یا `plan_id`ها نامعتبر باشند، serializer خطای validation برمیگرداند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جمعبندی تغییرات برای فرانت
|
||||||
|
|
||||||
|
- دیگر لازم نیست `plant_name` را برای این 3 API بفرستید.
|
||||||
|
- فقط `farm_uuid` اجباری است.
|
||||||
|
- اگر کاربر plan خاصی را انتخاب کرده، `irrigation_plan_id` و/یا `fertilization_plan_id` را هم بفرستید.
|
||||||
|
- response هر 3 endpoint با ساختار یکنواخت `code`, `msg`, `data` برمیگردد.
|
||||||
|
- backend خودش payload مناسب AI را از context مزرعه و planهای انتخابی میسازد.
|
||||||
@@ -39,7 +39,7 @@ def sync_farmer_calendar_schema(apps, schema_editor):
|
|||||||
updated_at DATETIME(6) NOT NULL,
|
updated_at DATETIME(6) NOT NULL,
|
||||||
farm_id BIGINT NOT NULL,
|
farm_id BIGINT NOT NULL,
|
||||||
CONSTRAINT farmer_calendar_zones_farm_id_fk
|
CONSTRAINT farmer_calendar_zones_farm_id_fk
|
||||||
FOREIGN KEY (farm_id) REFERENCES farm_hub_farmhub (id)
|
FOREIGN KEY (farm_id) REFERENCES farm_hubs (id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Fertilization Plan APIs
|
||||||
|
|
||||||
|
این فایل APIهای مدیریت برنامههای کودی را توضیح میدهد.
|
||||||
|
|
||||||
|
Base path:
|
||||||
|
|
||||||
|
`/api/fertilization/`
|
||||||
|
|
||||||
|
این APIها فقط روی برنامههای متعلق به کاربر لاگینشده عمل میکنند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) دریافت لیست برنامههای کودی
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/fertilization/plans/`
|
||||||
|
- Query params:
|
||||||
|
- `farm_uuid` الزامی
|
||||||
|
- `page` اختیاری
|
||||||
|
- `page_size` اختیاری، حداکثر `100`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"source": "free_text",
|
||||||
|
"source_label": "متن آزاد کاربر",
|
||||||
|
"title": "برنامه کودی گندم",
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-02-24T10:20:30Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"total_pages": 1,
|
||||||
|
"total_items": 1,
|
||||||
|
"has_next": false,
|
||||||
|
"has_previous": false,
|
||||||
|
"next": null,
|
||||||
|
"previous": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- فقط planهایی برگردانده میشوند که `is_deleted=False` باشند.
|
||||||
|
- ترتیب لیست از جدید به قدیم است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) دریافت جزئیات یک برنامه کودی
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/fertilization/plans/{plan_uuid}/`
|
||||||
|
- Path param:
|
||||||
|
- `plan_uuid` الزامی
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"source": "free_text",
|
||||||
|
"source_label": "متن آزاد کاربر",
|
||||||
|
"title": "برنامه کودی گندم",
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-02-24T10:20:30Z",
|
||||||
|
"updated_at": "2025-02-24T10:20:30Z",
|
||||||
|
"plan_payload": {
|
||||||
|
"title": "برنامه کودی گندم",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "NPK 20-20-20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) حذف برنامه کودی
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `DELETE`
|
||||||
|
- URL: `/api/fertilization/plans/{plan_uuid}/`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"is_deleted": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- حذف بهصورت `soft delete` انجام میشود.
|
||||||
|
- در عمل:
|
||||||
|
- `is_deleted = true`
|
||||||
|
- `is_active = false`
|
||||||
|
- `deleted_at` مقداردهی میشود
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) تغییر وضعیت فعال بودن برنامه کودی
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `PATCH`
|
||||||
|
- URL: `/api/fertilization/plans/{plan_uuid}/status/`
|
||||||
|
- Body:
|
||||||
|
- `is_active` الزامی، `boolean`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"is_active": [
|
||||||
|
"This field is required."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- `GET /api/fertilization/plans/` لیست برنامهها
|
||||||
|
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
|
||||||
|
- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه
|
||||||
|
- `PATCH /api/fertilization/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه
|
||||||
@@ -6,7 +6,7 @@ OLD_STATUSES = {"", "success", "error", None}
|
|||||||
|
|
||||||
|
|
||||||
def migrate_existing_statuses(apps, schema_editor):
|
def migrate_existing_statuses(apps, schema_editor):
|
||||||
Recommendation = apps.get_model("fertilization", "FertilizationRecommendationRequest")
|
Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest")
|
||||||
Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update(
|
Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update(
|
||||||
status=PENDING_STATUS
|
status=PENDING_STATUS
|
||||||
)
|
)
|
||||||
@@ -15,7 +15,7 @@ def migrate_existing_statuses(apps, schema_editor):
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("fertilization", "0001_initial"),
|
("fertilization_recommendation", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("fertilization_recommendation", "0002_recommendation_status_lifecycle"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FertilizationPlan",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)),
|
||||||
|
("title", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("crop_id", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("growth_stage", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("plan_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("request_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("response_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("is_active", models.BooleanField(db_index=True, default=True)),
|
||||||
|
("is_deleted", models.BooleanField(db_index=True, default=False)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"farm",
|
||||||
|
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="fertilization_plans", to="farm_hub.farmhub"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recommendation",
|
||||||
|
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="fertilization_recommendation.fertilizationrecommendationrequest"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "fertilization_plans",
|
||||||
|
"ordering": ["-created_at", "-id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
@@ -41,3 +42,51 @@ class FertilizationRecommendationRequest(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.task_id or str(self.uuid)
|
return self.task_id or str(self.uuid)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlan(models.Model):
|
||||||
|
SOURCE_RECOMMENDATION = "recommendation"
|
||||||
|
SOURCE_FREE_TEXT = "free_text"
|
||||||
|
SOURCE_CHOICES = (
|
||||||
|
(SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"),
|
||||||
|
(SOURCE_FREE_TEXT, "متن آزاد کاربر"),
|
||||||
|
)
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||||
|
farm = models.ForeignKey(
|
||||||
|
FarmHub,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="fertilization_plans",
|
||||||
|
)
|
||||||
|
source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True)
|
||||||
|
recommendation = models.ForeignKey(
|
||||||
|
FertilizationRecommendationRequest,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="plans",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
crop_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
plan_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "fertilization_plans"
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title or self.crop_id or str(self.uuid)
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
self.is_deleted = True
|
||||||
|
self.is_active = False
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"])
|
||||||
|
|||||||
@@ -167,3 +167,39 @@ class FertilizationRecommendResponseDataSerializer(serializers.Serializer):
|
|||||||
application_guide = ApplicationGuideSerializer(read_only=True)
|
application_guide = ApplicationGuideSerializer(read_only=True)
|
||||||
alternative_recommendations = AlternativeRecommendationSerializer(many=True, read_only=True)
|
alternative_recommendations = AlternativeRecommendationSerializer(many=True, read_only=True)
|
||||||
sections = FertilizationSectionSerializer(many=True, read_only=True)
|
sections = FertilizationSectionSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanListQuerySerializer(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 FertilizationPlanListItemSerializer(serializers.Serializer):
|
||||||
|
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||||
|
source = serializers.CharField(read_only=True)
|
||||||
|
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||||
|
title = serializers.CharField(read_only=True)
|
||||||
|
crop_id = serializers.CharField(read_only=True)
|
||||||
|
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||||
|
growth_stage = serializers.CharField(read_only=True)
|
||||||
|
is_active = serializers.BooleanField(read_only=True)
|
||||||
|
created_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanDetailSerializer(serializers.Serializer):
|
||||||
|
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||||
|
source = serializers.CharField(read_only=True)
|
||||||
|
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||||
|
title = serializers.CharField(read_only=True)
|
||||||
|
crop_id = serializers.CharField(read_only=True)
|
||||||
|
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||||
|
growth_stage = serializers.CharField(read_only=True)
|
||||||
|
is_active = serializers.BooleanField(read_only=True)
|
||||||
|
created_at = serializers.DateTimeField(read_only=True)
|
||||||
|
updated_at = serializers.DateTimeField(read_only=True)
|
||||||
|
plan_payload = serializers.DictField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanStatusUpdateSerializer(serializers.Serializer):
|
||||||
|
is_active = serializers.BooleanField(required=True)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA
|
from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA
|
||||||
from .models import FertilizationRecommendationRequest
|
from .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||||
|
|
||||||
|
|
||||||
def _extract_result(response_payload):
|
def _extract_result(response_payload):
|
||||||
@@ -31,6 +31,51 @@ def _get_latest_result(farm):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_plan_payload(farm):
|
||||||
|
if farm is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
plan = (
|
||||||
|
FertilizationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False)
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if plan is None or not isinstance(plan.plan_payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return deepcopy(plan.plan_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def build_active_plan_context(farm):
|
||||||
|
plan_payload = get_active_plan_payload(farm)
|
||||||
|
if not plan_payload:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = {"plan_payload": plan_payload}
|
||||||
|
|
||||||
|
primary_recommendation = plan_payload.get("primary_recommendation")
|
||||||
|
if isinstance(primary_recommendation, dict) and primary_recommendation:
|
||||||
|
context["primary_recommendation"] = deepcopy(primary_recommendation)
|
||||||
|
|
||||||
|
nutrient_analysis = plan_payload.get("nutrient_analysis")
|
||||||
|
if isinstance(nutrient_analysis, dict) and nutrient_analysis:
|
||||||
|
context["nutrient_analysis"] = deepcopy(nutrient_analysis)
|
||||||
|
|
||||||
|
application_guide = plan_payload.get("application_guide")
|
||||||
|
if isinstance(application_guide, dict) and application_guide:
|
||||||
|
context["application_guide"] = deepcopy(application_guide)
|
||||||
|
|
||||||
|
alternative_recommendations = plan_payload.get("alternative_recommendations")
|
||||||
|
if isinstance(alternative_recommendations, list) and alternative_recommendations:
|
||||||
|
context["alternative_recommendations"] = deepcopy(alternative_recommendations)
|
||||||
|
|
||||||
|
sections = plan_payload.get("sections")
|
||||||
|
if isinstance(sections, list) and sections:
|
||||||
|
context["sections"] = deepcopy(sections)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
def get_fertilization_dashboard_recommendation(farm=None):
|
def get_fertilization_dashboard_recommendation(farm=None):
|
||||||
default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION)
|
default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION)
|
||||||
result = _get_latest_result(farm)
|
result = _get_latest_result(farm)
|
||||||
|
|||||||
+167
-2
@@ -5,8 +5,16 @@ 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 .models import FertilizationRecommendationRequest
|
from .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||||
from .views import PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView
|
from .views import (
|
||||||
|
FertilizationPlanDetailView,
|
||||||
|
FertilizationPlanListView,
|
||||||
|
FertilizationPlanStatusView,
|
||||||
|
PlanFromTextView,
|
||||||
|
RecommendationDetailView,
|
||||||
|
RecommendationListView,
|
||||||
|
RecommendView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FertilizationRecommendViewTests(TestCase):
|
class FertilizationRecommendViewTests(TestCase):
|
||||||
@@ -51,6 +59,7 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["data"]["status"], "needs_clarification")
|
self.assertEqual(response.data["data"]["status"], "needs_clarification")
|
||||||
|
self.assertEqual(FertilizationPlan.objects.count(), 0)
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/fertilization/plan-from-text/",
|
"/api/fertilization/plan-from-text/",
|
||||||
@@ -143,9 +152,16 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
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)
|
self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1)
|
||||||
|
self.assertEqual(FertilizationPlan.objects.count(), 1)
|
||||||
saved_request = FertilizationRecommendationRequest.objects.get()
|
saved_request = FertilizationRecommendationRequest.objects.get()
|
||||||
|
saved_plan = FertilizationPlan.objects.get()
|
||||||
self.assertEqual(saved_request.crop_id, "گندم")
|
self.assertEqual(saved_request.crop_id, "گندم")
|
||||||
self.assertEqual(saved_request.growth_stage, "vegetative")
|
self.assertEqual(saved_request.growth_stage, "vegetative")
|
||||||
|
self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION)
|
||||||
|
self.assertEqual(saved_plan.recommendation_id, saved_request.id)
|
||||||
|
self.assertTrue(saved_plan.is_active)
|
||||||
|
self.assertFalse(saved_plan.is_deleted)
|
||||||
|
self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
saved_request.status,
|
saved_request.status,
|
||||||
FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
@@ -194,6 +210,79 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("fertilization.views.external_api_request")
|
||||||
|
def test_recommend_includes_active_fertilization_plan_in_ai_payload(self, mock_external_api_request):
|
||||||
|
FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه فعال",
|
||||||
|
crop_id="گندم",
|
||||||
|
growth_stage="vegetative",
|
||||||
|
plan_payload={
|
||||||
|
"primary_recommendation": {"fertilizer_code": "npk-101010", "fertilizer_name": "NPK 10-10-10"},
|
||||||
|
"nutrient_analysis": {"macro": [{"key": "n", "value": 10}]},
|
||||||
|
"application_guide": {"steps": [{"step_number": 1, "title": "مرحله اول"}]},
|
||||||
|
"sections": [{"type": "recommendation", "title": "اصلی"}],
|
||||||
|
},
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
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), "crop_id": "گندم", "growth_stage": "vegetative"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RecommendView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||||
|
self.assertIn("active_fertilization_plan", sent_payload)
|
||||||
|
self.assertEqual(
|
||||||
|
sent_payload["active_fertilization_plan"]["primary_recommendation"]["fertilizer_code"],
|
||||||
|
"npk-101010",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("fertilization.views.external_api_request")
|
||||||
|
def test_plan_from_text_creates_plan_when_final_plan_exists(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"code": 200,
|
||||||
|
"msg": "موفق",
|
||||||
|
"data": {
|
||||||
|
"status": "completed",
|
||||||
|
"final_plan": {
|
||||||
|
"title": "برنامه کوددهی گندم",
|
||||||
|
"crop_name": "گندم",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"items": [{"name": "NPK 20-20-20"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/fertilization/plan-from-text/",
|
||||||
|
{"message": "برنامه کودی", "farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = PlanFromTextView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(FertilizationPlan.objects.count(), 1)
|
||||||
|
plan = FertilizationPlan.objects.get()
|
||||||
|
self.assertEqual(plan.source, FertilizationPlan.SOURCE_FREE_TEXT)
|
||||||
|
self.assertEqual(plan.title, "برنامه کوددهی گندم")
|
||||||
|
self.assertEqual(plan.crop_id, "گندم")
|
||||||
|
self.assertEqual(plan.growth_stage, "flowering")
|
||||||
|
self.assertTrue(plan.is_active)
|
||||||
|
self.assertFalse(plan.is_deleted)
|
||||||
|
|
||||||
def test_recommendation_list_returns_paginated_summary_items(self):
|
def test_recommendation_list_returns_paginated_summary_items(self):
|
||||||
first = FertilizationRecommendationRequest.objects.create(
|
first = FertilizationRecommendationRequest.objects.create(
|
||||||
farm=self.farm,
|
farm=self.farm,
|
||||||
@@ -318,3 +407,79 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
response.data["data"]["primary_recommendation"]["fertilizer_code"],
|
response.data["data"]["primary_recommendation"]["fertilizer_code"],
|
||||||
"legacy-code-101",
|
"legacy-code-101",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="fert-plan-user",
|
||||||
|
password="secret123",
|
||||||
|
email="fert-plan@example.com",
|
||||||
|
phone_number="09123334455",
|
||||||
|
)
|
||||||
|
self.farm_type = FarmType.objects.create(name="باغی")
|
||||||
|
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-plan-farm")
|
||||||
|
self.plan = FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه نمونه",
|
||||||
|
crop_id="گوجه",
|
||||||
|
growth_stage="flowering",
|
||||||
|
plan_payload={"items": [{"title": "مرحله اول"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plan_list_returns_non_deleted_plans(self):
|
||||||
|
FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_RECOMMENDATION,
|
||||||
|
title="حذف شده",
|
||||||
|
is_deleted=True,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/fertilization/plans/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = FertilizationPlanListView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["code"], 200)
|
||||||
|
self.assertEqual(len(response.data["data"]), 1)
|
||||||
|
self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid))
|
||||||
|
self.assertEqual(response.data["data"][0]["source"], FertilizationPlan.SOURCE_FREE_TEXT)
|
||||||
|
|
||||||
|
def test_plan_detail_returns_plan_payload(self):
|
||||||
|
request = self.factory.get(f"/api/fertilization/plans/{self.plan.uuid}/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid))
|
||||||
|
self.assertEqual(response.data["data"]["plan_payload"]["items"][0]["title"], "مرحله اول")
|
||||||
|
|
||||||
|
def test_plan_delete_is_soft_delete(self):
|
||||||
|
request = self.factory.delete(f"/api/fertilization/plans/{self.plan.uuid}/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertTrue(self.plan.is_deleted)
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|
||||||
|
def test_plan_status_patch_updates_is_active(self):
|
||||||
|
request = self.factory.patch(
|
||||||
|
f"/api/fertilization/plans/{self.plan.uuid}/status/",
|
||||||
|
{"is_active": False},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|||||||
+13
-1
@@ -1,9 +1,21 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import ConfigView, PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView
|
from .views import (
|
||||||
|
ConfigView,
|
||||||
|
FertilizationPlanDetailView,
|
||||||
|
FertilizationPlanListView,
|
||||||
|
FertilizationPlanStatusView,
|
||||||
|
PlanFromTextView,
|
||||||
|
RecommendationDetailView,
|
||||||
|
RecommendationListView,
|
||||||
|
RecommendView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
|
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
|
||||||
|
path("plans/", FertilizationPlanListView.as_view(), name="fertilization-plan-list"),
|
||||||
|
path("plans/<uuid:plan_uuid>/", FertilizationPlanDetailView.as_view(), name="fertilization-plan-detail"),
|
||||||
|
path("plans/<uuid:plan_uuid>/status/", FertilizationPlanStatusView.as_view(), name="fertilization-plan-status"),
|
||||||
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"),
|
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"),
|
||||||
path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"),
|
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"),
|
||||||
|
|||||||
+171
-4
@@ -15,11 +15,16 @@ from drf_spectacular.utils import extend_schema
|
|||||||
from config.swagger import code_response, 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 .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||||
|
from .services import build_active_plan_context
|
||||||
from .mock_data import CONFIG_RESPONSE_DATA
|
from .mock_data import CONFIG_RESPONSE_DATA
|
||||||
from .models import FertilizationRecommendationRequest
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
FreeTextPlanParserRequestSerializer,
|
FreeTextPlanParserRequestSerializer,
|
||||||
FreeTextPlanParserResponseDataSerializer,
|
FreeTextPlanParserResponseDataSerializer,
|
||||||
|
FertilizationPlanDetailSerializer,
|
||||||
|
FertilizationPlanListItemSerializer,
|
||||||
|
FertilizationPlanListQuerySerializer,
|
||||||
|
FertilizationPlanStatusUpdateSerializer,
|
||||||
FertilizationRecommendationListItemSerializer,
|
FertilizationRecommendationListItemSerializer,
|
||||||
FertilizationRecommendationListQuerySerializer,
|
FertilizationRecommendationListQuerySerializer,
|
||||||
FertilizationRecommendRequestSerializer,
|
FertilizationRecommendRequestSerializer,
|
||||||
@@ -358,6 +363,34 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
"sections": normalized_sections,
|
"sections": normalized_sections,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_plan_title(crop_id, growth_stage, primary_recommendation):
|
||||||
|
fertilizer_name = str(primary_recommendation.get("display_title") or primary_recommendation.get("fertilizer_name") or "").strip()
|
||||||
|
parts = [part for part in [fertilizer_name, crop_id, growth_stage] if part]
|
||||||
|
return " - ".join(parts) if parts else "برنامه کودی"
|
||||||
|
|
||||||
|
def _create_plan_from_recommendation(self, recommendation, public_data):
|
||||||
|
primary_recommendation = public_data.get("primary_recommendation", {})
|
||||||
|
FertilizationPlan.objects.create(
|
||||||
|
farm=recommendation.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_RECOMMENDATION,
|
||||||
|
recommendation=recommendation,
|
||||||
|
title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, primary_recommendation),
|
||||||
|
crop_id=recommendation.crop_id,
|
||||||
|
growth_stage=recommendation.growth_stage,
|
||||||
|
plan_payload=public_data,
|
||||||
|
request_payload=recommendation.request_payload,
|
||||||
|
response_payload=recommendation.response_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _enrich_ai_payload(payload, farm):
|
||||||
|
enriched_payload = payload.copy()
|
||||||
|
active_plan_context = build_active_plan_context(farm)
|
||||||
|
if active_plan_context:
|
||||||
|
enriched_payload["active_fertilization_plan"] = active_plan_context
|
||||||
|
return enriched_payload
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Fertilization Recommendation"],
|
tags=["Fertilization Recommendation"],
|
||||||
request=FertilizationRecommendRequestSerializer,
|
request=FertilizationRecommendRequestSerializer,
|
||||||
@@ -374,12 +407,13 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
payload["crop_id"] = crop_id
|
payload["crop_id"] = crop_id
|
||||||
payload["plant_name"] = plant_name
|
payload["plant_name"] = plant_name
|
||||||
payload["growth_stage"] = payload.get("growth_stage", "")
|
payload["growth_stage"] = payload.get("growth_stage", "")
|
||||||
|
ai_payload = self._enrich_ai_payload(payload, farm)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/fertilization/recommend/",
|
"/api/fertilization/recommend/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload=payload,
|
payload=ai_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||||
@@ -393,13 +427,13 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
len(public_data.get("sections", [])),
|
len(public_data.get("sections", [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
FertilizationRecommendationRequest.objects.create(
|
recommendation = FertilizationRecommendationRequest.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
crop_id=crop_id,
|
crop_id=crop_id,
|
||||||
growth_stage=payload.get("growth_stage", ""),
|
growth_stage=payload.get("growth_stage", ""),
|
||||||
task_id="",
|
task_id="",
|
||||||
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
request_payload=payload,
|
request_payload=ai_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},
|
||||||
)
|
)
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
@@ -412,6 +446,8 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._create_plan_from_recommendation(recommendation, public_data)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@@ -490,6 +526,30 @@ class RecommendationDetailView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
|
|
||||||
class PlanFromTextView(FarmAccessMixin, APIView):
|
class PlanFromTextView(FarmAccessMixin, APIView):
|
||||||
|
@staticmethod
|
||||||
|
def _extract_final_plan(response_data):
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
data = response_data.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
final_plan = data.get("final_plan")
|
||||||
|
if isinstance(final_plan, dict) and final_plan:
|
||||||
|
return final_plan
|
||||||
|
final_plan = response_data.get("final_plan")
|
||||||
|
if isinstance(final_plan, dict) and final_plan:
|
||||||
|
return final_plan
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_free_text_plan_title(final_plan):
|
||||||
|
if not isinstance(final_plan, dict):
|
||||||
|
return "برنامه کودی"
|
||||||
|
for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"):
|
||||||
|
value = str(final_plan.get(key, "")).strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return "برنامه کودی"
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Fertilization Recommendation"],
|
tags=["Fertilization Recommendation"],
|
||||||
request=FreeTextPlanParserRequestSerializer,
|
request=FreeTextPlanParserRequestSerializer,
|
||||||
@@ -519,7 +579,114 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
final_plan = self._extract_final_plan(response_data)
|
||||||
|
if final_plan and farm_uuid:
|
||||||
|
FertilizationPlan.objects.create(
|
||||||
|
farm=farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title=self._build_free_text_plan_title(final_plan),
|
||||||
|
crop_id=str(
|
||||||
|
final_plan.get("crop_id")
|
||||||
|
or final_plan.get("crop_name")
|
||||||
|
or final_plan.get("plant_name")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
growth_stage=str(final_plan.get("growth_stage") or "").strip(),
|
||||||
|
plan_payload=final_plan,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload=response_data,
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanListView(FarmAccessMixin, APIView):
|
||||||
|
pagination_class = FertilizationRecommendationPagination
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
parameters=[FertilizationPlanListQuerySerializer],
|
||||||
|
responses={200: code_response("FertilizationPlanListResponse")},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
serializer = FertilizationPlanListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
|
||||||
|
plans = farm.fertilization_plans.filter(is_deleted=False).order_by("-created_at", "-id")
|
||||||
|
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
page = paginator.paginate_queryset(plans, request, view=self)
|
||||||
|
data = FertilizationPlanListItemSerializer(page, many=True).data
|
||||||
|
return paginator.get_paginated_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanDetailView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="plan_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.PATH,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: code_response("FertilizationPlanDetailResponse", data=FertilizationPlanDetailSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request, plan_uuid):
|
||||||
|
plan = FertilizationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).select_related("farm").first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
data = FertilizationPlanDetailSerializer(plan).data
|
||||||
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
responses={200: status_response("FertilizationPlanDeleteResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def delete(self, request, plan_uuid):
|
||||||
|
plan = FertilizationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
plan.soft_delete()
|
||||||
|
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationPlanStatusView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
request=FertilizationPlanStatusUpdateSerializer,
|
||||||
|
responses={200: code_response("FertilizationPlanStatusResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def patch(self, request, plan_uuid):
|
||||||
|
serializer = FertilizationPlanStatusUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
plan = FertilizationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
plan.is_active = serializer.validated_data["is_active"]
|
||||||
|
plan.save(update_fields=["is_active", "updated_at"])
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Irrigation Plan APIs
|
||||||
|
|
||||||
|
این فایل APIهای مدیریت برنامههای آبیاری را توضیح میدهد.
|
||||||
|
|
||||||
|
Base path:
|
||||||
|
|
||||||
|
`/api/irrigation/`
|
||||||
|
|
||||||
|
این APIها فقط روی برنامههای متعلق به کاربر لاگینشده عمل میکنند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) دریافت لیست برنامههای آبیاری
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/irrigation/plans/`
|
||||||
|
- Query params:
|
||||||
|
- `farm_uuid` الزامی
|
||||||
|
- `page` اختیاری
|
||||||
|
- `page_size` اختیاری، حداکثر `100`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"source": "free_text",
|
||||||
|
"source_label": "متن آزاد کاربر",
|
||||||
|
"title": "برنامه آبیاری گندم",
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-02-24T10:20:30Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"total_pages": 1,
|
||||||
|
"total_items": 1,
|
||||||
|
"has_next": false,
|
||||||
|
"has_previous": false,
|
||||||
|
"next": null,
|
||||||
|
"previous": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- فقط planهایی برگردانده میشوند که `is_deleted=False` باشند.
|
||||||
|
- ترتیب لیست از جدید به قدیم است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) دریافت جزئیات یک برنامه آبیاری
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- URL: `/api/irrigation/plans/{plan_uuid}/`
|
||||||
|
- Path param:
|
||||||
|
- `plan_uuid` الزامی
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"source": "free_text",
|
||||||
|
"source_label": "متن آزاد کاربر",
|
||||||
|
"title": "برنامه آبیاری گندم",
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2025-02-24T10:20:30Z",
|
||||||
|
"updated_at": "2025-02-24T10:20:30Z",
|
||||||
|
"plan_payload": {
|
||||||
|
"plan": {
|
||||||
|
"durationMinutes": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) حذف برنامه آبیاری
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `DELETE`
|
||||||
|
- URL: `/api/irrigation/plans/{plan_uuid}/`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"is_deleted": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- حذف بهصورت `soft delete` انجام میشود.
|
||||||
|
- در عمل:
|
||||||
|
- `is_deleted = true`
|
||||||
|
- `is_active = false`
|
||||||
|
- `deleted_at` مقداردهی میشود
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) تغییر وضعیت فعال بودن برنامه آبیاری
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
- Method: `PATCH`
|
||||||
|
- URL: `/api/irrigation/plans/{plan_uuid}/status/`
|
||||||
|
- Body:
|
||||||
|
- `is_active` الزامی، `boolean`
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"is_active": [
|
||||||
|
"This field is required."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not Found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Plan not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- `GET /api/irrigation/plans/` لیست برنامهها
|
||||||
|
- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه
|
||||||
|
- `DELETE /api/irrigation/plans/{plan_uuid}/` حذف نرم برنامه
|
||||||
|
- `PATCH /api/irrigation/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه
|
||||||
@@ -3,7 +3,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("irrigation", "0001_initial"),
|
("irrigation_recommendation", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("irrigation_recommendation", "0002_recommendation_status_and_growth_stage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="IrrigationPlan",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)),
|
||||||
|
("title", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("crop_id", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("growth_stage", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("plan_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("request_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("response_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("is_active", models.BooleanField(db_index=True, default=True)),
|
||||||
|
("is_deleted", models.BooleanField(db_index=True, default=False)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"farm",
|
||||||
|
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="irrigation_plans", to="farm_hub.farmhub"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recommendation",
|
||||||
|
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="irrigation_recommendation.irrigationrecommendationrequest"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "irrigation_plans",
|
||||||
|
"ordering": ["-created_at", "-id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
@@ -43,3 +44,51 @@ class IrrigationRecommendationRequest(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.task_id or str(self.uuid)
|
return self.task_id or str(self.uuid)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlan(models.Model):
|
||||||
|
SOURCE_RECOMMENDATION = "recommendation"
|
||||||
|
SOURCE_FREE_TEXT = "free_text"
|
||||||
|
SOURCE_CHOICES = (
|
||||||
|
(SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"),
|
||||||
|
(SOURCE_FREE_TEXT, "متن آزاد کاربر"),
|
||||||
|
)
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||||
|
farm = models.ForeignKey(
|
||||||
|
FarmHub,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="irrigation_plans",
|
||||||
|
)
|
||||||
|
source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True)
|
||||||
|
recommendation = models.ForeignKey(
|
||||||
|
IrrigationRecommendationRequest,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="plans",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
crop_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
plan_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "irrigation_plans"
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title or self.crop_id or str(self.uuid)
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
self.is_deleted = True
|
||||||
|
self.is_active = False
|
||||||
|
self.deleted_at = timezone.now()
|
||||||
|
self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"])
|
||||||
|
|||||||
@@ -117,3 +117,39 @@ class IrrigationRecommendResponseDataSerializer(serializers.Serializer):
|
|||||||
water_balance = serializers.DictField(read_only=True)
|
water_balance = serializers.DictField(read_only=True)
|
||||||
timeline = serializers.ListField(child=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)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanListQuerySerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های آبیاری.")
|
||||||
|
page = serializers.IntegerField(required=False, min_value=1)
|
||||||
|
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanListItemSerializer(serializers.Serializer):
|
||||||
|
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||||
|
source = serializers.CharField(read_only=True)
|
||||||
|
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||||
|
title = serializers.CharField(read_only=True)
|
||||||
|
crop_id = serializers.CharField(read_only=True)
|
||||||
|
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||||
|
growth_stage = serializers.CharField(read_only=True)
|
||||||
|
is_active = serializers.BooleanField(read_only=True)
|
||||||
|
created_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanDetailSerializer(serializers.Serializer):
|
||||||
|
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||||
|
source = serializers.CharField(read_only=True)
|
||||||
|
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||||
|
title = serializers.CharField(read_only=True)
|
||||||
|
crop_id = serializers.CharField(read_only=True)
|
||||||
|
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||||
|
growth_stage = serializers.CharField(read_only=True)
|
||||||
|
is_active = serializers.BooleanField(read_only=True)
|
||||||
|
created_at = serializers.DateTimeField(read_only=True)
|
||||||
|
updated_at = serializers.DateTimeField(read_only=True)
|
||||||
|
plan_payload = serializers.DictField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanStatusUpdateSerializer(serializers.Serializer):
|
||||||
|
is_active = serializers.BooleanField(required=True)
|
||||||
|
|||||||
+42
-1
@@ -1,7 +1,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
|
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
|
||||||
from .models import IrrigationRecommendationRequest
|
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||||
|
|
||||||
|
|
||||||
def _extract_result(response_payload):
|
def _extract_result(response_payload):
|
||||||
@@ -37,6 +37,47 @@ def _get_latest_result(farm):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_plan_payload(farm):
|
||||||
|
if farm is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
plan = (
|
||||||
|
IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False)
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if plan is None or not isinstance(plan.plan_payload, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return deepcopy(plan.plan_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def build_active_plan_context(farm):
|
||||||
|
plan_payload = get_active_plan_payload(farm)
|
||||||
|
if not plan_payload:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context = {"plan_payload": plan_payload}
|
||||||
|
|
||||||
|
plan = _normalize_plan(plan_payload.get("plan"))
|
||||||
|
if plan:
|
||||||
|
context["plan"] = plan
|
||||||
|
|
||||||
|
water_balance = _normalize_water_balance(plan_payload.get("water_balance"))
|
||||||
|
if water_balance:
|
||||||
|
context["water_balance"] = water_balance
|
||||||
|
|
||||||
|
timeline = _normalize_timeline(plan_payload.get("timeline"))
|
||||||
|
if timeline:
|
||||||
|
context["timeline"] = timeline
|
||||||
|
|
||||||
|
sections = _normalize_sections(plan_payload.get("sections"))
|
||||||
|
if sections:
|
||||||
|
context["sections"] = sections
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
def _normalize_plan(plan):
|
def _normalize_plan(plan):
|
||||||
if not isinstance(plan, dict):
|
if not isinstance(plan, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
+122
-1
@@ -7,9 +7,12 @@ 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 .models import IrrigationRecommendationRequest
|
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||||
from .views import (
|
from .views import (
|
||||||
IrrigationMethodListView,
|
IrrigationMethodListView,
|
||||||
|
IrrigationPlanDetailView,
|
||||||
|
IrrigationPlanListView,
|
||||||
|
IrrigationPlanStatusView,
|
||||||
PlanFromTextView,
|
PlanFromTextView,
|
||||||
RecommendView,
|
RecommendView,
|
||||||
RecommendationDetailView,
|
RecommendationDetailView,
|
||||||
@@ -132,6 +135,10 @@ class IrrigationPlanFromTextViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["data"]["status"], "completed")
|
self.assertEqual(response.data["data"]["status"], "completed")
|
||||||
|
self.assertEqual(IrrigationPlan.objects.count(), 1)
|
||||||
|
plan = IrrigationPlan.objects.get()
|
||||||
|
self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT)
|
||||||
|
self.assertEqual(plan.crop_id, "گوجه فرنگی")
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/irrigation/plan-from-text/",
|
"/api/irrigation/plan-from-text/",
|
||||||
@@ -302,6 +309,11 @@ class RecommendViewTests(TestCase):
|
|||||||
self.assertIn("recommendation_uuid", response.data["data"])
|
self.assertIn("recommendation_uuid", response.data["data"])
|
||||||
self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION)
|
self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION)
|
||||||
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
|
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
|
||||||
|
self.assertEqual(IrrigationPlan.objects.count(), 1)
|
||||||
|
plan = IrrigationPlan.objects.get()
|
||||||
|
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
|
||||||
|
self.assertTrue(plan.is_active)
|
||||||
|
self.assertFalse(plan.is_deleted)
|
||||||
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
|
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
|
||||||
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
|
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"]["timeline"][0]["step_number"], 1)
|
||||||
@@ -320,6 +332,39 @@ class RecommendViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("irrigation.views.external_api_request")
|
||||||
|
def test_post_includes_active_irrigation_plan_in_ai_payload(self, mock_external_api_request):
|
||||||
|
IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه فعال",
|
||||||
|
crop_id="گوجه فرنگی",
|
||||||
|
growth_stage="گلدهی",
|
||||||
|
plan_payload={
|
||||||
|
"plan": {"frequencyPerWeek": 2, "durationMinutes": 25, "bestTimeOfDay": "صبح"},
|
||||||
|
"water_balance": {"active_kc": 0.82, "daily": []},
|
||||||
|
"timeline": [{"step_number": 1, "title": "مرحله", "description": "توضیح"}],
|
||||||
|
"sections": [{"type": "warning", "title": "هشدار", "content": "متن"}],
|
||||||
|
},
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {"plan": {}}}})
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/irrigation/recommend/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه فرنگی", "growth_stage": "گلدهی"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RecommendView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||||
|
self.assertIn("active_irrigation_plan", sent_payload)
|
||||||
|
self.assertEqual(sent_payload["active_irrigation_plan"]["plan"]["durationMinutes"], 25)
|
||||||
|
self.assertEqual(sent_payload["active_irrigation_plan"]["water_balance"]["active_kc"], 0.82)
|
||||||
|
|
||||||
|
|
||||||
class IrrigationRecommendationHistoryTests(TestCase):
|
class IrrigationRecommendationHistoryTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -428,6 +473,7 @@ class IrrigationRecommendationHistoryTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response.data["msg"], "Recommendation not found.")
|
self.assertEqual(response.data["msg"], "Recommendation not found.")
|
||||||
|
|
||||||
@patch("irrigation.views.external_api_request")
|
@patch("irrigation.views.external_api_request")
|
||||||
def test_post_accepts_sensor_uuid_as_farm_uuid_alias(self, mock_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(
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
@@ -459,3 +505,78 @@ class IrrigationRecommendationHistoryTests(TestCase):
|
|||||||
"irrigation_method_name": "آبیاری قطره ای",
|
"irrigation_method_name": "آبیاری قطره ای",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="irrigation-plan-user",
|
||||||
|
password="secret123",
|
||||||
|
email="irrigation-plan@example.com",
|
||||||
|
phone_number="09124445566",
|
||||||
|
)
|
||||||
|
self.farm_type = FarmType.objects.create(name="زراعی")
|
||||||
|
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Irrigation Plan Farm")
|
||||||
|
self.plan = IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه آبیاری نمونه",
|
||||||
|
crop_id="گندم",
|
||||||
|
growth_stage="flowering",
|
||||||
|
plan_payload={"plan": {"durationMinutes": 25}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plan_list_returns_non_deleted_plans(self):
|
||||||
|
IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_RECOMMENDATION,
|
||||||
|
title="حذف شده",
|
||||||
|
is_deleted=True,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/irrigation/plans/?farm_uuid={self.farm.farm_uuid}")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = IrrigationPlanListView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["code"], 200)
|
||||||
|
self.assertEqual(len(response.data["data"]), 1)
|
||||||
|
self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid))
|
||||||
|
|
||||||
|
def test_plan_detail_returns_plan_payload(self):
|
||||||
|
request = self.factory.get(f"/api/irrigation/plans/{self.plan.uuid}/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid))
|
||||||
|
self.assertEqual(response.data["data"]["plan_payload"]["plan"]["durationMinutes"], 25)
|
||||||
|
|
||||||
|
def test_plan_delete_is_soft_delete(self):
|
||||||
|
request = self.factory.delete(f"/api/irrigation/plans/{self.plan.uuid}/")
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertTrue(self.plan.is_deleted)
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|
||||||
|
def test_plan_status_patch_updates_is_active(self):
|
||||||
|
request = self.factory.patch(
|
||||||
|
f"/api/irrigation/plans/{self.plan.uuid}/status/",
|
||||||
|
{"is_active": False},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.plan.refresh_from_db()
|
||||||
|
self.assertFalse(self.plan.is_active)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
ConfigView,
|
ConfigView,
|
||||||
IrrigationMethodListView,
|
IrrigationMethodListView,
|
||||||
|
IrrigationPlanDetailView,
|
||||||
|
IrrigationPlanListView,
|
||||||
|
IrrigationPlanStatusView,
|
||||||
PlanFromTextView,
|
PlanFromTextView,
|
||||||
RecommendationDetailView,
|
RecommendationDetailView,
|
||||||
RecommendationListView,
|
RecommendationListView,
|
||||||
@@ -13,6 +16,9 @@ from .views import (
|
|||||||
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("plans/", IrrigationPlanListView.as_view(), name="irrigation-plan-list"),
|
||||||
|
path("plans/<uuid:plan_uuid>/", IrrigationPlanDetailView.as_view(), name="irrigation-plan-detail"),
|
||||||
|
path("plans/<uuid:plan_uuid>/status/", IrrigationPlanStatusView.as_view(), name="irrigation-plan-status"),
|
||||||
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"),
|
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"),
|
||||||
path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"),
|
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"),
|
||||||
|
|||||||
+172
-3
@@ -18,11 +18,15 @@ from farm_hub.models import FarmHub
|
|||||||
from water.serializers import WaterStressIndexSerializer
|
from water.serializers import WaterStressIndexSerializer
|
||||||
from water.views import WaterStressIndexView
|
from water.views import WaterStressIndexView
|
||||||
from .mock_data import CONFIG_RESPONSE_DATA
|
from .mock_data import CONFIG_RESPONSE_DATA
|
||||||
from .models import IrrigationRecommendationRequest
|
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
FreeTextPlanParserRequestSerializer,
|
FreeTextPlanParserRequestSerializer,
|
||||||
FreeTextPlanParserResponseDataSerializer,
|
FreeTextPlanParserResponseDataSerializer,
|
||||||
IrrigationMethodSerializer,
|
IrrigationMethodSerializer,
|
||||||
|
IrrigationPlanDetailSerializer,
|
||||||
|
IrrigationPlanListItemSerializer,
|
||||||
|
IrrigationPlanListQuerySerializer,
|
||||||
|
IrrigationPlanStatusUpdateSerializer,
|
||||||
IrrigationRecommendationListItemSerializer,
|
IrrigationRecommendationListItemSerializer,
|
||||||
IrrigationRecommendationListQuerySerializer,
|
IrrigationRecommendationListQuerySerializer,
|
||||||
IrrigationRecommendRequestSerializer,
|
IrrigationRecommendRequestSerializer,
|
||||||
@@ -30,6 +34,7 @@ from .serializers import (
|
|||||||
WaterStressRequestSerializer,
|
WaterStressRequestSerializer,
|
||||||
)
|
)
|
||||||
from .services import build_recommendation_response
|
from .services import build_recommendation_response
|
||||||
|
from .services import build_active_plan_context
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -157,6 +162,35 @@ class IrrigationMethodListView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class RecommendView(FarmAccessMixin, APIView):
|
class RecommendView(FarmAccessMixin, APIView):
|
||||||
|
@staticmethod
|
||||||
|
def _build_plan_title(crop_id, growth_stage, plan):
|
||||||
|
best_time = ""
|
||||||
|
if isinstance(plan, dict):
|
||||||
|
best_time = str(plan.get("bestTimeOfDay") or "").strip()
|
||||||
|
parts = [part for part in [crop_id, growth_stage, best_time] if part]
|
||||||
|
return " - ".join(parts) if parts else "برنامه آبیاری"
|
||||||
|
|
||||||
|
def _create_plan_from_recommendation(self, recommendation, recommendation_data):
|
||||||
|
IrrigationPlan.objects.create(
|
||||||
|
farm=recommendation.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_RECOMMENDATION,
|
||||||
|
recommendation=recommendation,
|
||||||
|
title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, recommendation_data.get("plan")),
|
||||||
|
crop_id=recommendation.crop_id,
|
||||||
|
growth_stage=recommendation.growth_stage,
|
||||||
|
plan_payload=recommendation_data,
|
||||||
|
request_payload=recommendation.request_payload,
|
||||||
|
response_payload=recommendation.response_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _enrich_ai_payload(payload, farm):
|
||||||
|
enriched_payload = payload.copy()
|
||||||
|
active_plan_context = build_active_plan_context(farm)
|
||||||
|
if active_plan_context:
|
||||||
|
enriched_payload["active_irrigation_plan"] = active_plan_context
|
||||||
|
return enriched_payload
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Irrigation Recommendation"],
|
tags=["Irrigation Recommendation"],
|
||||||
request=IrrigationRecommendRequestSerializer,
|
request=IrrigationRecommendRequestSerializer,
|
||||||
@@ -178,11 +212,13 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
if farm.irrigation_method_id is not None:
|
if farm.irrigation_method_id is not None:
|
||||||
payload["irrigation_method_id"] = farm.irrigation_method_id
|
payload["irrigation_method_id"] = farm.irrigation_method_id
|
||||||
|
|
||||||
|
ai_payload = self._enrich_ai_payload(payload, farm)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/irrigation/recommend/",
|
"/api/irrigation/recommend/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload=payload,
|
payload=ai_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||||
@@ -206,7 +242,7 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
if adapter_response.status_code < 400
|
if adapter_response.status_code < 400
|
||||||
else IrrigationRecommendationRequest.STATUS_ERROR
|
else IrrigationRecommendationRequest.STATUS_ERROR
|
||||||
),
|
),
|
||||||
request_payload=payload,
|
request_payload=ai_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},
|
||||||
)
|
)
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
@@ -219,6 +255,8 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._create_plan_from_recommendation(recommendation, recommendation_data)
|
||||||
|
|
||||||
recommendation_data["recommendation_uuid"] = str(recommendation.uuid)
|
recommendation_data["recommendation_uuid"] = str(recommendation.uuid)
|
||||||
recommendation_data["crop_id"] = recommendation.crop_id
|
recommendation_data["crop_id"] = recommendation.crop_id
|
||||||
recommendation_data["plant_name"] = recommendation.crop_id
|
recommendation_data["plant_name"] = recommendation.crop_id
|
||||||
@@ -358,6 +396,30 @@ class WaterStressView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class PlanFromTextView(FarmAccessMixin, APIView):
|
class PlanFromTextView(FarmAccessMixin, APIView):
|
||||||
|
@staticmethod
|
||||||
|
def _extract_final_plan(response_data):
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return None
|
||||||
|
data = response_data.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
final_plan = data.get("final_plan")
|
||||||
|
if isinstance(final_plan, dict) and final_plan:
|
||||||
|
return final_plan
|
||||||
|
final_plan = response_data.get("final_plan")
|
||||||
|
if isinstance(final_plan, dict) and final_plan:
|
||||||
|
return final_plan
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_free_text_plan_title(final_plan):
|
||||||
|
if not isinstance(final_plan, dict):
|
||||||
|
return "برنامه آبیاری"
|
||||||
|
for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"):
|
||||||
|
value = str(final_plan.get(key, "")).strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return "برنامه آبیاری"
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Irrigation Recommendation"],
|
tags=["Irrigation Recommendation"],
|
||||||
request=FreeTextPlanParserRequestSerializer,
|
request=FreeTextPlanParserRequestSerializer,
|
||||||
@@ -387,7 +449,114 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
final_plan = self._extract_final_plan(response_data)
|
||||||
|
if final_plan and farm_uuid:
|
||||||
|
IrrigationPlan.objects.create(
|
||||||
|
farm=farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title=self._build_free_text_plan_title(final_plan),
|
||||||
|
crop_id=str(
|
||||||
|
final_plan.get("crop_id")
|
||||||
|
or final_plan.get("crop_name")
|
||||||
|
or final_plan.get("plant_name")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
growth_stage=str(final_plan.get("growth_stage") or "").strip(),
|
||||||
|
plan_payload=final_plan,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload=response_data,
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanListView(FarmAccessMixin, APIView):
|
||||||
|
pagination_class = IrrigationRecommendationPagination
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Irrigation Recommendation"],
|
||||||
|
parameters=[IrrigationPlanListQuerySerializer],
|
||||||
|
responses={200: code_response("IrrigationPlanListResponse")},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
serializer = IrrigationPlanListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
|
||||||
|
plans = farm.irrigation_plans.filter(is_deleted=False).order_by("-created_at", "-id")
|
||||||
|
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
page = paginator.paginate_queryset(plans, request, view=self)
|
||||||
|
data = IrrigationPlanListItemSerializer(page, many=True).data
|
||||||
|
return paginator.get_paginated_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanDetailView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Irrigation Recommendation"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="plan_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.PATH,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: code_response("IrrigationPlanDetailResponse", data=IrrigationPlanDetailSerializer())},
|
||||||
|
)
|
||||||
|
def get(self, request, plan_uuid):
|
||||||
|
plan = IrrigationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).select_related("farm").first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
data = IrrigationPlanDetailSerializer(plan).data
|
||||||
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Irrigation Recommendation"],
|
||||||
|
responses={200: status_response("IrrigationPlanDeleteResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def delete(self, request, plan_uuid):
|
||||||
|
plan = IrrigationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
plan.soft_delete()
|
||||||
|
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationPlanStatusView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Irrigation Recommendation"],
|
||||||
|
request=IrrigationPlanStatusUpdateSerializer,
|
||||||
|
responses={200: code_response("IrrigationPlanStatusResponse", data=serializers.JSONField())},
|
||||||
|
)
|
||||||
|
def patch(self, request, plan_uuid):
|
||||||
|
serializer = IrrigationPlanStatusUpdateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
plan = IrrigationPlan.objects.filter(
|
||||||
|
uuid=plan_uuid,
|
||||||
|
farm__owner=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
plan.is_active = serializer.validated_data["is_active"]
|
||||||
|
plan.save(update_fields=["is_active", "updated_at"])
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ class CropSimulationRequestSerializer(serializers.Serializer):
|
|||||||
initial="11111111-1111-1111-1111-111111111111",
|
initial="11111111-1111-1111-1111-111111111111",
|
||||||
help_text="UUID مزرعه برای اجرای شبیهسازی.",
|
help_text="UUID مزرعه برای اجرای شبیهسازی.",
|
||||||
)
|
)
|
||||||
|
irrigation_plan_id = serializers.IntegerField(
|
||||||
|
required=False,
|
||||||
|
min_value=1,
|
||||||
|
help_text="شناسه داخلی برنامه آبیاری برای ارسال context به AI.",
|
||||||
|
)
|
||||||
|
fertilization_plan_id = serializers.IntegerField(
|
||||||
|
required=False,
|
||||||
|
min_value=1,
|
||||||
|
help_text="شناسه داخلی برنامه کودی برای ارسال context به AI.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GrowthSimulationRequestSerializer(serializers.Serializer):
|
class GrowthSimulationRequestSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
|
|||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType, Product
|
from farm_hub.models import FarmHub, FarmType, Product
|
||||||
|
from fertilization.models import FertilizationPlan
|
||||||
|
from irrigation.models import IrrigationPlan
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
CurrentFarmChartView,
|
CurrentFarmChartView,
|
||||||
@@ -378,6 +380,52 @@ class CropSimulationViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجهفرنگی"},
|
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجهفرنگی"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_current_farm_chart_includes_selected_plans(self, mock_external_api_request):
|
||||||
|
irrigation_plan = IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه آبیاری",
|
||||||
|
plan_payload={"plan": {"durationMinutes": 20}},
|
||||||
|
)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "series": []}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/yield-harvest/current-farm-chart/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid), "irrigation_plan_id": irrigation_plan.id},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||||
|
self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_harvest_prediction_includes_selected_plans(self, mock_external_api_request):
|
||||||
|
fertilization_plan = FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه کودی",
|
||||||
|
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-151515"}},
|
||||||
|
)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/yield-harvest/harvest-prediction/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid), "fertilization_plan_id": fertilization_plan.id},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||||
|
self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id)
|
||||||
|
|
||||||
@patch("yield_harvest.views.external_api_request")
|
@patch("yield_harvest.views.external_api_request")
|
||||||
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
mock_external_api_request.return_value = AdapterResponse(
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
@@ -470,6 +518,72 @@ class CropSimulationViewTests(TestCase):
|
|||||||
payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجهفرنگی"},
|
payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجهفرنگی"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_yield_prediction_includes_selected_irrigation_and_fertilization_plans(self, mock_external_api_request):
|
||||||
|
irrigation_plan = IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه آبیاری",
|
||||||
|
crop_id="گوجهفرنگی",
|
||||||
|
growth_stage="flowering",
|
||||||
|
plan_payload={"plan": {"durationMinutes": 30}},
|
||||||
|
request_payload={"source": "manual"},
|
||||||
|
response_payload={"ok": True},
|
||||||
|
)
|
||||||
|
fertilization_plan = FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه کودی",
|
||||||
|
crop_id="گوجهفرنگی",
|
||||||
|
growth_stage="flowering",
|
||||||
|
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-202020"}},
|
||||||
|
request_payload={"source": "manual"},
|
||||||
|
response_payload={"ok": True},
|
||||||
|
)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/yield-harvest/yield-prediction/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"irrigation_plan_id": irrigation_plan.id,
|
||||||
|
"fertilization_plan_id": fertilization_plan.id,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
|
||||||
|
self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id)
|
||||||
|
self.assertEqual(sent_payload["irrigation_plan"]["plan_payload"]["plan"]["durationMinutes"], 30)
|
||||||
|
self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id)
|
||||||
|
self.assertEqual(
|
||||||
|
sent_payload["fertilization_plan"]["plan_payload"]["primary_recommendation"]["fertilizer_code"],
|
||||||
|
"npk-202020",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_yield_prediction_rejects_foreign_plan_ids(self):
|
||||||
|
other_irrigation_plan = IrrigationPlan.objects.create(
|
||||||
|
farm=self.other_farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="other irrigation",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.post(
|
||||||
|
"/api/yield-harvest/yield-prediction/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"irrigation_plan_id": other_irrigation_plan.id,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.json()["data"]["irrigation_plan_id"][0], "Irrigation plan not found.")
|
||||||
|
|
||||||
@patch("yield_harvest.views.external_api_request")
|
@patch("yield_harvest.views.external_api_request")
|
||||||
def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
|
||||||
mock_external_api_request.return_value = AdapterResponse(
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
@@ -520,6 +634,34 @@ class CropSimulationViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||||
|
|
||||||
|
@patch("yield_harvest.views.external_api_request")
|
||||||
|
def test_yield_harvest_summary_includes_selected_plans_in_query(self, mock_external_api_request):
|
||||||
|
irrigation_plan = IrrigationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه آبیاری",
|
||||||
|
plan_payload={"plan": {"durationMinutes": 18}},
|
||||||
|
)
|
||||||
|
fertilization_plan = FertilizationPlan.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
|
title="برنامه کودی",
|
||||||
|
plan_payload={"primary_recommendation": {"fertilizer_code": "npk-111111"}},
|
||||||
|
)
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.api_client.get(
|
||||||
|
f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&irrigation_plan_id={irrigation_plan.id}&fertilization_plan_id={fertilization_plan.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
sent_query = mock_external_api_request.call_args.kwargs["query"]
|
||||||
|
self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id)
|
||||||
|
self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id)
|
||||||
|
|
||||||
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
def test_crop_simulation_rejects_foreign_farm_uuid(self):
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
"/api/yield-harvest/crop-simulation/yield-prediction/",
|
||||||
|
|||||||
+138
-12
@@ -9,6 +9,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|||||||
from config.swagger import code_response, farm_uuid_query_param
|
from config.swagger import code_response, farm_uuid_query_param
|
||||||
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 fertilization.models import FertilizationPlan
|
||||||
|
from irrigation.models import IrrigationPlan
|
||||||
from .models import YieldHarvestPredictionLog
|
from .models import YieldHarvestPredictionLog
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
CropSimulationRequestSerializer,
|
CropSimulationRequestSerializer,
|
||||||
@@ -77,6 +79,20 @@ class YieldHarvestSummaryView(APIView):
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
|
irrigation_plan_id, irrigation_plan_error = CropSimulationBaseView._parse_optional_plan_id(
|
||||||
|
request.query_params.get("irrigation_plan_id"),
|
||||||
|
"irrigation_plan_id",
|
||||||
|
)
|
||||||
|
if irrigation_plan_error is not None:
|
||||||
|
return irrigation_plan_error
|
||||||
|
|
||||||
|
fertilization_plan_id, fertilization_plan_error = CropSimulationBaseView._parse_optional_plan_id(
|
||||||
|
request.query_params.get("fertilization_plan_id"),
|
||||||
|
"fertilization_plan_id",
|
||||||
|
)
|
||||||
|
if fertilization_plan_error is not None:
|
||||||
|
return fertilization_plan_error
|
||||||
|
|
||||||
query = {"farm_uuid": str(farm.farm_uuid)}
|
query = {"farm_uuid": str(farm.farm_uuid)}
|
||||||
if request.query_params.get("season_year"):
|
if request.query_params.get("season_year"):
|
||||||
query["season_year"] = request.query_params.get("season_year")
|
query["season_year"] = request.query_params.get("season_year")
|
||||||
@@ -85,6 +101,15 @@ class YieldHarvestSummaryView(APIView):
|
|||||||
if request.query_params.get("include_narrative") is not None:
|
if request.query_params.get("include_narrative") is not None:
|
||||||
query["include_narrative"] = request.query_params.get("include_narrative")
|
query["include_narrative"] = request.query_params.get("include_narrative")
|
||||||
|
|
||||||
|
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||||
|
farm,
|
||||||
|
irrigation_plan_id=irrigation_plan_id,
|
||||||
|
fertilization_plan_id=fertilization_plan_id,
|
||||||
|
)
|
||||||
|
if plan_error is not None:
|
||||||
|
return plan_error
|
||||||
|
query.update(ai_payload)
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/crop-simulation/yield-harvest-summary/",
|
"/api/crop-simulation/yield-harvest-summary/",
|
||||||
@@ -191,6 +216,98 @@ class CropSimulationBaseView(APIView):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_irrigation_plan_or_error(farm, plan_id):
|
||||||
|
if not plan_id:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
plan = IrrigationPlan.objects.filter(
|
||||||
|
id=plan_id,
|
||||||
|
farm=farm,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return None, Response(
|
||||||
|
{"code": 404, "msg": "error", "data": {"irrigation_plan_id": ["Irrigation plan not found."]}},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return plan, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_fertilization_plan_or_error(farm, plan_id):
|
||||||
|
if not plan_id:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
plan = FertilizationPlan.objects.filter(
|
||||||
|
id=plan_id,
|
||||||
|
farm=farm,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if plan is None:
|
||||||
|
return None, Response(
|
||||||
|
{"code": 404, "msg": "error", "data": {"fertilization_plan_id": ["Fertilization plan not found."]}},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return plan, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_plan_payload(plan):
|
||||||
|
if plan is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": plan.id,
|
||||||
|
"uuid": str(plan.uuid),
|
||||||
|
"source": plan.source,
|
||||||
|
"title": plan.title,
|
||||||
|
"crop_id": plan.crop_id,
|
||||||
|
"growth_stage": plan.growth_stage,
|
||||||
|
"is_active": plan.is_active,
|
||||||
|
"plan_payload": plan.plan_payload if isinstance(plan.plan_payload, dict) else {},
|
||||||
|
"request_payload": plan.request_payload if isinstance(plan.request_payload, dict) else {},
|
||||||
|
"response_payload": plan.response_payload if isinstance(plan.response_payload, dict) else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_ai_payload_with_selected_plans(self, farm, irrigation_plan_id=None, fertilization_plan_id=None):
|
||||||
|
irrigation_plan, irrigation_error = self._get_irrigation_plan_or_error(farm, irrigation_plan_id)
|
||||||
|
if irrigation_error is not None:
|
||||||
|
return None, irrigation_error
|
||||||
|
|
||||||
|
fertilization_plan, fertilization_error = self._get_fertilization_plan_or_error(
|
||||||
|
farm, fertilization_plan_id
|
||||||
|
)
|
||||||
|
if fertilization_error is not None:
|
||||||
|
return None, fertilization_error
|
||||||
|
|
||||||
|
ai_payload = {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"plant_name": self._get_first_farm_product_name(farm),
|
||||||
|
}
|
||||||
|
if irrigation_plan is not None:
|
||||||
|
ai_payload["irrigation_plan"] = self._build_plan_payload(irrigation_plan)
|
||||||
|
if fertilization_plan is not None:
|
||||||
|
ai_payload["fertilization_plan"] = self._build_plan_payload(fertilization_plan)
|
||||||
|
|
||||||
|
return ai_payload, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_optional_plan_id(raw_value, field_name):
|
||||||
|
if raw_value in (None, ""):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
parsed_value = int(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, Response(
|
||||||
|
{"code": 400, "msg": "error", "data": {field_name: ["A valid integer is required."]}},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if parsed_value < 1:
|
||||||
|
return None, Response(
|
||||||
|
{"code": 400, "msg": "error", "data": {field_name: ["Ensure this value is greater than or equal to 1."]}},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
return parsed_value, None
|
||||||
|
|
||||||
|
|
||||||
class CurrentFarmChartView(CropSimulationBaseView):
|
class CurrentFarmChartView(CropSimulationBaseView):
|
||||||
ai_path = "/api/crop-simulation/current-farm-chart/"
|
ai_path = "/api/crop-simulation/current-farm-chart/"
|
||||||
@@ -209,10 +326,13 @@ class CurrentFarmChartView(CropSimulationBaseView):
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
ai_payload = {
|
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
farm,
|
||||||
"plant_name": self._get_first_farm_product_name(farm),
|
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||||
}
|
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||||
|
)
|
||||||
|
if plan_error is not None:
|
||||||
|
return plan_error
|
||||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||||
|
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
@@ -241,10 +361,13 @@ class HarvestPredictionView(CropSimulationBaseView):
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
ai_payload = {
|
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
farm,
|
||||||
"plant_name": self._get_first_farm_product_name(farm),
|
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||||
}
|
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||||
|
)
|
||||||
|
if plan_error is not None:
|
||||||
|
return plan_error
|
||||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||||
|
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
@@ -273,10 +396,13 @@ class YieldPredictionView(CropSimulationBaseView):
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response
|
return error_response
|
||||||
|
|
||||||
ai_payload = {
|
ai_payload, plan_error = self._build_ai_payload_with_selected_plans(
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
farm,
|
||||||
"plant_name": self._get_first_farm_product_name(farm),
|
irrigation_plan_id=payload.get("irrigation_plan_id"),
|
||||||
}
|
fertilization_plan_id=payload.get("fertilization_plan_id"),
|
||||||
|
)
|
||||||
|
if plan_error is not None:
|
||||||
|
return plan_error
|
||||||
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
|
||||||
|
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
|
|||||||
Reference in New Issue
Block a user