This commit is contained in:
2026-05-02 14:36:26 +03:30
parent f34d5dd198
commit 9c37e98b33
24 changed files with 2021 additions and 29 deletions
+232
View File
@@ -0,0 +1,232 @@
# Fertilization Plan APIs
این فایل APIهای مدیریت برنامه‌های کودی را توضیح می‌دهد.
Base path:
`/api/fertilization/`
این APIها فقط روی برنامه‌های متعلق به کاربر لاگین‌شده عمل می‌کنند.
---
## 1) دریافت لیست برنامه‌های کودی
### Request
- Method: `GET`
- URL: `/api/fertilization/plans/`
- Query params:
- `farm_uuid` الزامی
- `page` اختیاری
- `page_size` اختیاری، حداکثر `100`
### Example
```http
GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": [
{
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"source": "free_text",
"source_label": "متن آزاد کاربر",
"title": "برنامه کودی گندم",
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"created_at": "2025-02-24T10:20:30Z"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total_pages": 1,
"total_items": 1,
"has_next": false,
"has_previous": false,
"next": null,
"previous": null
}
}
```
### Notes
- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند.
- ترتیب لیست از جدید به قدیم است.
---
## 2) دریافت جزئیات یک برنامه کودی
### Request
- Method: `GET`
- URL: `/api/fertilization/plans/{plan_uuid}/`
- Path param:
- `plan_uuid` الزامی
### Example
```http
GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"source": "free_text",
"source_label": "متن آزاد کاربر",
"title": "برنامه کودی گندم",
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"created_at": "2025-02-24T10:20:30Z",
"updated_at": "2025-02-24T10:20:30Z",
"plan_payload": {
"title": "برنامه کودی گندم",
"items": [
{
"name": "NPK 20-20-20"
}
]
}
}
}
```
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
### Notes
- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده می‌شود.
---
## 3) حذف برنامه کودی
### Request
- Method: `DELETE`
- URL: `/api/fertilization/plans/{plan_uuid}/`
### Example
```http
DELETE /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_deleted": true
}
}
```
### Behavior
- حذف به‌صورت `soft delete` انجام می‌شود.
- در عمل:
- `is_deleted = true`
- `is_active = false`
- `deleted_at` مقداردهی می‌شود
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
---
## 4) تغییر وضعیت فعال بودن برنامه کودی
### Request
- Method: `PATCH`
- URL: `/api/fertilization/plans/{plan_uuid}/status/`
- Body:
- `is_active` الزامی، `boolean`
### Example
```http
PATCH /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/
Content-Type: application/json
{
"is_active": false
}
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_active": false
}
}
```
### Validation Error
```json
{
"is_active": [
"This field is required."
]
}
```
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
---
## Summary
- `GET /api/fertilization/plans/` لیست برنامه‌ها
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه
- `PATCH /api/fertilization/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه
@@ -6,7 +6,7 @@ OLD_STATUSES = {"", "success", "error", None}
def migrate_existing_statuses(apps, schema_editor):
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 = [
@@ -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"],
},
),
]
+49
View File
@@ -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"])
+36
View File
@@ -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)
+46 -1
View File
@@ -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)
+167 -2
View File
@@ -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)
+13 -1
View File
@@ -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/<uuid:plan_uuid>/", FertilizationPlanDetailView.as_view(), name="fertilization-plan-detail"),
path("plans/<uuid:plan_uuid>/status/", FertilizationPlanStatusView.as_view(), name="fertilization-plan-status"),
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"),
path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
+171 -4
View File
@@ -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,
)