diff --git a/docs/fertilization_recommendation_frontend.md b/docs/fertilization_recommendation_frontend.md new file mode 100644 index 0000000..74fcbab --- /dev/null +++ b/docs/fertilization_recommendation_frontend.md @@ -0,0 +1,330 @@ +# Fertilization Recommendation History APIs + +این فایل برای تیم فرانت نوشته شده تا بتواند از APIهای history توصیه های کودهی استفاده کند. + +## وضعیت recommendation + +هر recommendation یک status دارد. + +### statusهای ممکن + +- `pending_confirmation` → `منتظر تایید` +- `in_progress` → `در حال مصرف` +- `completed` → `پایان یافته` + +### وضعیت فعلی سیستم + +فعلاً همه recommendationهای جدید و recommendationهای قبلی که migrate شده اند با وضعیت زیر ذخیره می شوند: + +- `pending_confirmation` +- برچسب نمایشی: `منتظر تایید` + +فرانت باید `status` را برای منطق برنامه و `status_label` را برای نمایش مستقیم استفاده کند. + +--- + +## 1) لیست توصیه های کودهی یک مزرعه + +### Endpoint + +`GET /api/fertilization/recommendations/?farm_uuid=` + +### کاربرد + +- نمایش history توصیه های کودهی یک مزرعه +- ساخت جدول یا لیست برای مشاهده توصیه های قبلی +- نمایش badge وضعیت recommendation +- ورود به صفحه جزئیات هر recommendation + +### Query Params + +- `farm_uuid`: شناسه مزرعه +- `crop_id`: شناسه یا نام محصول. این فیلد همان plant name است و مستقیم برای AI هم ارسال می شود +- `page`: شماره صفحه، شروع از `1` +- `page_size`: تعداد آیتم در هر صفحه، بین `1` تا `100` + +### هدرها + +- `Authorization: Bearer ` +- `Accept: application/json` + +### نمونه درخواست + +```bash +curl -X GET \ + 'http://localhost:8000/api/fertilization/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' +``` + +### نمونه پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "recommendation_uuid": "4d595ee0-9dbb-4c50-a871-2b4359d0d748", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "vegetative", + "fertilizer_type": "NPK", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-01-10T08:30:00Z" + }, + { + "recommendation_uuid": "bbdf0d50-0f78-4099-a4d3-b1c4aa54eeb9", + "crop_id": "ذرت", + "plant_name": "ذرت", + "growth_stage": "flowering", + "fertilizer_type": "Micronutrient", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-01-08T09:10:00Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 3, + "total_items": 25, + "has_next": true, + "has_previous": false, + "next": "http://localhost:8000/api/fertilization/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=10", + "previous": null + } +} +``` + +### فیلدهای `data[]` + +- `recommendation_uuid`: شناسه یکتای recommendation برای گرفتن جزئیات +- `crop_id`: شناسه یا نام محصول ثبت شده در recommendation +- `plant_name`: معادل نمایشی `crop_id` برای سازگاری با فرانت +- `growth_stage`: مرحله رشد در زمان ثبت recommendation +- `fertilizer_type`: نوع کود پیشنهادی مثل `NPK` +- `status`: کد وضعیت recommendation +- `status_label`: متن نمایشی وضعیت recommendation +- `requested_at`: زمان ثبت recommendation + +### فیلدهای `pagination` + +- `page`: صفحه فعلی +- `page_size`: تعداد آیتم در هر صفحه +- `total_pages`: تعداد کل صفحات +- `total_items`: تعداد کل recommendationها +- `has_next`: آیا صفحه بعدی وجود دارد یا نه +- `has_previous`: آیا صفحه قبلی وجود دارد یا نه +- `next`: لینک صفحه بعدی +- `previous`: لینک صفحه قبلی + +### پیشنهاد نمایش status در UI + +- `pending_confirmation` → badge زرد یا خاکستری روشن +- `in_progress` → badge آبی یا سبز +- `completed` → badge خاکستری یا سفید + +### خطاهای رایج + +#### مزرعه پیدا نشد + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +#### پارامترهای pagination نامعتبر + +```json +{ + "page": [ + "Ensure this value is greater than or equal to 1." + ] +} +``` + +--- + +## 2) جزئیات یک recommendation + +### Endpoint + +`GET /api/fertilization/recommendations//` + +### کاربرد + +- نمایش کامل جزئیات recommendation +- باز کردن صفحه detail یا modal recommendation +- replay کردن خروجی recommendation بدون نیاز به درخواست مجدد از AI + +### Path Param + +- `recommendation_uuid`: شناسه recommendation از API لیست + +### نکته مهم برای محصول + +- فیلد اصلی محصول در این ماژول `crop_id` است +- `crop_id` همان plant name است +- بک اند همان `crop_id` را مستقیم برای AI ارسال می کند +- `plant_name` در response فقط برای سازگاری فرانت نگه داشته شده و مقدارش برابر `crop_id` است + +### هدرها + +- `Authorization: Bearer ` +- `Accept: application/json` + +### نمونه درخواست + +```bash +curl -X GET \ + 'http://localhost:8000/api/fertilization/recommendations/4d595ee0-9dbb-4c50-a871-2b4359d0d748/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' +``` + +### نمونه پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "vegetative", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_name": "NPK 20-20-20", + "display_title": "کود کامل متعادل", + "fertilizer_type": "NPK", + "npk_ratio": { + "n": 20, + "p": 20, + "k": 20, + "label": "20-20-20" + }, + "application_method": { + "id": "fertigation", + "label": "کودآبیاری" + }, + "application_interval": { + "value": 14, + "unit": "day", + "label": "هر 14 روز" + }, + "dosage": { + "base_amount_per_hectare": 65, + "base_amount_per_square_meter": 0.0065, + "unit": "kg", + "label": "65 کیلوگرم در هکتار", + "calculation_basis": "engine-v2" + }, + "reasoning": "متعادل برای فاز رشد", + "summary": "مصرف منظم در این مرحله توصیه می شود" + }, + "nutrient_analysis": { + "macro": [ + { + "key": "n", + "name": "Nitrogen", + "value": 20, + "unit": "percent", + "description": "تقویت رشد رویشی" + } + ], + "micro": [] + }, + "application_guide": { + "safety_warning": "در ساعات خنک مصرف شود", + "steps": [ + { + "step_number": 1, + "title": "حل کردن", + "description": "کود را در آب حل کنید" + } + ] + }, + "alternative_recommendations": [ + { + "fertilizer_code": "npk-121236", + "fertilizer_name": "NPK 12-12-36", + "fertilizer_type": "NPK", + "usage_method": "fertigation", + "description": "برای نیاز پتاس بالا" + } + ], + "sections": [ + { + "type": "recommendation", + "title": "پیشنهاد اصلی", + "icon": "leaf", + "content": "NPK 20-20-20" + } + ] + } +} +``` + +### نکته مهم + +این response دقیقا همان ساختار endpoint زیر را برمی گرداند: + +`POST /api/fertilization/recommend/` + +یعنی فرانت می تواند برای صفحه detail همان componentهایی را استفاده کند که برای recommendation اصلی استفاده می کند. + +### خطای رایج + +#### recommendation پیدا نشد + +```json +{ + "code": 404, + "msg": "Recommendation not found." +} +``` + +--- + +## پیشنهاد پیاده سازی در فرانت + +### برای صفحه history + +- ابتدا API لیست را با `farm_uuid` صدا بزنید +- `data` را در جدول یا کارت لیست نمایش دهید +- `status_label` را مستقیم در badge یا chip نشان دهید +- اگر لازم بود رفتار UI بر اساس وضعیت تغییر کند، از `status` استفاده کنید +- با `pagination.page` و `pagination.total_pages` صفحه بندی را بسازید +- روی هر آیتم با `recommendation_uuid` به صفحه detail بروید + +### برای صفحه detail + +- `recommendation_uuid` را از route بگیرید +- API جزئیات را صدا بزنید +- `data.primary_recommendation` را در Hero/Card اصلی نمایش دهید +- `data.nutrient_analysis` را در بخش تحلیل عناصر نمایش دهید +- `data.application_guide` را در بخش راهنمای مصرف نمایش دهید +- `data.alternative_recommendations` را برای جایگزین ها نمایش دهید +- در صورت نیاز برای سازگاری، از `data.sections` هم استفاده کنید + +### فرمول محاسبه مقدار مصرف + +```text +مقدار کل = base_amount_per_square_meter × مساحت مزرعه +``` + +--- + +## خلاصه مسیرها + +- لیست recommendationها: + - `GET /api/fertilization/recommendations/?farm_uuid=&page=1&page_size=10` +- جزئیات recommendation: + - `GET /api/fertilization/recommendations//` diff --git a/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py b/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py new file mode 100644 index 0000000..14bbba9 --- /dev/null +++ b/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0008_product_plant_selector_fields"), + ] + + operations = [ + migrations.AddField( + model_name="farmhub", + name="irrigation_method_id", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmhub", + name="irrigation_method_name", + field=models.CharField(blank=True, default="", max_length=255), + ), + ] diff --git a/farm_hub/models.py b/farm_hub/models.py index 6f49853..ce65231 100644 --- a/farm_hub/models.py +++ b/farm_hub/models.py @@ -103,6 +103,8 @@ class FarmHub(models.Model): ) name = models.CharField(max_length=255) is_active = models.BooleanField(default=True) + irrigation_method_id = models.IntegerField(null=True, blank=True) + irrigation_method_name = models.CharField(max_length=255, blank=True, default="") current_crop_area = models.ForeignKey( "crop_zoning.CropArea", on_delete=models.SET_NULL, diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py index 4ea2325..230b36e 100644 --- a/farm_hub/seeds.py +++ b/farm_hub/seeds.py @@ -15,6 +15,8 @@ ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") ADMIN_FARM_DATA = { "name": "Admin Smart Farm", "is_active": True, + "irrigation_method_id": 1, + "irrigation_method_name": "آبیاری قطره ای", "sensors": [ { "sensor_catalog_code": "sensor_7_soil_moisture_sensor_v1_2", @@ -81,6 +83,8 @@ def seed_admin_farm(): "farm_type": farm_type, "name": ADMIN_FARM_DATA["name"], "is_active": ADMIN_FARM_DATA["is_active"], + "irrigation_method_id": ADMIN_FARM_DATA["irrigation_method_id"], + "irrigation_method_name": ADMIN_FARM_DATA["irrigation_method_name"], }, ) farm.products.set(products) diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index 443254e..ffdff02 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -72,6 +72,8 @@ class FarmHubSerializer(serializers.ModelSerializer): "area_uuid", "name", "is_active", + "irrigation_method_id", + "irrigation_method_name", "farm_type", "subscription_plan", "products", @@ -128,7 +130,8 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): sensors = FarmSensorWriteSerializer(many=True, required=False) sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1") sensor_payload = serializers.JSONField(write_only=True, required=False) - irrigation_method_id = serializers.IntegerField(write_only=True, required=False, allow_null=True) + irrigation_method_id = serializers.IntegerField(required=False, allow_null=True) + irrigation_method_name = serializers.CharField(required=False, allow_blank=True) class Meta: model = FarmHub @@ -144,6 +147,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): "sensor_key", "sensor_payload", "irrigation_method_id", + "irrigation_method_name", ] def to_internal_value(self, data): @@ -217,13 +221,20 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): attrs["farm_type"] = farm_type attrs["subscription_plan"] = subscription_plan attrs["products"] = products + + irrigation_method_id = attrs.get("irrigation_method_id", serializers.empty) + irrigation_method_name = attrs.get("irrigation_method_name", serializers.empty) + if irrigation_method_id is None: + attrs["irrigation_method_name"] = "" + elif irrigation_method_name is serializers.empty and self.instance is not None: + attrs["irrigation_method_name"] = self.instance.irrigation_method_name + return attrs def create(self, validated_data): validated_data.pop("area_geojson", None) validated_data.pop("sensor_key", None) validated_data.pop("sensor_payload", None) - validated_data.pop("irrigation_method_id", None) sensors_data = validated_data.pop("sensors", []) products = validated_data.pop("products", []) validated_data["farm_type"] = validated_data.pop("farm_type") @@ -243,7 +254,6 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): validated_data.pop("area_geojson", None) validated_data.pop("sensor_key", None) validated_data.pop("sensor_payload", None) - validated_data.pop("irrigation_method_id", None) sensors_data = validated_data.pop("sensors", None) products = validated_data.pop("products", None) farm_type = validated_data.pop("farm_type", None) diff --git a/farm_hub/services.py b/farm_hub/services.py index 1642bec..9ba6207 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -73,8 +73,11 @@ def sync_farm_data( if plant_ids: request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids] - if irrigation_method_id is not None: - request_payload["irrigation_method_id"] = int(irrigation_method_id) + resolved_irrigation_method_id = irrigation_method_id + if resolved_irrigation_method_id is None: + resolved_irrigation_method_id = farm.irrigation_method_id + if resolved_irrigation_method_id is not None: + request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id) if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")): raise FarmDataSyncError( @@ -112,7 +115,7 @@ def create_farm_with_zoning(serializer, owner): area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature() sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1") sensor_payload = serializer.validated_data.pop("sensor_payload", None) - irrigation_method_id = serializer.validated_data.pop("irrigation_method_id", None) + irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None) with transaction.atomic(): farm = serializer.save(owner=owner) diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 230049b..2547762 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -66,6 +66,8 @@ class FarmListCreateViewTests(TestCase): "farm_type_uuid": str(self.farm_type.uuid), "subscription_plan_uuid": str(self.plan.uuid), "product_uuids": [str(self.wheat.uuid)], + "irrigation_method_id": 3, + "irrigation_method_name": "Drip", "sensors": [ { "sensor_catalog_uuid": str(self.weather_station.uuid), @@ -88,6 +90,8 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.data["code"], 201) self.assertEqual(response.data["data"]["name"], "farm-1") self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code) + self.assertEqual(response.data["data"]["irrigation_method_id"], 3) + self.assertEqual(response.data["data"]["irrigation_method_name"], "Drip") self.assertIn("zoning", response.data["data"]) self.assertIsNotNone(response.data["data"]["area_uuid"]) self.assertEqual(len(response.data["data"]["sensors"]), 1) @@ -107,6 +111,7 @@ class FarmListCreateViewTests(TestCase): "farm_uuid": response.data["data"]["farm_uuid"], "farm_boundary": AREA_GEOJSON["geometry"], "plant_ids": [self.wheat.id], + "irrigation_method_id": 3, }, headers={ "Accept": "application/json", @@ -223,6 +228,7 @@ class FarmListCreateViewTests(TestCase): }, "sensor_payload": {"soil_moisture": 45.2}, "irrigation_method_id": 3, + "irrigation_method_name": "Drip", }, format="json", ) @@ -233,6 +239,8 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.status_code, 200) farm.refresh_from_db() self.assertIsNotNone(farm.current_crop_area) + self.assertEqual(farm.irrigation_method_id, 3) + self.assertEqual(farm.irrigation_method_name, "Drip") mock_external_api_request.assert_called_once_with( "ai", "/api/farm-data/", @@ -279,6 +287,8 @@ class FarmSeedTests(TestCase): self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111") self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(farm.sensors.count(), 1) + self.assertEqual(farm.irrigation_method_id, 1) + self.assertEqual(farm.irrigation_method_name, "آبیاری قطره ای") self.assertIsNotNone(farm.sensors.first().physical_device_uuid) self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists()) diff --git a/fertilization_recommendation/migrations/0002_recommendation_status_lifecycle.py b/fertilization_recommendation/migrations/0002_recommendation_status_lifecycle.py new file mode 100644 index 0000000..e23b588 --- /dev/null +++ b/fertilization_recommendation/migrations/0002_recommendation_status_lifecycle.py @@ -0,0 +1,37 @@ +from django.db import migrations, models + + +PENDING_STATUS = "pending_confirmation" +OLD_STATUSES = {"", "success", "error", None} + + +def migrate_existing_statuses(apps, schema_editor): + Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest") + Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update( + status=PENDING_STATUS + ) + Recommendation.objects.filter(status__isnull=True).update(status=PENDING_STATUS) + + +class Migration(migrations.Migration): + dependencies = [ + ("fertilization_recommendation", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="fertilizationrecommendationrequest", + name="status", + field=models.CharField( + choices=[ + ("in_progress", "در حال مصرف"), + ("pending_confirmation", "منتظر تایید"), + ("completed", "پایان یافته"), + ], + db_index=True, + default="pending_confirmation", + max_length=64, + ), + ), + migrations.RunPython(migrate_existing_statuses, migrations.RunPython.noop), + ] diff --git a/fertilization_recommendation/models.py b/fertilization_recommendation/models.py index cbbb5f0..4154238 100644 --- a/fertilization_recommendation/models.py +++ b/fertilization_recommendation/models.py @@ -6,6 +6,15 @@ from farm_hub.models import FarmHub class FertilizationRecommendationRequest(models.Model): + STATUS_IN_PROGRESS = "in_progress" + STATUS_PENDING_CONFIRMATION = "pending_confirmation" + STATUS_COMPLETED = "completed" + STATUS_CHOICES = ( + (STATUS_IN_PROGRESS, "در حال مصرف"), + (STATUS_PENDING_CONFIRMATION, "منتظر تایید"), + (STATUS_COMPLETED, "پایان یافته"), + ) + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) farm = models.ForeignKey( FarmHub, @@ -15,7 +24,12 @@ class FertilizationRecommendationRequest(models.Model): crop_id = models.CharField(max_length=255, blank=True, default="") growth_stage = models.CharField(max_length=255, blank=True, default="") task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) - status = models.CharField(max_length=64, blank=True, default="") + status = models.CharField( + max_length=64, + choices=STATUS_CHOICES, + default=STATUS_PENDING_CONFIRMATION, + db_index=True, + ) request_payload = models.JSONField(default=dict, blank=True) response_payload = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/fertilization_recommendation/serializers.py b/fertilization_recommendation/serializers.py index 8e569fc..667427d 100644 --- a/fertilization_recommendation/serializers.py +++ b/fertilization_recommendation/serializers.py @@ -9,10 +9,17 @@ class FertilizationFarmDataSerializer(serializers.Serializer): class FertilizationRecommendRequestSerializer(serializers.Serializer): farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.") - plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.") + crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول. این فیلد همان plant_name است.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه. این فیلد همان crop_id است.") growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") +class FertilizationRecommendationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های کودی.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + class FertilizationSectionSerializer(serializers.Serializer): type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"]) title = serializers.CharField(required=False, allow_blank=True) @@ -98,7 +105,24 @@ class AlternativeRecommendationSerializer(serializers.Serializer): description = serializers.CharField(required=False, allow_blank=True) +class FertilizationRecommendationListItemSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + fertilizer_type = serializers.CharField(read_only=True, allow_blank=True) + status = serializers.CharField(read_only=True) + status_label = serializers.CharField(source="get_status_display", read_only=True) + requested_at = serializers.DateTimeField(source="created_at", read_only=True) + + class FertilizationRecommendResponseDataSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(read_only=True, required=False) + crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) + plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True) + status = serializers.CharField(read_only=True, required=False) + status_label = serializers.CharField(read_only=True, required=False) primary_recommendation = PrimaryRecommendationSerializer(read_only=True) nutrient_analysis = NutrientAnalysisSerializer(read_only=True) application_guide = ApplicationGuideSerializer(read_only=True) diff --git a/fertilization_recommendation/tests.py b/fertilization_recommendation/tests.py index fdc6a64..38a186f 100644 --- a/fertilization_recommendation/tests.py +++ b/fertilization_recommendation/tests.py @@ -5,7 +5,8 @@ from unittest.mock import patch from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType -from .views import RecommendView +from .models import FertilizationRecommendationRequest +from .views import RecommendationDetailView, RecommendationListView, RecommendView class FertilizationRecommendViewTests(TestCase): @@ -78,7 +79,7 @@ class FertilizationRecommendViewTests(TestCase): request = self.factory.post( "/api/fertilization/recommend/", - {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گندم", "growth_stage": "vegetative"}, + {"farm_uuid": str(self.farm.farm_uuid), "crop_id": "گندم", "growth_stage": "vegetative"}, format="json", ) force_authenticate(request, user=self.user) @@ -95,13 +96,179 @@ class FertilizationRecommendViewTests(TestCase): self.assertEqual(response.data["data"]["primary_recommendation"]["application_interval"]["value"], 14.0) self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation") self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation") + self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1) + saved_request = FertilizationRecommendationRequest.objects.get() + self.assertEqual(saved_request.crop_id, "گندم") + self.assertEqual(saved_request.growth_stage, "vegetative") + self.assertEqual( + saved_request.status, + FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + ) + self.assertEqual( + saved_request.response_payload["data"]["primary_recommendation"]["fertilizer_code"], + "npk-202020", + ) mock_external_api_request.assert_called_once_with( "ai", "/api/fertilization/recommend/", method="POST", payload={ "farm_uuid": str(self.farm.farm_uuid), + "crop_id": "گندم", "plant_name": "گندم", "growth_stage": "vegetative", }, ) + + @patch("fertilization_recommendation.views.external_api_request") + def test_recommend_accepts_plant_name_and_passes_it_directly_to_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}}) + + request = self.factory.post( + "/api/fertilization/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "جو", "growth_stage": "flowering"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + saved_request = FertilizationRecommendationRequest.objects.latest("created_at") + self.assertEqual(saved_request.crop_id, "جو") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/fertilization/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "crop_id": "جو", + "plant_name": "جو", + "growth_stage": "flowering", + }, + ) + + def test_recommendation_list_returns_paginated_summary_items(self): + first = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_type": "NPK", + } + } + }, + ) + second = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="ذرت", + growth_stage="flowering", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_type": "Micronutrient", + } + } + }, + ) + + request = self.factory.get( + f"/api/fertilization/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1" + ) + force_authenticate(request, user=self.user) + + response = RecommendationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["pagination"]["page"], 1) + self.assertEqual(response.data["pagination"]["page_size"], 1) + self.assertEqual(response.data["pagination"]["total_pages"], 2) + self.assertEqual(response.data["pagination"]["total_items"], 2) + self.assertTrue(response.data["pagination"]["has_next"]) + self.assertFalse(response.data["pagination"]["has_previous"]) + self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid)) + self.assertEqual(response.data["data"][0]["plant_name"], "ذرت") + self.assertEqual(response.data["data"][0]["growth_stage"], "flowering") + self.assertEqual(response.data["data"][0]["fertilizer_type"], "Micronutrient") + self.assertEqual(response.data["data"][0]["status"], "pending_confirmation") + self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید") + self.assertIn("requested_at", response.data["data"][0]) + self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid)) + + def test_recommendation_detail_returns_same_shape_as_recommend_endpoint(self): + recommendation = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_type": "NPK", + "summary": "خلاصه توصیه", + }, + "nutrient_analysis": { + "macro": [{"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent"}], + "micro": [], + }, + "application_guide": { + "safety_warning": "در هوای خنک استفاده شود", + "steps": [{"step_number": 1, "title": "آماده سازی", "description": "در آب حل شود"}], + }, + "alternative_recommendations": [ + {"fertilizer_code": "alt-1", "fertilizer_name": "Alt", "fertilizer_type": "NPK"} + ], + "sections": [{"type": "warning", "title": "هشدار", "content": "اختلاط نشود"}], + } + }, + ) + + request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_code"], "npk-202020") + self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_type"], "NPK") + self.assertEqual(response.data["data"]["nutrient_analysis"]["macro"][0]["value"], 20.0) + self.assertEqual(response.data["data"]["application_guide"]["steps"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "warning") + self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid)) + self.assertEqual(response.data["data"]["crop_id"], "گندم") + self.assertEqual(response.data["data"]["plant_name"], "گندم") + self.assertEqual(response.data["data"]["status"], "pending_confirmation") + self.assertEqual(response.data["data"]["status_label"], "منتظر تایید") + + def test_recommendation_detail_falls_back_to_top_level_fertilizer_code(self): + recommendation = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "fertilizer_code": "legacy-code-101", + "fertilizer_type": "NPK", + } + }, + ) + + request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["data"]["primary_recommendation"]["fertilizer_code"], + "legacy-code-101", + ) diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index e52d26d..cef3db7 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -1,8 +1,10 @@ from django.urls import path -from .views import ConfigView, RecommendView +from .views import ConfigView, RecommendationDetailView, RecommendationListView, RecommendView urlpatterns = [ path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), + path("recommendations//", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"), + path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"), path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), ] diff --git a/fertilization_recommendation/views.py b/fertilization_recommendation/views.py index 7fc10cb..d549be8 100644 --- a/fertilization_recommendation/views.py +++ b/fertilization_recommendation/views.py @@ -4,7 +4,10 @@ Fertilization Recommendation API views. import logging +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema @@ -15,6 +18,8 @@ from farm_hub.models import FarmHub from .mock_data import CONFIG_RESPONSE_DATA from .models import FertilizationRecommendationRequest from .serializers import ( + FertilizationRecommendationListItemSerializer, + FertilizationRecommendationListQuerySerializer, FertilizationRecommendRequestSerializer, FertilizationRecommendResponseDataSerializer, ) @@ -23,6 +28,33 @@ from .serializers import ( logger = logging.getLogger(__name__) +class FertilizationRecommendationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data): + page_size = self.get_page_size(self.request) or self.page.paginator.per_page + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "pagination": { + "page": self.page.number, + "page_size": page_size, + "total_pages": self.page.paginator.num_pages, + "total_items": self.page.paginator.count, + "has_next": self.page.has_next(), + "has_previous": self.page.has_previous(), + "next": self.get_next_link(), + "previous": self.get_previous_link(), + }, + }, + status=status.HTTP_200_OK, + ) + + class FarmAccessMixin: @staticmethod def _get_farm(request, farm_uuid): @@ -161,21 +193,50 @@ class RecommendView(FarmAccessMixin, APIView): return normalized + @staticmethod + def _first_non_empty(*values): + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + def _normalize_primary_recommendation(self, payload): raw_data = payload.get("primary_recommendation") if not isinstance(raw_data, dict): - return {} + raw_data = {} normalized = {} - for key in ( - "fertilizer_code", - "fertilizer_name", - "display_title", - "fertilizer_type", - "reasoning", - "summary", - ): - value = self._to_string(raw_data.get(key)).strip() + scalar_fields = { + "fertilizer_code": ( + raw_data.get("fertilizer_code"), + payload.get("fertilizer_code"), + ), + "fertilizer_name": ( + raw_data.get("fertilizer_name"), + payload.get("fertilizer_name"), + ), + "display_title": ( + raw_data.get("display_title"), + payload.get("display_title"), + ), + "fertilizer_type": ( + raw_data.get("fertilizer_type"), + payload.get("fertilizer_type"), + ), + "reasoning": ( + raw_data.get("reasoning"), + payload.get("reasoning"), + ), + "summary": ( + raw_data.get("summary"), + payload.get("summary"), + ), + } + for key, values in scalar_fields.items(): + value = self._first_non_empty(*values) if value: normalized[key] = value @@ -305,8 +366,11 @@ class RecommendView(FarmAccessMixin, APIView): serializer.is_valid(raise_exception=True) payload = serializer.validated_data.copy() farm = self._get_farm(request, payload.get("farm_uuid")) + crop_id = self._first_non_empty(payload.get("crop_id"), payload.get("plant_name")) + plant_name = self._first_non_empty(payload.get("plant_name"), payload.get("crop_id")) payload["farm_uuid"] = str(farm.farm_uuid) - payload["plant_name"] = payload.get("plant_name", "") + payload["crop_id"] = crop_id + payload["plant_name"] = plant_name payload["growth_stage"] = payload.get("growth_stage", "") adapter_response = external_api_request( @@ -329,10 +393,10 @@ class RecommendView(FarmAccessMixin, APIView): FertilizationRecommendationRequest.objects.create( farm=farm, - crop_id=payload.get("plant_name", ""), + crop_id=crop_id, growth_stage=payload.get("growth_stage", ""), task_id="", - status="success" if adapter_response.status_code < 400 else "error", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, request_payload=payload, response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) @@ -354,3 +418,70 @@ class RecommendView(FarmAccessMixin, APIView): }, status=status.HTTP_200_OK, ) + + +class RecommendationListView(FarmAccessMixin, APIView): + permission_classes = RecommendView.permission_classes + pagination_class = FertilizationRecommendationPagination + + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[FertilizationRecommendationListQuerySerializer], + responses={200: code_response("FertilizationRecommendationListResponse")}, + ) + def get(self, request): + serializer = FertilizationRecommendationListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + recommendations = farm.fertilization_recommendations.all().order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(recommendations, request, view=self) + + items = [] + view_helper = RecommendView() + for recommendation in page: + normalized_payload = view_helper._normalize_response_payload(recommendation.response_payload) + recommendation.fertilizer_type = ( + normalized_payload.get("primary_recommendation", {}).get("fertilizer_type", "") + ) + items.append(recommendation) + + data = FertilizationRecommendationListItemSerializer(items, many=True).data + return paginator.get_paginated_response(data) + + +class RecommendationDetailView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[ + OpenApiParameter( + name="recommendation_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + 200: code_response("FertilizationRecommendationDetailResponse", data=FertilizationRecommendResponseDataSerializer()), + 404: code_response("FertilizationRecommendationDetailNotFoundResponse"), + }, + ) + def get(self, request, recommendation_uuid): + recommendation = FertilizationRecommendationRequest.objects.filter( + uuid=recommendation_uuid, + farm__owner=request.user, + ).select_related("farm").first() + if recommendation is None: + return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND) + + view_helper = RecommendView() + data = view_helper._normalize_response_payload(recommendation.response_payload) + data["recommendation_uuid"] = str(recommendation.uuid) + data["crop_id"] = recommendation.crop_id + data["plant_name"] = recommendation.crop_id + data["growth_stage"] = recommendation.growth_stage + data["status"] = recommendation.status + data["status_label"] = recommendation.get_status_display() + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/irrigation_recommendation/API_REFERENCE_FA.md b/irrigation_recommendation/API_REFERENCE_FA.md new file mode 100644 index 0000000..29e72d6 --- /dev/null +++ b/irrigation_recommendation/API_REFERENCE_FA.md @@ -0,0 +1,492 @@ +# مستند API آبیاری و محصولات انتخاب‌شده + +این فایل برای تحویل به فرانت نوشته شده و endpointهای مرتبط با آبیاری را به‌صورت کامل توضیح می‌دهد. + +محدوده این مستند: +- همه endpointهای `irrigation_recommendation/urls.py` +- endpoint دریافت محصولات انتخاب‌شده مزرعه: `GET /api/plants/selected/` + +## نکات عمومی + +- همه endpointها نیاز به authentication کاربر دارند، مگر اینکه در gateway یا لایه بالاتر خلاف آن تنظیم شده باشد. +- در همه endpointهای وابسته به مزرعه، `farm_uuid` باید متعلق به همان کاربر لاگین‌شده باشد. +- فرمت کلی پاسخ‌های موفق در این backend معمولاً به شکل زیر است: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +- در خطاهای اعتبارسنجی معمولاً ساختار زیر برمی‌گردد: + +```json +{ + "farm_uuid": [ + "This field is required." + ] +} +``` + +یا در بعضی endpointها: + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": [ + "Farm not found." + ] + } +} +``` + +--- + +# 1) محصولات انتخاب‌شده مزرعه + +## GET `/api/plants/selected/` + +این endpoint برای گرفتن محصول/محصولات انتخاب‌شده یک مزرعه استفاده می‌شود؛ یعنی همان محصولاتی که روی خود `FarmHub.products` ذخیره شده‌اند. + +این endpoint برای فرانت مفید است وقتی می‌خواهید: +- محصول فعلی مزرعه را نمایش دهید +- لیست گیاه‌های متصل به مزرعه را برای انتخاب stage یا recommendation استفاده کنید +- قبل از درخواست recommendation، محصول‌های مرتبط با همان مزرعه را بخوانید + +### Query Params + +#### `farm_uuid` +- نوع: `string (uuid)` +- اجباری: بله +- توضیح: شناسه مزرعه برای خواندن محصولات انتخاب‌شده آن. + +### نمونه درخواست + +```bash +curl -s "http://localhost:8000/api/plants/selected/?farm_uuid=11111111-1111-1111-1111-111111111111" \ + -H "accept: application/json" \ + -H "Authorization: Bearer " +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "name": "گوجه فرنگی", + "icon": "tabler-carrot", + "growth_stages": ["رویشی", "گلدهی", "میوه دهی"] + } + ] +} +``` + +### فیلدهای هر آیتم + +#### `name` +- نوع: `string` +- توضیح: نام محصول. + +#### `icon` +- نوع: `string` +- توضیح: آیکون پیشنهادی برای UI. + +#### `growth_stages` +- نوع: `array` +- توضیح: مراحل رشد قابل استفاده برای فرانت. + +### خطاهای رایج + +#### اگر `farm_uuid` ارسال نشود +```json +{ + "farm_uuid": ["This field is required."] +} +``` + +#### اگر مزرعه متعلق به کاربر نباشد یا پیدا نشود +```json +{ + "farm_uuid": ["Farm not found."] +} +``` + +# 5) تولید recommendation آبیاری + +## POST `/api/irrigation/recommend/` + +این endpoint recommendation آبیاری را تولید می‌کند و خروجی آن با UI فعلی recommendation هماهنگ شده است. + +نکته مهم: +- روش آبیاری از body فرانت خوانده نمی‌شود. +- backend روش آبیاری را از خود مزرعه (`FarmHub.irrigation_method_id` و `FarmHub.irrigation_method_name`) برمی‌دارد. +- بنابراین قبل از صدا زدن این endpoint، فرانت باید روش آبیاری انتخاب‌شده را روی مزرعه ذخیره کرده باشد. + +## ساختار کلی پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "...", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "plan": {}, + "water_balance": {}, + "timeline": [], + "sections": [] + } +} +``` + +## Request + +### حداقل payload پیشنهادی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی" +} +``` + +### فیلدهای Request + +### `farm_uuid` +- نوع: `string` +- اجباری: بله +- توضیح: شناسه یکتای مزرعه. + +### `sensor_uuid` +- نوع: `string` +- اجباری: خیر +- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود. + +### `plant_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام گیاه هدف برای تولید recommendation. + +### `growth_stage` +- نوع: `string` +- اجباری: خیر +- توضیح: مرحله رشد گیاه، مثل `رویشی`، `گلدهی` یا `میوه دهی`. + +## فیلدهای `data` + +### `recommendation_uuid` +- نوع: `string (uuid)` +- توضیح: شناسه recommendation ذخیره‌شده برای history/detail. + +### `crop_id` +- نوع: `string` +- توضیح: نام/شناسه گیاه ذخیره‌شده روی recommendation. + +### `plant_name` +- نوع: `string` +- توضیح: معادل `crop_id` برای مصرف آسان‌تر در UI. + +### `growth_stage` +- نوع: `string` +- توضیح: مرحله رشد ذخیره‌شده همراه recommendation. + +### `irrigation_method_name` +- نوع: `string` +- توضیح: نام روش آبیاری خوانده‌شده از مزرعه. + +### `status` +- نوع: `string` +- توضیح: وضعیت recommendation. مقادیر فعلی: + - `in_progress` + - `pending_confirmation` + - `completed` + - `error` + +### `status_label` +- نوع: `string` +- توضیح: متن فارسی وضعیت برای نمایش مستقیم در UI. + +### `plan` +- نوع: `object` +- توضیح: خلاصه اصلی recommendation برای کارت بالای UI. + +### `water_balance` +- نوع: `object` +- توضیح: تراز آب و خروجی محاسبات روزانه. + +### `timeline` +- نوع: `array` +- توضیح: مراحل اجرایی recommendation برای stepper. + +### `sections` +- نوع: `array` +- توضیح: هشدارها و نکات تکمیلی. + +## نمونه پاسخ حداقلی قابل استفاده + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود" + }, + "water_balance": { + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78 + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00" + } + ] + }, + "timeline": [ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود" + } + ], + "sections": [ + { + "title": "هشدار تبخیر بالا", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": "در ساعات گرم روز آبیاری انجام نشود" + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } + ] + } +} +``` + +--- + +# 6) لیست recommendationهای آبیاری + +## GET `/api/irrigation/recommendations/` + +این endpoint history recommendationهای آبیاری یک مزرعه را برمی‌گرداند. + +### Query Params + +#### `farm_uuid` +- نوع: `string (uuid)` +- اجباری: بله + +#### `page` +- نوع: `number` +- اجباری: خیر +- پیش‌فرض: `1` + +#### `page_size` +- نوع: `number` +- اجباری: خیر +- پیش‌فرض backend: `10` +- حداکثر: `100` + +### نمونه درخواست + +```bash +curl -s "http://localhost:8000/api/irrigation/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10" \ + -H "accept: application/json" \ + -H "Authorization: Bearer " +``` + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-02-12T09:30:00Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 1, + "total_items": 1, + "has_next": false, + "has_previous": false, + "next": null, + "previous": null + } +} +``` + +### فیلدهای هر آیتم + +#### `recommendation_uuid` +- نوع: `string (uuid)` +- توضیح: شناسه recommendation برای باز کردن جزئیات. + +#### `crop_id` +- نوع: `string` +- توضیح: نام/شناسه گیاه. + +#### `plant_name` +- نوع: `string` +- توضیح: معادل `crop_id`. + +#### `growth_stage` +- نوع: `string` +- توضیح: مرحله رشد ثبت‌شده. + +#### `irrigation_method_name` +- نوع: `string` +- توضیح: نام روش آبیاری. + +#### `status` +- نوع: `string` +- توضیح: وضعیت recommendation. + +#### `status_label` +- نوع: `string` +- توضیح: متن فارسی وضعیت. + +#### `requested_at` +- نوع: `string(datetime)` +- توضیح: زمان ساخت recommendation. + +--- + +# 7) جزئیات یک recommendation آبیاری + +## GET `/api/irrigation/recommendations/{recommendation_uuid}/` + +این endpoint جزئیات یک recommendation ذخیره‌شده را با همان shape endpoint اصلی recommendation برمی‌گرداند. + +### Path Params + +#### `recommendation_uuid` +- نوع: `string (uuid)` +- اجباری: بله +- توضیح: شناسه recommendation. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "completed", + "status_label": "پایان یافته", + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 30 + }, + "water_balance": { + "active_kc": 0.93, + "daily": [] + }, + "timeline": [ + { + "step_number": 1, + "title": "مرحله اول", + "description": "اجرا شود" + } + ], + "sections": [ + { + "type": "tip", + "title": "نکته", + "content": "صبح زود آبیاری شود" + } + ] + } +} +``` + +### خطای عدم وجود recommendation + +```json +{ + "code": 404, + "msg": "Recommendation not found." +} +``` + +--- + + +# 9) پیشنهاد جریان استفاده در فرانت + +برای صفحه recommendation آبیاری، ترتیب پیشنهادی این است: + +1. با `GET /api/irrigation/` لیست روش‌های آبیاری را بگیرید. +2. کاربر یکی از روش‌ها را انتخاب کند. +3. روش انتخاب‌شده را روی مزرعه ذخیره کنید (`irrigation_method_id` و `irrigation_method_name`). +4. با `GET /api/plants/selected/?farm_uuid=...` محصولات انتخاب‌شده مزرعه را بگیرید. +5. کاربر محصول و مرحله رشد را انتخاب کند. +6. `POST /api/irrigation/recommend/` را فقط با `farm_uuid` و `plant_name` و `growth_stage` صدا بزنید. +7. برای history از `GET /api/irrigation/recommendations/` و برای جزئیات از `GET /api/irrigation/recommendations/{recommendation_uuid}/` استفاده کنید. + +--- + +# 10) جمع‌بندی سریع endpointها + +| Method | Path | کاربرد | +|---|---|---| +| GET | `/api/plants/selected/` | گرفتن محصولات انتخاب‌شده مزرعه | +| GET | `/api/irrigation/` | گرفتن لیست روش‌های آبیاری | +| POST | `/api/irrigation/` | ایجاد روش آبیاری جدید در upstream | +| GET | `/api/irrigation/config/` | گرفتن config اولیه صفحه recommendation | +| POST | `/api/irrigation/recommend/` | تولید recommendation آبیاری | +| GET | `/api/irrigation/recommendations/` | گرفتن history recommendationهای آبیاری | +| GET | `/api/irrigation/recommendations/{recommendation_uuid}/` | گرفتن جزئیات یک recommendation | +| POST | `/api/irrigation/water-stress/` | گرفتن شاخص تنش آبی | diff --git a/irrigation_recommendation/migrations/0002_recommendation_status_and_growth_stage.py b/irrigation_recommendation/migrations/0002_recommendation_status_and_growth_stage.py new file mode 100644 index 0000000..7f93df4 --- /dev/null +++ b/irrigation_recommendation/migrations/0002_recommendation_status_and_growth_stage.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("irrigation_recommendation", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="irrigationrecommendationrequest", + name="growth_stage", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="irrigationrecommendationrequest", + name="status", + field=models.CharField( + choices=[ + ("in_progress", "در حال اجرا"), + ("pending_confirmation", "منتظر تایید"), + ("completed", "پایان یافته"), + ("error", "خطا"), + ], + db_index=True, + default="pending_confirmation", + max_length=64, + ), + ), + ] diff --git a/irrigation_recommendation/models.py b/irrigation_recommendation/models.py index 59ad3cb..3c442f7 100644 --- a/irrigation_recommendation/models.py +++ b/irrigation_recommendation/models.py @@ -6,6 +6,17 @@ from farm_hub.models import FarmHub class IrrigationRecommendationRequest(models.Model): + STATUS_IN_PROGRESS = "in_progress" + STATUS_PENDING_CONFIRMATION = "pending_confirmation" + STATUS_COMPLETED = "completed" + STATUS_ERROR = "error" + STATUS_CHOICES = ( + (STATUS_IN_PROGRESS, "در حال اجرا"), + (STATUS_PENDING_CONFIRMATION, "منتظر تایید"), + (STATUS_COMPLETED, "پایان یافته"), + (STATUS_ERROR, "خطا"), + ) + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) farm = models.ForeignKey( FarmHub, @@ -13,8 +24,14 @@ class IrrigationRecommendationRequest(models.Model): related_name="irrigation_recommendations", ) crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) - status = models.CharField(max_length=64, blank=True, default="") + status = models.CharField( + max_length=64, + choices=STATUS_CHOICES, + default=STATUS_PENDING_CONFIRMATION, + db_index=True, + ) request_payload = models.JSONField(default=dict, blank=True) response_payload = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/irrigation_recommendation/serializers.py b/irrigation_recommendation/serializers.py index 74ce63d..a0c5eae 100644 --- a/irrigation_recommendation/serializers.py +++ b/irrigation_recommendation/serializers.py @@ -8,11 +8,26 @@ class IrrigationFarmDataSerializer(serializers.Serializer): class IrrigationRecommendRequestSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه آبیاری.") + farm_uuid = serializers.UUIDField(required=False, help_text="UUID مزرعه برای دریافت توصیه آبیاری.") + sensor_uuid = serializers.UUIDField(required=False, help_text="نام قدیمی farm_uuid برای سازگاری با کلاینت های قدیمی.") plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.") growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") + irrigation_type = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری مورد استفاده در UI.") irrigation_method_name = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری انتخابی.") + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + + attrs["farm_uuid"] = farm_uuid + irrigation_method_name = attrs.get("irrigation_method_name") or attrs.get("irrigation_type") + if irrigation_method_name: + attrs["irrigation_method_name"] = irrigation_method_name + attrs.setdefault("irrigation_type", irrigation_method_name) + + return attrs + class WaterStressRequestSerializer(serializers.Serializer): farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.") @@ -34,5 +49,32 @@ class IrrigationMethodSerializer(serializers.Serializer): updated_at = serializers.DateTimeField(required=False) +class IrrigationRecommendationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های آبیاری.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class IrrigationRecommendationListItemSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + irrigation_method_name = serializers.CharField(read_only=True, allow_blank=True) + status = serializers.CharField(read_only=True) + status_label = serializers.CharField(source="get_status_display", read_only=True) + requested_at = serializers.DateTimeField(source="created_at", read_only=True) + + class IrrigationRecommendResponseDataSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(read_only=True, required=False) + crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) + plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True) + irrigation_method_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + status = serializers.CharField(read_only=True, required=False) + status_label = serializers.CharField(read_only=True, required=False) + plan = serializers.DictField(read_only=True) + water_balance = serializers.DictField(read_only=True) + timeline = serializers.ListField(child=serializers.DictField(), read_only=True) sections = serializers.ListField(child=serializers.DictField(), read_only=True) diff --git a/irrigation_recommendation/services.py b/irrigation_recommendation/services.py index 632ee35..fbca39d 100644 --- a/irrigation_recommendation/services.py +++ b/irrigation_recommendation/services.py @@ -9,13 +9,19 @@ def _extract_result(response_payload): return {} data = response_payload.get("data") - if isinstance(data, dict) and isinstance(data.get("result"), dict): - return data["result"] + if isinstance(data, dict): + if isinstance(data.get("result"), dict): + return data["result"] + if any(key in data for key in ("plan", "water_balance", "timeline", "sections")): + return data result = response_payload.get("result") if isinstance(result, dict): return result + if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")): + return response_payload + return {} @@ -23,7 +29,7 @@ def _get_latest_result(farm): if farm is None: return {} - for request in IrrigationRecommendationRequest.objects.filter(farm=farm): + for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"): result = _extract_result(request.response_payload) if result: return result @@ -31,6 +37,138 @@ def _get_latest_result(farm): return {} +def _normalize_plan(plan): + if not isinstance(plan, dict): + return {} + + normalized = {} + for key in ("frequencyPerWeek", "durationMinutes", "bestTimeOfDay", "moistureLevel", "warning"): + value = plan.get(key) + if value is not None: + normalized[key] = value + return normalized + + +def _normalize_crop_profile(crop_profile): + if not isinstance(crop_profile, dict): + return {} + + normalized = {} + for key in ("kc_initial", "kc_mid", "kc_end"): + value = crop_profile.get(key) + if value is not None: + normalized[key] = value + return normalized + + +def _normalize_daily_entries(daily_entries): + if not isinstance(daily_entries, list): + return [] + + normalized_daily = [] + allowed_keys = ( + "forecast_date", + "et0_mm", + "etc_mm", + "effective_rainfall_mm", + "gross_irrigation_mm", + "irrigation_timing", + ) + for entry in daily_entries: + if not isinstance(entry, dict): + continue + normalized_entry = {key: entry.get(key) for key in allowed_keys if entry.get(key) is not None} + if normalized_entry: + normalized_daily.append(normalized_entry) + + return normalized_daily + + +def _normalize_water_balance(water_balance): + if not isinstance(water_balance, dict): + return {} + + normalized = {} + if water_balance.get("active_kc") is not None: + normalized["active_kc"] = water_balance.get("active_kc") + + crop_profile = _normalize_crop_profile(water_balance.get("crop_profile")) + if crop_profile: + normalized["crop_profile"] = crop_profile + + normalized["daily"] = _normalize_daily_entries(water_balance.get("daily")) + return normalized + + +def _normalize_timeline(timeline): + if not isinstance(timeline, list): + return [] + + normalized_timeline = [] + for item in timeline: + if not isinstance(item, dict): + continue + normalized_item = {} + for key in ("step_number", "title", "description"): + value = item.get(key) + if value is not None: + normalized_item[key] = value + if normalized_item: + normalized_timeline.append(normalized_item) + + return normalized_timeline + + +def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "icon", + "content", + "items", + "frequency", + "amount", + "timing", + "validityPeriod", + "expandableExplanation", + } + + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + +def build_recommendation_response(adapter_payload): + result = _extract_result(adapter_payload) + fallback_plan = RECOMMEND_RESPONSE_DATA.get("plan", {}) + + return { + "plan": _normalize_plan(result.get("plan") or fallback_plan), + "water_balance": _normalize_water_balance(result.get("water_balance")), + "timeline": _normalize_timeline(result.get("timeline")), + "sections": _normalize_sections(result.get("sections")), + } + + def get_water_need_prediction_data(farm=None): default_data = deepcopy(WATER_NEED_PREDICTION) result = _get_latest_result(farm) diff --git a/irrigation_recommendation/tests.py b/irrigation_recommendation/tests.py index 02d79d1..557cc2b 100644 --- a/irrigation_recommendation/tests.py +++ b/irrigation_recommendation/tests.py @@ -7,7 +7,14 @@ from rest_framework.test import APIRequestFactory, force_authenticate from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType -from .views import IrrigationMethodListView, WaterStressView +from .models import IrrigationRecommendationRequest +from .views import ( + IrrigationMethodListView, + RecommendView, + RecommendationDetailView, + RecommendationListView, + WaterStressView, +) class WaterStressViewTests(TestCase): @@ -139,3 +146,256 @@ class IrrigationMethodListViewTests(TestCase): method="POST", payload={"name": "Drip"}, ) + + +class RecommendViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="recommend-farmer", + password="secret123", + email="recommend@example.com", + phone_number="09120000002", + ) + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Recommend Farm", + irrigation_method_id=3, + irrigation_method_name="آبیاری قطره ای", + ) + + @patch("irrigation_recommendation.views.external_api_request") + def test_post_returns_full_recommendation_shape(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود", + }, + "water_balance": { + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78, + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00", + } + ], + }, + "timeline": [ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود", + } + ], + "sections": [ + { + "title": "هشدار تبخیر بالا", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": "در ساعات گرم روز آبیاری انجام نشود", + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند", + }, + ], + } + } + }, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertIn("recommendation_uuid", response.data["data"]) + self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION) + self.assertEqual(response.data["data"]["status_label"], "منتظر تایید") + self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38) + self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93) + self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "warning") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_id": 3, + "irrigation_type": "آبیاری قطره ای", + "irrigation_method_name": "آبیاری قطره ای", + }, + ) + + +class IrrigationRecommendationHistoryTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="history-farmer", + password="secret123", + email="history@example.com", + phone_number="09120000003", + ) + self.other_user = get_user_model().objects.create_user( + username="other-history-farmer", + password="secret123", + email="other-history@example.com", + phone_number="09120000004", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="History Farm") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Other History Farm") + + def test_recommendation_list_returns_paginated_items(self): + first = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=IrrigationRecommendationRequest.STATUS_COMPLETED, + request_payload={"irrigation_method_name": "بارانی"}, + response_payload={"data": {"plan": {"durationMinutes": 20}}}, + ) + second = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="ذرت", + growth_stage="flowering", + status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + request_payload={"irrigation_method_name": "قطره ای"}, + response_payload={"data": {"plan": {"durationMinutes": 35}}}, + ) + + request = self.factory.get( + f"/api/irrigation/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1" + ) + force_authenticate(request, user=self.user) + + response = RecommendationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["pagination"]["total_items"], 2) + self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid)) + self.assertEqual(response.data["data"][0]["plant_name"], "ذرت") + self.assertEqual(response.data["data"][0]["growth_stage"], "flowering") + self.assertEqual(response.data["data"][0]["irrigation_method_name"], "قطره ای") + self.assertEqual(response.data["data"][0]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION) + self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید") + self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid)) + + def test_recommendation_detail_returns_saved_shape(self): + recommendation = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گوجه فرنگی", + growth_stage="fruiting", + status=IrrigationRecommendationRequest.STATUS_COMPLETED, + request_payload={"irrigation_method_name": "قطره ای"}, + response_payload={ + "data": { + "result": { + "plan": {"frequencyPerWeek": 4, "durationMinutes": 30}, + "water_balance": {"active_kc": 0.93, "daily": []}, + "timeline": [{"step_number": 1, "title": "مرحله اول", "description": "اجرا شود"}], + "sections": [{"type": "tip", "title": "نکته", "content": "صبح زود آبیاری شود"}], + } + } + }, + ) + + request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid)) + self.assertEqual(response.data["data"]["crop_id"], "گوجه فرنگی") + self.assertEqual(response.data["data"]["plant_name"], "گوجه فرنگی") + self.assertEqual(response.data["data"]["growth_stage"], "fruiting") + self.assertEqual(response.data["data"]["irrigation_method_name"], "قطره ای") + self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_COMPLETED) + self.assertEqual(response.data["data"]["status_label"], "پایان یافته") + self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 30) + self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "tip") + + def test_recommendation_detail_rejects_foreign_recommendation(self): + recommendation = IrrigationRecommendationRequest.objects.create( + farm=self.other_farm, + crop_id="خیار", + status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + ) + + request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Recommendation not found.") + @patch("irrigation_recommendation.views.external_api_request") + def test_post_accepts_sensor_uuid_as_farm_uuid_alias(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"sections": []}}}, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "sensor_uuid": str(self.farm.farm_uuid), + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["plan"]["frequencyPerWeek"], 4) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_method_id": 3, + "irrigation_type": "آبیاری قطره ای", + "irrigation_method_name": "آبیاری قطره ای", + }, + ) diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index 6d6c66a..36223eb 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -1,10 +1,19 @@ from django.urls import path -from .views import ConfigView, IrrigationMethodListView, RecommendView, WaterStressView +from .views import ( + ConfigView, + IrrigationMethodListView, + RecommendationDetailView, + RecommendationListView, + RecommendView, + WaterStressView, +) urlpatterns = [ path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"), path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), + path("recommendations//", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"), + path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"), path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"), ] diff --git a/irrigation_recommendation/views.py b/irrigation_recommendation/views.py index 0b2a689..2d2dbe2 100644 --- a/irrigation_recommendation/views.py +++ b/irrigation_recommendation/views.py @@ -4,12 +4,15 @@ Irrigation Recommendation API views. import logging +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema -from config.swagger import status_response +from config.swagger import code_response, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub from water.serializers import WaterStressIndexSerializer @@ -18,15 +21,45 @@ from .mock_data import CONFIG_RESPONSE_DATA from .models import IrrigationRecommendationRequest from .serializers import ( IrrigationMethodSerializer, + IrrigationRecommendationListItemSerializer, + IrrigationRecommendationListQuerySerializer, IrrigationRecommendRequestSerializer, IrrigationRecommendResponseDataSerializer, WaterStressRequestSerializer, ) +from .services import build_recommendation_response logger = logging.getLogger(__name__) +class IrrigationRecommendationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data): + page_size = self.get_page_size(self.request) or self.page.paginator.per_page + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "pagination": { + "page": self.page.number, + "page_size": page_size, + "total_pages": self.page.paginator.num_pages, + "total_items": self.page.paginator.count, + "has_next": self.page.has_next(), + "has_previous": self.page.has_previous(), + "next": self.get_next_link(), + "previous": self.get_previous_link(), + }, + }, + status=status.HTTP_200_OK, + ) + + class FarmAccessMixin: @staticmethod def _get_farm(request, farm_uuid): @@ -122,61 +155,6 @@ class IrrigationMethodListView(APIView): class RecommendView(FarmAccessMixin, APIView): - @staticmethod - def _normalize_sections(raw_sections): - if not isinstance(raw_sections, list): - return [] - - allowed_keys = { - "type", - "title", - "icon", - "content", - "items", - "frequency", - "amount", - "timing", - "validityPeriod", - "expandableExplanation", - } - - normalized_sections = [] - for section in raw_sections: - if not isinstance(section, dict) or not section.get("type"): - continue - - normalized_section = {} - for key in allowed_keys: - value = section.get(key) - if value is None: - continue - if key == "items": - if not isinstance(value, list): - continue - normalized_section[key] = [str(item) for item in value] - continue - normalized_section[key] = str(value) if key != "type" else value - - normalized_sections.append(normalized_section) - return normalized_sections - - def _extract_public_sections(self, adapter_data): - if not isinstance(adapter_data, dict): - return [] - - data = adapter_data.get("data") - if isinstance(data, dict) and isinstance(data.get("sections"), list): - return self._normalize_sections(data.get("sections")) - - result = data.get("result") if isinstance(data, dict) else None - if isinstance(result, dict) and isinstance(result.get("sections"), list): - return self._normalize_sections(result.get("sections")) - - if isinstance(adapter_data.get("sections"), list): - return self._normalize_sections(adapter_data.get("sections")) - - return [] - @extend_schema( tags=["Irrigation Recommendation"], request=IrrigationRecommendRequestSerializer, @@ -188,6 +166,15 @@ class RecommendView(FarmAccessMixin, APIView): payload = serializer.validated_data.copy() farm = self._get_farm(request, payload.get("farm_uuid")) payload["farm_uuid"] = str(farm.farm_uuid) + payload.pop("sensor_uuid", None) + payload.pop("irrigation_type", None) + payload.pop("irrigation_method_name", None) + + if farm.irrigation_method_name: + payload["irrigation_method_name"] = farm.irrigation_method_name + payload["irrigation_type"] = farm.irrigation_method_name + if farm.irrigation_method_id is not None: + payload["irrigation_method_id"] = farm.irrigation_method_id adapter_response = external_api_request( "ai", @@ -197,21 +184,26 @@ class RecommendView(FarmAccessMixin, APIView): ) response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - public_sections = self._extract_public_sections(response_data) + recommendation_data = build_recommendation_response(response_data) logger.warning( "Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", str(farm.farm_uuid), adapter_response.status_code, sorted(response_data.keys()) if isinstance(response_data, dict) else None, - len(public_sections), + len(recommendation_data["sections"]), ) - IrrigationRecommendationRequest.objects.create( + recommendation = IrrigationRecommendationRequest.objects.create( farm=farm, crop_id=payload.get("plant_name", ""), + growth_stage=payload.get("growth_stage", ""), task_id="", - status="success" if adapter_response.status_code < 400 else "error", + status=( + IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION + if adapter_response.status_code < 400 + else IrrigationRecommendationRequest.STATUS_ERROR + ), request_payload=payload, response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) @@ -225,18 +217,88 @@ class RecommendView(FarmAccessMixin, APIView): status=adapter_response.status_code, ) + recommendation_data["recommendation_uuid"] = str(recommendation.uuid) + recommendation_data["crop_id"] = recommendation.crop_id + recommendation_data["plant_name"] = recommendation.crop_id + recommendation_data["growth_stage"] = recommendation.growth_stage + recommendation_data["irrigation_method_name"] = payload.get("irrigation_method_name", "") + recommendation_data["status"] = recommendation.status + recommendation_data["status_label"] = recommendation.get_status_display() + return Response( { "code": 200, "msg": "success", - "data": { - "sections": public_sections, - }, + "data": recommendation_data, }, status=status.HTTP_200_OK, ) +class RecommendationListView(FarmAccessMixin, APIView): + pagination_class = IrrigationRecommendationPagination + + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[IrrigationRecommendationListQuerySerializer], + responses={200: code_response("IrrigationRecommendationListResponse")}, + ) + def get(self, request): + serializer = IrrigationRecommendationListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + recommendations = farm.irrigation_recommendations.all().order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(recommendations, request, view=self) + + items = [] + for recommendation in page: + request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {} + recommendation.irrigation_method_name = str(request_payload.get("irrigation_method_name") or "") + items.append(recommendation) + + data = IrrigationRecommendationListItemSerializer(items, many=True).data + return paginator.get_paginated_response(data) + + +class RecommendationDetailView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[ + OpenApiParameter( + name="recommendation_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + 200: code_response("IrrigationRecommendationDetailResponse", data=IrrigationRecommendResponseDataSerializer()), + 404: code_response("IrrigationRecommendationDetailNotFoundResponse"), + }, + ) + def get(self, request, recommendation_uuid): + recommendation = IrrigationRecommendationRequest.objects.filter( + uuid=recommendation_uuid, + farm__owner=request.user, + ).select_related("farm").first() + if recommendation is None: + return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND) + + data = build_recommendation_response(recommendation.response_payload) + request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {} + data["recommendation_uuid"] = str(recommendation.uuid) + data["crop_id"] = recommendation.crop_id + data["plant_name"] = recommendation.crop_id + data["growth_stage"] = recommendation.growth_stage + data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "") + data["status"] = recommendation.status + data["status_label"] = recommendation.get_status_display() + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + class WaterStressView(APIView): @staticmethod def _get_farm(request, farm_uuid):