This commit is contained in:
2026-05-05 00:56:05 +03:30
parent 21b734f6a7
commit cfe60f6729
85 changed files with 1786 additions and 3840 deletions
+6 -3
View File
@@ -42,7 +42,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z"
}
],
@@ -63,6 +63,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag
- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند.
- ترتیب لیست از جدید به قدیم است.
- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد.
---
@@ -95,7 +96,7 @@ GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z",
"updated_at": "2025-02-24T10:20:30Z",
"plan_payload": {
@@ -198,7 +199,7 @@ Content-Type: application/json
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_active": false
"is_active": true
}
}
```
@@ -226,6 +227,8 @@ Content-Type: application/json
## Summary
- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند.
- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد.
- `GET /api/fertilization/plans/` لیست برنامه‌ها
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fertilization_recommendation", "0003_fertilizationplan"),
]
operations = [
migrations.AlterField(
model_name="fertilizationplan",
name="is_active",
field=models.BooleanField(db_index=True, default=False),
),
]
+1 -1
View File
@@ -72,7 +72,7 @@ class FertilizationPlan(models.Model):
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_active = models.BooleanField(default=False, 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)
+72 -4
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import FertilizationPlan, FertilizationRecommendationRequest
from .views import (
FertilizationPlanDetailView,
@@ -159,7 +160,7 @@ class FertilizationRecommendViewTests(TestCase):
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_active)
self.assertFalse(saved_plan.is_deleted)
self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020")
self.assertEqual(
@@ -280,7 +281,7 @@ class FertilizationRecommendViewTests(TestCase):
self.assertEqual(plan.title, "برنامه کوددهی گندم")
self.assertEqual(plan.crop_id, "گندم")
self.assertEqual(plan.growth_stage, "flowering")
self.assertTrue(plan.is_active)
self.assertFalse(plan.is_active)
self.assertFalse(plan.is_deleted)
def test_recommendation_list_returns_paginated_summary_items(self):
@@ -473,7 +474,7 @@ class FertilizationPlanApiTests(TestCase):
def test_plan_status_patch_updates_is_active(self):
request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": False},
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
@@ -482,4 +483,71 @@ class FertilizationPlanApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertFalse(self.plan.is_active)
self.assertTrue(self.plan.is_active)
def test_activating_one_plan_deactivates_other_active_plan(self):
other_plan = FertilizationPlan.objects.create(
farm=self.farm,
source=FertilizationPlan.SOURCE_FREE_TEXT,
title="برنامه دوم",
is_active=True,
)
request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": True},
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()
other_plan.refresh_from_db()
self.assertTrue(self.plan.is_active)
self.assertFalse(other_plan.is_active)
def test_plan_status_patch_syncs_calendar_events(self):
self.plan.plan_payload = {
"primary_recommendation": {
"fertilizer_code": "npk-202020",
"fertilizer_name": "NPK 20-20-20",
"application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"},
},
"application_guide": {
"steps": [
{"step_number": 1, "title": "مرحله اول", "description": "در آب حل شود", "date": "2025-02-14"}
]
},
}
self.plan.is_active = False
self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"])
activate_request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(activate_request, user=self.user)
activate_response = FertilizationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid)
self.assertEqual(activate_response.status_code, 200)
events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid))
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().extended_props["plan_type"], "fertilization")
deactivate_request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": False},
format="json",
)
force_authenticate(deactivate_request, user=self.user)
deactivate_response = FertilizationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid)
self.assertEqual(deactivate_response.status_code, 200)
self.assertFalse(
FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists()
)
+19 -3
View File
@@ -15,6 +15,7 @@ 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 farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events
from .models import FertilizationPlan, FertilizationRecommendationRequest
from .services import build_active_plan_context
from .mock_data import CONFIG_RESPONSE_DATA
@@ -371,7 +372,7 @@ class RecommendView(FarmAccessMixin, APIView):
def _create_plan_from_recommendation(self, recommendation, public_data):
primary_recommendation = public_data.get("primary_recommendation", {})
FertilizationPlan.objects.create(
plan = FertilizationPlan.objects.create(
farm=recommendation.farm,
source=FertilizationPlan.SOURCE_RECOMMENDATION,
recommendation=recommendation,
@@ -382,6 +383,7 @@ class RecommendView(FarmAccessMixin, APIView):
request_payload=recommendation.request_payload,
response_payload=recommendation.response_payload,
)
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
@staticmethod
def _enrich_ai_payload(payload, farm):
@@ -581,7 +583,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
final_plan = self._extract_final_plan(response_data)
if final_plan and farm_uuid:
FertilizationPlan.objects.create(
plan = FertilizationPlan.objects.create(
farm=farm,
source=FertilizationPlan.SOURCE_FREE_TEXT,
title=self._build_free_text_plan_title(final_plan),
@@ -596,6 +598,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
request_payload=payload,
response_payload=response_data,
)
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
@@ -663,6 +666,7 @@ class FertilizationPlanDetailView(APIView):
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.soft_delete()
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid)
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
@@ -684,8 +688,20 @@ class FertilizationPlanStatusView(APIView):
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"]
new_is_active = serializer.validated_data["is_active"]
if new_is_active:
FertilizationPlan.objects.filter(
farm=plan.farm,
is_deleted=False,
is_active=True,
).exclude(pk=plan.pk).update(is_active=False)
plan.is_active = new_is_active
plan.save(update_fields=["is_active", "updated_at"])
if plan.is_active:
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
else:
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid)
return Response(
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
status=status.HTTP_200_OK,