From 9c37e98b33aa0592a250a4bb79e1f5b5ed749188 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sat, 2 May 2026 14:36:26 +0330 Subject: [PATCH] UPDATE --- celerybeat-schedule | Bin 16384 -> 16384 bytes docs/yield_harvest_prediction_api_changes.md | 269 ++++++++++++++++++ .../0002_add_zone_and_todo_fields.py | 2 +- fertilization/FERTILIZATION_PLAN_APIS.md | 232 +++++++++++++++ .../0002_recommendation_status_lifecycle.py | 4 +- .../migrations/0003_fertilizationplan.py | 44 +++ fertilization/models.py | 49 ++++ fertilization/serializers.py | 36 +++ fertilization/services.py | 47 ++- fertilization/tests.py | 169 ++++++++++- fertilization/urls.py | 14 +- fertilization/views.py | 175 +++++++++++- irrigation/IRRIGATION_PLAN_APIS.md | 229 +++++++++++++++ ..._recommendation_status_and_growth_stage.py | 2 +- irrigation/migrations/0003_irrigationplan.py | 44 +++ irrigation/models.py | 49 ++++ irrigation/serializers.py | 36 +++ irrigation/services.py | 43 ++- irrigation/tests.py | 123 +++++++- irrigation/urls.py | 6 + irrigation/views.py | 175 +++++++++++- yield_harvest/serializers.py | 10 + yield_harvest/tests.py | 142 +++++++++ yield_harvest/views.py | 150 +++++++++- 24 files changed, 2021 insertions(+), 29 deletions(-) create mode 100644 docs/yield_harvest_prediction_api_changes.md create mode 100644 fertilization/FERTILIZATION_PLAN_APIS.md create mode 100644 fertilization/migrations/0003_fertilizationplan.py create mode 100644 irrigation/IRRIGATION_PLAN_APIS.md create mode 100644 irrigation/migrations/0003_irrigationplan.py diff --git a/celerybeat-schedule b/celerybeat-schedule index c271c6a8c075ce030d4ae6248fcc762a00242185..8a6fc6ed83d35064f95c169ed299aad4d10b6e1d 100644 GIT binary patch delta 27 icmZo@U~Fh$+|X{!#?8RM7{@#Lqd_2}w2nZtp delta 27 icmZo@U~Fh$+|X{!#>BwDz{oWDqd_2}-RAqoiaY>q5(lmT diff --git a/docs/yield_harvest_prediction_api_changes.md b/docs/yield_harvest_prediction_api_changes.md new file mode 100644 index 0000000..fa33dd3 --- /dev/null +++ b/docs/yield_harvest_prediction_api_changes.md @@ -0,0 +1,269 @@ +# Yield/Harvest Prediction API Changes + +این فایل تغییرات 3 API زیر را توضیح می‌دهد: + +- `POST /api/yield-harvest/harvest-prediction/` +- `POST /api/yield-harvest/yield-prediction/` +- `POST /api/yield-harvest/current-farm-chart/` + +--- + +## خلاصه تغییرات + +تغییر اصلی در هر 3 endpoint این است که backend حالا context موردنیاز AI را خودش از روی مزرعه و planهای انتخابی می‌سازد. + +### قبل + +در استفاده قدیمی، معمولاً فرض می‌شد client باید context بیشتری برای AI بفرستد. + +### الآن + +- `farm_uuid` ورودی اصلی و الزامی است. +- `plant_name` اگر هم توسط client ارسال شود، مبنای نهایی backend نیست و از روی مزرعه بازنویسی/resolve می‌شود. +- در صورت نیاز، `irrigation_plan_id` و `fertilization_plan_id` هم می‌توانند ارسال شوند. +- اگر plan انتخابی معتبر و متعلق به همان مزرعه کاربر باشد، backend محتوای آن را به payload ارسالی به AI اضافه می‌کند. +- خروجی backend به‌صورت یکدست با فرمت `code / msg / data` برگردانده می‌شود. + +--- + +## Request Contract جدید + +هر 3 API از این قرارداد ورودی استفاده می‌کنند: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_plan_id": 12, + "fertilization_plan_id": 34 +} +``` + +### فیلدها + +- `farm_uuid` اجباری +- `irrigation_plan_id` اختیاری +- `fertilization_plan_id` اختیاری + +### نکته مهم + +اگر client `plant_name` بفرستد، در این APIها مبنای نهایی backend نیست؛ backend نام گیاه را از مزرعه استخراج می‌کند. + +--- + +## 1) POST `/api/yield-harvest/current-farm-chart/` + +### تغییرات + +- ورودی endpoint عملاً بر پایه `farm_uuid` کار می‌کند و `plant_name` از context مزرعه تعیین می‌شود. +- backend به‌صورت خودکار `plant_name` را از مزرعه پیدا می‌کند. +- در صورت ارسال `irrigation_plan_id`، اطلاعات برنامه آبیاری داخل payload ارسالی به AI قرار می‌گیرد. +- در صورت ارسال `fertilization_plan_id`، اطلاعات برنامه کودی هم اضافه می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### payload ارسالی backend به AI + +نمونه مفهومی: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### در صورت انتخاب plan + +نمونه مفهومی: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_plan": { + "id": 12, + "plan_payload": { + "plan": { + "durationMinutes": 20 + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "scenario_id": 1, + "categories": ["day1"], + "series": { + "biomass": [1.2] + } + } +} +``` + +--- + +## 2) POST `/api/yield-harvest/harvest-prediction/` + +### تغییرات + +- ورودی endpoint عملاً بر پایه `farm_uuid` کار می‌کند و `plant_name` توسط backend تعیین می‌شود. +- امکان ارسال `fertilization_plan_id` و `irrigation_plan_id` برای enrich کردن context اضافه/پشتیبانی شده است. +- پاسخ AI بعد از extract شدن در `data.result`، به شکل مستقیم در `data` برگردانده می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "fertilization_plan_id": 34 +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "fertilization_plan": { + "id": 34, + "plan_payload": { + "primary_recommendation": { + "fertilizer_code": "npk-151515" + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "gddDetails": { + "current": 800 + } + } +} +``` + +--- + +## 3) POST `/api/yield-harvest/yield-prediction/` + +### تغییرات + +- مثل دو endpoint دیگر، `plant_name` از روی مزرعه resolve می‌شود. +- در نبود محصول مستقیم روی مزرعه، backend از fallback مناسب مزرعه استفاده می‌کند. +- امکان ارسال `irrigation_plan_id` و `fertilization_plan_id` برای فرستادن context planها به AI اضافه/پشتیبانی شده است. +- پاسخ نهایی با ساختار یکنواخت `code / msg / data` برگردانده می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_plan_id": 12, + "fertilization_plan_id": 34 +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_plan": { + "id": 12, + "plan_payload": { + "plan": { + "durationMinutes": 30 + } + } + }, + "fertilization_plan": { + "id": 34, + "plan_payload": { + "primary_recommendation": { + "fertilizer_code": "npk-202020" + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "predictedYieldTons": 8.4, + "scenarioId": 1 + } +} +``` + +--- + +## خطاها و Validation + +### 1) مزرعه نامعتبر یا متعلق به کاربر دیگر + +در این حالت endpoint خطای دسترسی/یافت‌نشدن مزرعه برمی‌گرداند. + +### 2) plan نامعتبر یا متعلق به مزرعه دیگر + +اگر `irrigation_plan_id` یا `fertilization_plan_id` متعلق به همان مزرعه کاربر نباشد، درخواست با خطا رد می‌شود. + +نمونه: + +```json +{ + "code": 404, + "msg": "error", + "data": { + "irrigation_plan_id": [ + "Irrigation plan not found." + ] + } +} +``` + +### 3) خطای validation ورودی + +اگر `farm_uuid` ارسال نشود یا `plan_id`ها نامعتبر باشند، serializer خطای validation برمی‌گرداند. + +--- + +## جمع‌بندی تغییرات برای فرانت + +- دیگر لازم نیست `plant_name` را برای این 3 API بفرستید. +- فقط `farm_uuid` اجباری است. +- اگر کاربر plan خاصی را انتخاب کرده، `irrigation_plan_id` و/یا `fertilization_plan_id` را هم بفرستید. +- response هر 3 endpoint با ساختار یکنواخت `code`, `msg`, `data` برمی‌گردد. +- backend خودش payload مناسب AI را از context مزرعه و planهای انتخابی می‌سازد. diff --git a/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py b/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py index 9496320..e462441 100644 --- a/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py +++ b/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py @@ -39,7 +39,7 @@ def sync_farmer_calendar_schema(apps, schema_editor): updated_at DATETIME(6) NOT NULL, farm_id BIGINT NOT NULL, CONSTRAINT farmer_calendar_zones_farm_id_fk - FOREIGN KEY (farm_id) REFERENCES farm_hub_farmhub (id) + FOREIGN KEY (farm_id) REFERENCES farm_hubs (id) ) """ ) diff --git a/fertilization/FERTILIZATION_PLAN_APIS.md b/fertilization/FERTILIZATION_PLAN_APIS.md new file mode 100644 index 0000000..129da9e --- /dev/null +++ b/fertilization/FERTILIZATION_PLAN_APIS.md @@ -0,0 +1,232 @@ +# Fertilization Plan APIs + +این فایل APIهای مدیریت برنامه‌های کودی را توضیح می‌دهد. + +Base path: + +`/api/fertilization/` + +این APIها فقط روی برنامه‌های متعلق به کاربر لاگین‌شده عمل می‌کنند. + +--- + +## 1) دریافت لیست برنامه‌های کودی + +### Request + +- Method: `GET` +- URL: `/api/fertilization/plans/` +- Query params: + - `farm_uuid` الزامی + - `page` اختیاری + - `page_size` اختیاری، حداکثر `100` + +### Example + +```http +GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10 +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه کودی گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": true, + "created_at": "2025-02-24T10:20:30Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 1, + "total_items": 1, + "has_next": false, + "has_previous": false, + "next": null, + "previous": null + } +} +``` + +### Notes + +- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند. +- ترتیب لیست از جدید به قدیم است. + +--- + +## 2) دریافت جزئیات یک برنامه کودی + +### Request + +- Method: `GET` +- URL: `/api/fertilization/plans/{plan_uuid}/` +- Path param: + - `plan_uuid` الزامی + +### Example + +```http +GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه کودی گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": true, + "created_at": "2025-02-24T10:20:30Z", + "updated_at": "2025-02-24T10:20:30Z", + "plan_payload": { + "title": "برنامه کودی گندم", + "items": [ + { + "name": "NPK 20-20-20" + } + ] + } + } +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +### Notes + +- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده می‌شود. + +--- + +## 3) حذف برنامه کودی + +### Request + +- Method: `DELETE` +- URL: `/api/fertilization/plans/{plan_uuid}/` + +### Example + +```http +DELETE /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_deleted": true + } +} +``` + +### Behavior + +- حذف به‌صورت `soft delete` انجام می‌شود. +- در عمل: + - `is_deleted = true` + - `is_active = false` + - `deleted_at` مقداردهی می‌شود + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## 4) تغییر وضعیت فعال بودن برنامه کودی + +### Request + +- Method: `PATCH` +- URL: `/api/fertilization/plans/{plan_uuid}/status/` +- Body: + - `is_active` الزامی، `boolean` + +### Example + +```http +PATCH /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/ +Content-Type: application/json + +{ + "is_active": false +} +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_active": false + } +} +``` + +### Validation Error + +```json +{ + "is_active": [ + "This field is required." + ] +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## Summary + +- `GET /api/fertilization/plans/` لیست برنامه‌ها +- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه +- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه +- `PATCH /api/fertilization/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه diff --git a/fertilization/migrations/0002_recommendation_status_lifecycle.py b/fertilization/migrations/0002_recommendation_status_lifecycle.py index b8cba2f..e23b588 100644 --- a/fertilization/migrations/0002_recommendation_status_lifecycle.py +++ b/fertilization/migrations/0002_recommendation_status_lifecycle.py @@ -6,7 +6,7 @@ OLD_STATUSES = {"", "success", "error", None} def migrate_existing_statuses(apps, schema_editor): - Recommendation = apps.get_model("fertilization", "FertilizationRecommendationRequest") + Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest") Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update( status=PENDING_STATUS ) @@ -15,7 +15,7 @@ def migrate_existing_statuses(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("fertilization", "0001_initial"), + ("fertilization_recommendation", "0001_initial"), ] operations = [ diff --git a/fertilization/migrations/0003_fertilizationplan.py b/fertilization/migrations/0003_fertilizationplan.py new file mode 100644 index 0000000..e240dfb --- /dev/null +++ b/fertilization/migrations/0003_fertilizationplan.py @@ -0,0 +1,44 @@ +import django.db.models.deletion +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("fertilization_recommendation", "0002_recommendation_status_lifecycle"), + ] + + operations = [ + migrations.CreateModel( + name="FertilizationPlan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("plan_payload", models.JSONField(blank=True, default=dict)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("is_deleted", models.BooleanField(db_index=True, default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="fertilization_plans", to="farm_hub.farmhub"), + ), + ( + "recommendation", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="fertilization_recommendation.fertilizationrecommendationrequest"), + ), + ], + options={ + "db_table": "fertilization_plans", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/fertilization/models.py b/fertilization/models.py index 908834c..4309e5a 100644 --- a/fertilization/models.py +++ b/fertilization/models.py @@ -1,6 +1,7 @@ import uuid from django.db import models +from django.utils import timezone from farm_hub.models import FarmHub @@ -41,3 +42,51 @@ class FertilizationRecommendationRequest(models.Model): def __str__(self): return self.task_id or str(self.uuid) + + +class FertilizationPlan(models.Model): + SOURCE_RECOMMENDATION = "recommendation" + SOURCE_FREE_TEXT = "free_text" + SOURCE_CHOICES = ( + (SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"), + (SOURCE_FREE_TEXT, "متن آزاد کاربر"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="fertilization_plans", + ) + source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True) + recommendation = models.ForeignKey( + FertilizationRecommendationRequest, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="plans", + ) + title = models.CharField(max_length=255, blank=True, default="") + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + plan_payload = models.JSONField(default=dict, blank=True) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True, db_index=True) + is_deleted = models.BooleanField(default=False, db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "fertilization_plans" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.title or self.crop_id or str(self.uuid) + + def soft_delete(self): + self.is_deleted = True + self.is_active = False + self.deleted_at = timezone.now() + self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"]) diff --git a/fertilization/serializers.py b/fertilization/serializers.py index dd101f8..3b8ea55 100644 --- a/fertilization/serializers.py +++ b/fertilization/serializers.py @@ -167,3 +167,39 @@ class FertilizationRecommendResponseDataSerializer(serializers.Serializer): application_guide = ApplicationGuideSerializer(read_only=True) alternative_recommendations = AlternativeRecommendationSerializer(many=True, read_only=True) sections = FertilizationSectionSerializer(many=True, read_only=True) + + +class FertilizationPlanListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های کودی.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class FertilizationPlanListItemSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + + +class FertilizationPlanDetailSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + plan_payload = serializers.DictField(read_only=True) + + +class FertilizationPlanStatusUpdateSerializer(serializers.Serializer): + is_active = serializers.BooleanField(required=True) diff --git a/fertilization/services.py b/fertilization/services.py index e520c9e..49b55bb 100644 --- a/fertilization/services.py +++ b/fertilization/services.py @@ -1,7 +1,7 @@ from copy import deepcopy from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA -from .models import FertilizationRecommendationRequest +from .models import FertilizationPlan, FertilizationRecommendationRequest def _extract_result(response_payload): @@ -31,6 +31,51 @@ def _get_latest_result(farm): return {} +def get_active_plan_payload(farm): + if farm is None: + return {} + + plan = ( + FertilizationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False) + .order_by("-created_at", "-id") + .first() + ) + if plan is None or not isinstance(plan.plan_payload, dict): + return {} + + return deepcopy(plan.plan_payload) + + +def build_active_plan_context(farm): + plan_payload = get_active_plan_payload(farm) + if not plan_payload: + return {} + + context = {"plan_payload": plan_payload} + + primary_recommendation = plan_payload.get("primary_recommendation") + if isinstance(primary_recommendation, dict) and primary_recommendation: + context["primary_recommendation"] = deepcopy(primary_recommendation) + + nutrient_analysis = plan_payload.get("nutrient_analysis") + if isinstance(nutrient_analysis, dict) and nutrient_analysis: + context["nutrient_analysis"] = deepcopy(nutrient_analysis) + + application_guide = plan_payload.get("application_guide") + if isinstance(application_guide, dict) and application_guide: + context["application_guide"] = deepcopy(application_guide) + + alternative_recommendations = plan_payload.get("alternative_recommendations") + if isinstance(alternative_recommendations, list) and alternative_recommendations: + context["alternative_recommendations"] = deepcopy(alternative_recommendations) + + sections = plan_payload.get("sections") + if isinstance(sections, list) and sections: + context["sections"] = deepcopy(sections) + + return context + + def get_fertilization_dashboard_recommendation(farm=None): default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION) result = _get_latest_result(farm) diff --git a/fertilization/tests.py b/fertilization/tests.py index 5525512..310f61f 100644 --- a/fertilization/tests.py +++ b/fertilization/tests.py @@ -5,8 +5,16 @@ from unittest.mock import patch from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType -from .models import FertilizationRecommendationRequest -from .views import PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView +from .models import FertilizationPlan, FertilizationRecommendationRequest +from .views import ( + FertilizationPlanDetailView, + FertilizationPlanListView, + FertilizationPlanStatusView, + PlanFromTextView, + RecommendationDetailView, + RecommendationListView, + RecommendView, +) class FertilizationRecommendViewTests(TestCase): @@ -51,6 +59,7 @@ class FertilizationRecommendViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["data"]["status"], "needs_clarification") + self.assertEqual(FertilizationPlan.objects.count(), 0) mock_external_api_request.assert_called_once_with( "ai", "/api/fertilization/plan-from-text/", @@ -143,9 +152,16 @@ class FertilizationRecommendViewTests(TestCase): self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation") self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation") self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1) + self.assertEqual(FertilizationPlan.objects.count(), 1) saved_request = FertilizationRecommendationRequest.objects.get() + saved_plan = FertilizationPlan.objects.get() self.assertEqual(saved_request.crop_id, "گندم") self.assertEqual(saved_request.growth_stage, "vegetative") + self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION) + self.assertEqual(saved_plan.recommendation_id, saved_request.id) + self.assertTrue(saved_plan.is_active) + self.assertFalse(saved_plan.is_deleted) + self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020") self.assertEqual( saved_request.status, FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, @@ -194,6 +210,79 @@ class FertilizationRecommendViewTests(TestCase): }, ) + @patch("fertilization.views.external_api_request") + def test_recommend_includes_active_fertilization_plan_in_ai_payload(self, mock_external_api_request): + FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه فعال", + crop_id="گندم", + growth_stage="vegetative", + plan_payload={ + "primary_recommendation": {"fertilizer_code": "npk-101010", "fertilizer_name": "NPK 10-10-10"}, + "nutrient_analysis": {"macro": [{"key": "n", "value": 10}]}, + "application_guide": {"steps": [{"step_number": 1, "title": "مرحله اول"}]}, + "sections": [{"type": "recommendation", "title": "اصلی"}], + }, + is_active=True, + ) + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}}) + + request = self.factory.post( + "/api/fertilization/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "crop_id": "گندم", "growth_stage": "vegetative"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertIn("active_fertilization_plan", sent_payload) + self.assertEqual( + sent_payload["active_fertilization_plan"]["primary_recommendation"]["fertilizer_code"], + "npk-101010", + ) + + @patch("fertilization.views.external_api_request") + def test_plan_from_text_creates_plan_when_final_plan_exists(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "final_plan": { + "title": "برنامه کوددهی گندم", + "crop_name": "گندم", + "growth_stage": "flowering", + "items": [{"name": "NPK 20-20-20"}], + }, + }, + }, + ) + + request = self.factory.post( + "/api/fertilization/plan-from-text/", + {"message": "برنامه کودی", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(FertilizationPlan.objects.count(), 1) + plan = FertilizationPlan.objects.get() + self.assertEqual(plan.source, FertilizationPlan.SOURCE_FREE_TEXT) + self.assertEqual(plan.title, "برنامه کوددهی گندم") + self.assertEqual(plan.crop_id, "گندم") + self.assertEqual(plan.growth_stage, "flowering") + self.assertTrue(plan.is_active) + self.assertFalse(plan.is_deleted) + def test_recommendation_list_returns_paginated_summary_items(self): first = FertilizationRecommendationRequest.objects.create( farm=self.farm, @@ -318,3 +407,79 @@ class FertilizationRecommendViewTests(TestCase): response.data["data"]["primary_recommendation"]["fertilizer_code"], "legacy-code-101", ) + + +class FertilizationPlanApiTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="fert-plan-user", + password="secret123", + email="fert-plan@example.com", + phone_number="09123334455", + ) + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-plan-farm") + self.plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه نمونه", + crop_id="گوجه", + growth_stage="flowering", + plan_payload={"items": [{"title": "مرحله اول"}]}, + ) + + def test_plan_list_returns_non_deleted_plans(self): + FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_RECOMMENDATION, + title="حذف شده", + is_deleted=True, + is_active=False, + ) + + request = self.factory.get(f"/api/fertilization/plans/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FertilizationPlanListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid)) + self.assertEqual(response.data["data"][0]["source"], FertilizationPlan.SOURCE_FREE_TEXT) + + def test_plan_detail_returns_plan_payload(self): + request = self.factory.get(f"/api/fertilization/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid)) + self.assertEqual(response.data["data"]["plan_payload"]["items"][0]["title"], "مرحله اول") + + def test_plan_delete_is_soft_delete(self): + request = self.factory.delete(f"/api/fertilization/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertTrue(self.plan.is_deleted) + self.assertFalse(self.plan.is_active) + + def test_plan_status_patch_updates_is_active(self): + request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": False}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertFalse(self.plan.is_active) diff --git a/fertilization/urls.py b/fertilization/urls.py index 97c0930..c924568 100644 --- a/fertilization/urls.py +++ b/fertilization/urls.py @@ -1,9 +1,21 @@ from django.urls import path -from .views import ConfigView, PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView +from .views import ( + ConfigView, + FertilizationPlanDetailView, + FertilizationPlanListView, + FertilizationPlanStatusView, + PlanFromTextView, + RecommendationDetailView, + RecommendationListView, + RecommendView, +) urlpatterns = [ path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), + path("plans/", FertilizationPlanListView.as_view(), name="fertilization-plan-list"), + path("plans//", FertilizationPlanDetailView.as_view(), name="fertilization-plan-detail"), + path("plans//status/", FertilizationPlanStatusView.as_view(), name="fertilization-plan-status"), 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/views.py b/fertilization/views.py index 9568829..7f9c800 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -15,11 +15,16 @@ from drf_spectacular.utils import extend_schema from config.swagger import code_response, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from .models import FertilizationPlan, FertilizationRecommendationRequest +from .services import build_active_plan_context from .mock_data import CONFIG_RESPONSE_DATA -from .models import FertilizationRecommendationRequest from .serializers import ( FreeTextPlanParserRequestSerializer, FreeTextPlanParserResponseDataSerializer, + FertilizationPlanDetailSerializer, + FertilizationPlanListItemSerializer, + FertilizationPlanListQuerySerializer, + FertilizationPlanStatusUpdateSerializer, FertilizationRecommendationListItemSerializer, FertilizationRecommendationListQuerySerializer, FertilizationRecommendRequestSerializer, @@ -358,6 +363,34 @@ class RecommendView(FarmAccessMixin, APIView): "sections": normalized_sections, } + @staticmethod + def _build_plan_title(crop_id, growth_stage, primary_recommendation): + fertilizer_name = str(primary_recommendation.get("display_title") or primary_recommendation.get("fertilizer_name") or "").strip() + parts = [part for part in [fertilizer_name, crop_id, growth_stage] if part] + return " - ".join(parts) if parts else "برنامه کودی" + + def _create_plan_from_recommendation(self, recommendation, public_data): + primary_recommendation = public_data.get("primary_recommendation", {}) + FertilizationPlan.objects.create( + farm=recommendation.farm, + source=FertilizationPlan.SOURCE_RECOMMENDATION, + recommendation=recommendation, + title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, primary_recommendation), + crop_id=recommendation.crop_id, + growth_stage=recommendation.growth_stage, + plan_payload=public_data, + request_payload=recommendation.request_payload, + response_payload=recommendation.response_payload, + ) + + @staticmethod + def _enrich_ai_payload(payload, farm): + enriched_payload = payload.copy() + active_plan_context = build_active_plan_context(farm) + if active_plan_context: + enriched_payload["active_fertilization_plan"] = active_plan_context + return enriched_payload + @extend_schema( tags=["Fertilization Recommendation"], request=FertilizationRecommendRequestSerializer, @@ -374,12 +407,13 @@ class RecommendView(FarmAccessMixin, APIView): payload["crop_id"] = crop_id payload["plant_name"] = plant_name payload["growth_stage"] = payload.get("growth_stage", "") + ai_payload = self._enrich_ai_payload(payload, farm) adapter_response = external_api_request( "ai", "/api/fertilization/recommend/", method="POST", - payload=payload, + payload=ai_payload, ) response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} @@ -393,13 +427,13 @@ class RecommendView(FarmAccessMixin, APIView): len(public_data.get("sections", [])), ) - FertilizationRecommendationRequest.objects.create( + recommendation = FertilizationRecommendationRequest.objects.create( farm=farm, crop_id=crop_id, growth_stage=payload.get("growth_stage", ""), task_id="", status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, - request_payload=payload, + request_payload=ai_payload, response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, ) if adapter_response.status_code >= 400: @@ -412,6 +446,8 @@ class RecommendView(FarmAccessMixin, APIView): status=adapter_response.status_code, ) + self._create_plan_from_recommendation(recommendation, public_data) + return Response( { "code": 200, @@ -490,6 +526,30 @@ class RecommendationDetailView(FarmAccessMixin, APIView): class PlanFromTextView(FarmAccessMixin, APIView): + @staticmethod + def _extract_final_plan(response_data): + if not isinstance(response_data, dict): + return None + data = response_data.get("data") + if isinstance(data, dict): + final_plan = data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + final_plan = response_data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + return None + + @staticmethod + def _build_free_text_plan_title(final_plan): + if not isinstance(final_plan, dict): + return "برنامه کودی" + for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"): + value = str(final_plan.get(key, "")).strip() + if value: + return value + return "برنامه کودی" + @extend_schema( tags=["Fertilization Recommendation"], request=FreeTextPlanParserRequestSerializer, @@ -519,7 +579,114 @@ class PlanFromTextView(FarmAccessMixin, APIView): status=adapter_response.status_code, ) + final_plan = self._extract_final_plan(response_data) + if final_plan and farm_uuid: + FertilizationPlan.objects.create( + farm=farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title=self._build_free_text_plan_title(final_plan), + crop_id=str( + final_plan.get("crop_id") + or final_plan.get("crop_name") + or final_plan.get("plant_name") + or "" + ).strip(), + growth_stage=str(final_plan.get("growth_stage") or "").strip(), + plan_payload=final_plan, + request_payload=payload, + response_payload=response_data, + ) + return Response( {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, status=status.HTTP_200_OK, ) + + +class FertilizationPlanListView(FarmAccessMixin, APIView): + pagination_class = FertilizationRecommendationPagination + + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[FertilizationPlanListQuerySerializer], + responses={200: code_response("FertilizationPlanListResponse")}, + ) + def get(self, request): + serializer = FertilizationPlanListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + plans = farm.fertilization_plans.filter(is_deleted=False).order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(plans, request, view=self) + data = FertilizationPlanListItemSerializer(page, many=True).data + return paginator.get_paginated_response(data) + + +class FertilizationPlanDetailView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[ + OpenApiParameter( + name="plan_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={200: code_response("FertilizationPlanDetailResponse", data=FertilizationPlanDetailSerializer())}, + ) + def get(self, request, plan_uuid): + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).select_related("farm").first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + data = FertilizationPlanDetailSerializer(plan).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Fertilization Recommendation"], + responses={200: status_response("FertilizationPlanDeleteResponse", data=serializers.JSONField())}, + ) + def delete(self, request, plan_uuid): + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + plan.soft_delete() + return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK) + + +class FertilizationPlanStatusView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + request=FertilizationPlanStatusUpdateSerializer, + responses={200: code_response("FertilizationPlanStatusResponse", data=serializers.JSONField())}, + ) + def patch(self, request, plan_uuid): + serializer = FertilizationPlanStatusUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + plan.is_active = serializer.validated_data["is_active"] + plan.save(update_fields=["is_active", "updated_at"]) + return Response( + {"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}}, + status=status.HTTP_200_OK, + ) diff --git a/irrigation/IRRIGATION_PLAN_APIS.md b/irrigation/IRRIGATION_PLAN_APIS.md new file mode 100644 index 0000000..e1b47cf --- /dev/null +++ b/irrigation/IRRIGATION_PLAN_APIS.md @@ -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/` فعال/غیرفعال کردن برنامه diff --git a/irrigation/migrations/0002_recommendation_status_and_growth_stage.py b/irrigation/migrations/0002_recommendation_status_and_growth_stage.py index 7ef10a4..7f93df4 100644 --- a/irrigation/migrations/0002_recommendation_status_and_growth_stage.py +++ b/irrigation/migrations/0002_recommendation_status_and_growth_stage.py @@ -3,7 +3,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("irrigation", "0001_initial"), + ("irrigation_recommendation", "0001_initial"), ] operations = [ diff --git a/irrigation/migrations/0003_irrigationplan.py b/irrigation/migrations/0003_irrigationplan.py new file mode 100644 index 0000000..0d0e0f7 --- /dev/null +++ b/irrigation/migrations/0003_irrigationplan.py @@ -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"], + }, + ), + ] diff --git a/irrigation/models.py b/irrigation/models.py index 6532ffa..070f7cb 100644 --- a/irrigation/models.py +++ b/irrigation/models.py @@ -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"]) diff --git a/irrigation/serializers.py b/irrigation/serializers.py index 18d158e..6a6770b 100644 --- a/irrigation/serializers.py +++ b/irrigation/serializers.py @@ -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) diff --git a/irrigation/services.py b/irrigation/services.py index fbca39d..c01d8fd 100644 --- a/irrigation/services.py +++ b/irrigation/services.py @@ -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 {} diff --git a/irrigation/tests.py b/irrigation/tests.py index 49ae3af..95b1885 100644 --- a/irrigation/tests.py +++ b/irrigation/tests.py @@ -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) diff --git a/irrigation/urls.py b/irrigation/urls.py index 5ffaa4a..acb3fcb 100644 --- a/irrigation/urls.py +++ b/irrigation/urls.py @@ -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//", IrrigationPlanDetailView.as_view(), name="irrigation-plan-detail"), + path("plans//status/", IrrigationPlanStatusView.as_view(), name="irrigation-plan-status"), 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"), diff --git a/irrigation/views.py b/irrigation/views.py index dba2960..39dc12b 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -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, + ) diff --git a/yield_harvest/serializers.py b/yield_harvest/serializers.py index 861ae8b..784972e 100644 --- a/yield_harvest/serializers.py +++ b/yield_harvest/serializers.py @@ -64,6 +64,16 @@ class CropSimulationRequestSerializer(serializers.Serializer): initial="11111111-1111-1111-1111-111111111111", help_text="UUID مزرعه برای اجرای شبیه‌سازی.", ) + irrigation_plan_id = serializers.IntegerField( + required=False, + min_value=1, + help_text="شناسه داخلی برنامه آبیاری برای ارسال context به AI.", + ) + fertilization_plan_id = serializers.IntegerField( + required=False, + min_value=1, + help_text="شناسه داخلی برنامه کودی برای ارسال context به AI.", + ) class GrowthSimulationRequestSerializer(serializers.Serializer): diff --git a/yield_harvest/tests.py b/yield_harvest/tests.py index 6065bb5..3cc21e4 100644 --- a/yield_harvest/tests.py +++ b/yield_harvest/tests.py @@ -6,6 +6,8 @@ from rest_framework.test import APIClient, APIRequestFactory, force_authenticate from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product +from fertilization.models import FertilizationPlan +from irrigation.models import IrrigationPlan from .views import ( CurrentFarmChartView, @@ -378,6 +380,52 @@ class CropSimulationViewTests(TestCase): payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, ) + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_includes_selected_plans(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + plan_payload={"plan": {"durationMinutes": 20}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "series": []}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "irrigation_plan_id": irrigation_plan.id}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_includes_selected_plans(self, mock_external_api_request): + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-151515"}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid), "fertilization_plan_id": fertilization_plan.id}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id) + @patch("yield_harvest.views.external_api_request") def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( @@ -470,6 +518,72 @@ class CropSimulationViewTests(TestCase): payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجه‌فرنگی"}, ) + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_includes_selected_irrigation_and_fertilization_plans(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + crop_id="گوجه‌فرنگی", + growth_stage="flowering", + plan_payload={"plan": {"durationMinutes": 30}}, + request_payload={"source": "manual"}, + response_payload={"ok": True}, + ) + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + crop_id="گوجه‌فرنگی", + growth_stage="flowering", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-202020"}}, + request_payload={"source": "manual"}, + response_payload={"ok": True}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + { + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_plan_id": irrigation_plan.id, + "fertilization_plan_id": fertilization_plan.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id) + self.assertEqual(sent_payload["irrigation_plan"]["plan_payload"]["plan"]["durationMinutes"], 30) + self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id) + self.assertEqual( + sent_payload["fertilization_plan"]["plan_payload"]["primary_recommendation"]["fertilizer_code"], + "npk-202020", + ) + + def test_yield_prediction_rejects_foreign_plan_ids(self): + other_irrigation_plan = IrrigationPlan.objects.create( + farm=self.other_farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="other irrigation", + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + { + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_plan_id": other_irrigation_plan.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["data"]["irrigation_plan_id"][0], "Irrigation plan not found.") + @patch("yield_harvest.views.external_api_request") def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( @@ -520,6 +634,34 @@ class CropSimulationViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid)) + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_includes_selected_plans_in_query(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + plan_payload={"plan": {"durationMinutes": 18}}, + ) + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-111111"}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}}, + ) + + response = self.api_client.get( + f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&irrigation_plan_id={irrigation_plan.id}&fertilization_plan_id={fertilization_plan.id}" + ) + + self.assertEqual(response.status_code, 200) + sent_query = mock_external_api_request.call_args.kwargs["query"] + self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id) + self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id) + def test_crop_simulation_rejects_foreign_farm_uuid(self): request = self.factory.post( "/api/yield-harvest/crop-simulation/yield-prediction/", diff --git a/yield_harvest/views.py b/yield_harvest/views.py index c2b3f54..a588c6e 100644 --- a/yield_harvest/views.py +++ b/yield_harvest/views.py @@ -9,6 +9,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from config.swagger import code_response, farm_uuid_query_param from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub +from fertilization.models import FertilizationPlan +from irrigation.models import IrrigationPlan from .models import YieldHarvestPredictionLog from .serializers import ( CropSimulationRequestSerializer, @@ -77,6 +79,20 @@ class YieldHarvestSummaryView(APIView): if error_response is not None: return error_response + irrigation_plan_id, irrigation_plan_error = CropSimulationBaseView._parse_optional_plan_id( + request.query_params.get("irrigation_plan_id"), + "irrigation_plan_id", + ) + if irrigation_plan_error is not None: + return irrigation_plan_error + + fertilization_plan_id, fertilization_plan_error = CropSimulationBaseView._parse_optional_plan_id( + request.query_params.get("fertilization_plan_id"), + "fertilization_plan_id", + ) + if fertilization_plan_error is not None: + return fertilization_plan_error + query = {"farm_uuid": str(farm.farm_uuid)} if request.query_params.get("season_year"): query["season_year"] = request.query_params.get("season_year") @@ -85,6 +101,15 @@ class YieldHarvestSummaryView(APIView): if request.query_params.get("include_narrative") is not None: query["include_narrative"] = request.query_params.get("include_narrative") + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_id=irrigation_plan_id, + fertilization_plan_id=fertilization_plan_id, + ) + if plan_error is not None: + return plan_error + query.update(ai_payload) + adapter_response = external_api_request( "ai", "/api/crop-simulation/yield-harvest-summary/", @@ -191,6 +216,98 @@ class CropSimulationBaseView(APIView): return "" + @staticmethod + def _get_irrigation_plan_or_error(farm, plan_id): + if not plan_id: + return None, None + + plan = IrrigationPlan.objects.filter( + id=plan_id, + farm=farm, + is_deleted=False, + ).first() + if plan is None: + return None, Response( + {"code": 404, "msg": "error", "data": {"irrigation_plan_id": ["Irrigation plan not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + return plan, None + + @staticmethod + def _get_fertilization_plan_or_error(farm, plan_id): + if not plan_id: + return None, None + + plan = FertilizationPlan.objects.filter( + id=plan_id, + farm=farm, + is_deleted=False, + ).first() + if plan is None: + return None, Response( + {"code": 404, "msg": "error", "data": {"fertilization_plan_id": ["Fertilization plan not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + return plan, None + + @staticmethod + def _build_plan_payload(plan): + if plan is None: + return None + + return { + "id": plan.id, + "uuid": str(plan.uuid), + "source": plan.source, + "title": plan.title, + "crop_id": plan.crop_id, + "growth_stage": plan.growth_stage, + "is_active": plan.is_active, + "plan_payload": plan.plan_payload if isinstance(plan.plan_payload, dict) else {}, + "request_payload": plan.request_payload if isinstance(plan.request_payload, dict) else {}, + "response_payload": plan.response_payload if isinstance(plan.response_payload, dict) else {}, + } + + def _build_ai_payload_with_selected_plans(self, farm, irrigation_plan_id=None, fertilization_plan_id=None): + irrigation_plan, irrigation_error = self._get_irrigation_plan_or_error(farm, irrigation_plan_id) + if irrigation_error is not None: + return None, irrigation_error + + fertilization_plan, fertilization_error = self._get_fertilization_plan_or_error( + farm, fertilization_plan_id + ) + if fertilization_error is not None: + return None, fertilization_error + + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": self._get_first_farm_product_name(farm), + } + if irrigation_plan is not None: + ai_payload["irrigation_plan"] = self._build_plan_payload(irrigation_plan) + if fertilization_plan is not None: + ai_payload["fertilization_plan"] = self._build_plan_payload(fertilization_plan) + + return ai_payload, None + + @staticmethod + def _parse_optional_plan_id(raw_value, field_name): + if raw_value in (None, ""): + return None, None + try: + parsed_value = int(raw_value) + except (TypeError, ValueError): + return None, Response( + {"code": 400, "msg": "error", "data": {field_name: ["A valid integer is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + if parsed_value < 1: + return None, Response( + {"code": 400, "msg": "error", "data": {field_name: ["Ensure this value is greater than or equal to 1."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + return parsed_value, None + class CurrentFarmChartView(CropSimulationBaseView): ai_path = "/api/crop-simulation/current-farm-chart/" @@ -209,10 +326,13 @@ class CurrentFarmChartView(CropSimulationBaseView): if error_response is not None: return error_response - ai_payload = { - "farm_uuid": str(farm.farm_uuid), - "plant_name": self._get_first_farm_product_name(farm), - } + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_id=payload.get("irrigation_plan_id"), + fertilization_plan_id=payload.get("fertilization_plan_id"), + ) + if plan_error is not None: + return plan_error adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) if adapter_response.status_code >= 400: @@ -241,10 +361,13 @@ class HarvestPredictionView(CropSimulationBaseView): if error_response is not None: return error_response - ai_payload = { - "farm_uuid": str(farm.farm_uuid), - "plant_name": self._get_first_farm_product_name(farm), - } + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_id=payload.get("irrigation_plan_id"), + fertilization_plan_id=payload.get("fertilization_plan_id"), + ) + if plan_error is not None: + return plan_error adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) if adapter_response.status_code >= 400: @@ -273,10 +396,13 @@ class YieldPredictionView(CropSimulationBaseView): if error_response is not None: return error_response - ai_payload = { - "farm_uuid": str(farm.farm_uuid), - "plant_name": self._get_first_farm_product_name(farm), - } + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_id=payload.get("irrigation_plan_id"), + fertilization_plan_id=payload.get("fertilization_plan_id"), + ) + if plan_error is not None: + return plan_error adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) if adapter_response.status_code >= 400: