UPDATE
This commit is contained in:
@@ -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):
|
||||
dependencies = [
|
||||
("irrigation", "0001_initial"),
|
||||
("irrigation_recommendation", "0001_initial"),
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
@@ -43,3 +44,51 @@ class IrrigationRecommendationRequest(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.task_id or str(self.uuid)
|
||||
|
||||
|
||||
class IrrigationPlan(models.Model):
|
||||
SOURCE_RECOMMENDATION = "recommendation"
|
||||
SOURCE_FREE_TEXT = "free_text"
|
||||
SOURCE_CHOICES = (
|
||||
(SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"),
|
||||
(SOURCE_FREE_TEXT, "متن آزاد کاربر"),
|
||||
)
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey(
|
||||
FarmHub,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="irrigation_plans",
|
||||
)
|
||||
source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True)
|
||||
recommendation = models.ForeignKey(
|
||||
IrrigationRecommendationRequest,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="plans",
|
||||
)
|
||||
title = models.CharField(max_length=255, blank=True, default="")
|
||||
crop_id = models.CharField(max_length=255, blank=True, default="")
|
||||
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||
plan_payload = models.JSONField(default=dict, blank=True)
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
is_active = models.BooleanField(default=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)
|
||||
timeline = serializers.ListField(child=serializers.DictField(), read_only=True)
|
||||
sections = serializers.ListField(child=serializers.DictField(), read_only=True)
|
||||
|
||||
|
||||
class IrrigationPlanListQuerySerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های آبیاری.")
|
||||
page = serializers.IntegerField(required=False, min_value=1)
|
||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100)
|
||||
|
||||
|
||||
class IrrigationPlanListItemSerializer(serializers.Serializer):
|
||||
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||
source = serializers.CharField(read_only=True)
|
||||
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||
title = serializers.CharField(read_only=True)
|
||||
crop_id = serializers.CharField(read_only=True)
|
||||
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||
growth_stage = serializers.CharField(read_only=True)
|
||||
is_active = serializers.BooleanField(read_only=True)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
|
||||
|
||||
class IrrigationPlanDetailSerializer(serializers.Serializer):
|
||||
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||
source = serializers.CharField(read_only=True)
|
||||
source_label = serializers.CharField(source="get_source_display", read_only=True)
|
||||
title = serializers.CharField(read_only=True)
|
||||
crop_id = serializers.CharField(read_only=True)
|
||||
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||
growth_stage = serializers.CharField(read_only=True)
|
||||
is_active = serializers.BooleanField(read_only=True)
|
||||
created_at = serializers.DateTimeField(read_only=True)
|
||||
updated_at = serializers.DateTimeField(read_only=True)
|
||||
plan_payload = serializers.DictField(read_only=True)
|
||||
|
||||
|
||||
class IrrigationPlanStatusUpdateSerializer(serializers.Serializer):
|
||||
is_active = serializers.BooleanField(required=True)
|
||||
|
||||
+42
-1
@@ -1,7 +1,7 @@
|
||||
from copy import deepcopy
|
||||
|
||||
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):
|
||||
@@ -37,6 +37,47 @@ def _get_latest_result(farm):
|
||||
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):
|
||||
if not isinstance(plan, dict):
|
||||
return {}
|
||||
|
||||
+122
-1
@@ -7,9 +7,12 @@ from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
|
||||
from .models import IrrigationRecommendationRequest
|
||||
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||
from .views import (
|
||||
IrrigationMethodListView,
|
||||
IrrigationPlanDetailView,
|
||||
IrrigationPlanListView,
|
||||
IrrigationPlanStatusView,
|
||||
PlanFromTextView,
|
||||
RecommendView,
|
||||
RecommendationDetailView,
|
||||
@@ -132,6 +135,10 @@ class IrrigationPlanFromTextViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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(
|
||||
"ai",
|
||||
"/api/irrigation/plan-from-text/",
|
||||
@@ -302,6 +309,11 @@ class RecommendViewTests(TestCase):
|
||||
self.assertIn("recommendation_uuid", response.data["data"])
|
||||
self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION)
|
||||
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
|
||||
self.assertEqual(IrrigationPlan.objects.count(), 1)
|
||||
plan = IrrigationPlan.objects.get()
|
||||
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
|
||||
self.assertTrue(plan.is_active)
|
||||
self.assertFalse(plan.is_deleted)
|
||||
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
|
||||
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
|
||||
self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1)
|
||||
@@ -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):
|
||||
def setUp(self):
|
||||
@@ -428,6 +473,7 @@ class IrrigationRecommendationHistoryTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data["msg"], "Recommendation not found.")
|
||||
|
||||
@patch("irrigation.views.external_api_request")
|
||||
def test_post_accepts_sensor_uuid_as_farm_uuid_alias(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
@@ -459,3 +505,78 @@ class IrrigationRecommendationHistoryTests(TestCase):
|
||||
"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 (
|
||||
ConfigView,
|
||||
IrrigationMethodListView,
|
||||
IrrigationPlanDetailView,
|
||||
IrrigationPlanListView,
|
||||
IrrigationPlanStatusView,
|
||||
PlanFromTextView,
|
||||
RecommendationDetailView,
|
||||
RecommendationListView,
|
||||
@@ -13,6 +16,9 @@ from .views import (
|
||||
urlpatterns = [
|
||||
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
|
||||
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
|
||||
path("plans/", IrrigationPlanListView.as_view(), name="irrigation-plan-list"),
|
||||
path("plans/<uuid:plan_uuid>/", IrrigationPlanDetailView.as_view(), name="irrigation-plan-detail"),
|
||||
path("plans/<uuid:plan_uuid>/status/", IrrigationPlanStatusView.as_view(), name="irrigation-plan-status"),
|
||||
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"),
|
||||
path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"),
|
||||
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
|
||||
|
||||
+172
-3
@@ -18,11 +18,15 @@ from farm_hub.models import FarmHub
|
||||
from water.serializers import WaterStressIndexSerializer
|
||||
from water.views import WaterStressIndexView
|
||||
from .mock_data import CONFIG_RESPONSE_DATA
|
||||
from .models import IrrigationRecommendationRequest
|
||||
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||
from .serializers import (
|
||||
FreeTextPlanParserRequestSerializer,
|
||||
FreeTextPlanParserResponseDataSerializer,
|
||||
IrrigationMethodSerializer,
|
||||
IrrigationPlanDetailSerializer,
|
||||
IrrigationPlanListItemSerializer,
|
||||
IrrigationPlanListQuerySerializer,
|
||||
IrrigationPlanStatusUpdateSerializer,
|
||||
IrrigationRecommendationListItemSerializer,
|
||||
IrrigationRecommendationListQuerySerializer,
|
||||
IrrigationRecommendRequestSerializer,
|
||||
@@ -30,6 +34,7 @@ from .serializers import (
|
||||
WaterStressRequestSerializer,
|
||||
)
|
||||
from .services import build_recommendation_response
|
||||
from .services import build_active_plan_context
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -157,6 +162,35 @@ class IrrigationMethodListView(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(
|
||||
tags=["Irrigation Recommendation"],
|
||||
request=IrrigationRecommendRequestSerializer,
|
||||
@@ -178,11 +212,13 @@ class RecommendView(FarmAccessMixin, APIView):
|
||||
if farm.irrigation_method_id is not None:
|
||||
payload["irrigation_method_id"] = farm.irrigation_method_id
|
||||
|
||||
ai_payload = self._enrich_ai_payload(payload, farm)
|
||||
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/irrigation/recommend/",
|
||||
method="POST",
|
||||
payload=payload,
|
||||
payload=ai_payload,
|
||||
)
|
||||
|
||||
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
|
||||
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},
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
@@ -219,6 +255,8 @@ class RecommendView(FarmAccessMixin, APIView):
|
||||
status=adapter_response.status_code,
|
||||
)
|
||||
|
||||
self._create_plan_from_recommendation(recommendation, recommendation_data)
|
||||
|
||||
recommendation_data["recommendation_uuid"] = str(recommendation.uuid)
|
||||
recommendation_data["crop_id"] = recommendation.crop_id
|
||||
recommendation_data["plant_name"] = recommendation.crop_id
|
||||
@@ -358,6 +396,30 @@ class WaterStressView(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(
|
||||
tags=["Irrigation Recommendation"],
|
||||
request=FreeTextPlanParserRequestSerializer,
|
||||
@@ -387,7 +449,114 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
||||
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(
|
||||
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user