UPDATE
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
# 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": false,
|
||||
"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` باشند.
|
||||
- ترتیب لیست از جدید به قدیم است.
|
||||
- در هر مزرعه، در هر نوع plan فقط یک plan میتواند `is_active=true` باشد.
|
||||
|
||||
---
|
||||
|
||||
## 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": false,
|
||||
"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": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Error
|
||||
|
||||
```json
|
||||
{
|
||||
"is_active": [
|
||||
"This field is required."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 404,
|
||||
"msg": "Plan not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- planهای جدید بهصورت پیشفرض `inactive` ساخته میشوند.
|
||||
- در هر مزرعه فقط یک plan از این نوع میتواند `active` باشد.
|
||||
- `GET /api/fertilization/plans/` لیست برنامهها
|
||||
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
|
||||
- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه
|
||||
- `PATCH /api/fertilization/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FertilizationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "fertilization"
|
||||
label = "fertilization_recommendation"
|
||||
verbose_name = "Fertilization Recommendation & Plan Parser"
|
||||
@@ -0,0 +1,35 @@
|
||||
CONFIG_RESPONSE_TEMPLATE = {
|
||||
"farmData": {
|
||||
"soilType": None,
|
||||
"organicMatter": None,
|
||||
"waterEC": None,
|
||||
},
|
||||
"growthStages": [
|
||||
{"id": "prePlanting", "icon": "tabler-seedling"},
|
||||
{"id": "earlyGrowth", "icon": "tabler-leaf"},
|
||||
{"id": "flowering", "icon": "tabler-flower"},
|
||||
{"id": "fruiting", "icon": "tabler-apple"},
|
||||
{"id": "postHarvest", "icon": "tabler-basket"},
|
||||
],
|
||||
"cropOptions": [
|
||||
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
|
||||
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
|
||||
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
|
||||
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
|
||||
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
|
||||
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
|
||||
],
|
||||
"status": "success",
|
||||
"source": "default_template",
|
||||
}
|
||||
|
||||
|
||||
FERTILIZATION_DASHBOARD_TEMPLATE = {
|
||||
"title": "کود",
|
||||
"subtitle": "داده توصیه کودهی هنوز ثبت نشده است.",
|
||||
"avatarIcon": "tabler-leaf",
|
||||
"avatarColor": "success",
|
||||
"status": "empty",
|
||||
"source": "db",
|
||||
"warnings": ["No persisted fertilization recommendation is available for this farm."],
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("farm_hub", "0002_seed_default_catalog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FertilizationRecommendationRequest",
|
||||
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)),
|
||||
("crop_id", models.CharField(blank=True, default="", max_length=255)),
|
||||
("growth_stage", models.CharField(blank=True, default="", max_length=255)),
|
||||
("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)),
|
||||
("status", models.CharField(blank=True, default="", max_length=64)),
|
||||
("request_payload", models.JSONField(blank=True, default=dict)),
|
||||
("response_payload", models.JSONField(blank=True, default=dict)),
|
||||
("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="fertilizations",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "fertilization_requests",
|
||||
"ordering": ["-created_at", "-id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
PENDING_STATUS = "pending_confirmation"
|
||||
OLD_STATUSES = {"", "success", "error", None}
|
||||
|
||||
|
||||
def migrate_existing_statuses(apps, schema_editor):
|
||||
Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest")
|
||||
Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update(
|
||||
status=PENDING_STATUS
|
||||
)
|
||||
Recommendation.objects.filter(status__isnull=True).update(status=PENDING_STATUS)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("fertilization_recommendation", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="fertilizationrecommendationrequest",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("in_progress", "در حال مصرف"),
|
||||
("pending_confirmation", "منتظر تایید"),
|
||||
("completed", "پایان یافته"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending_confirmation",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_existing_statuses, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Static mock data for Fertilization Recommendation API.
|
||||
No database, no dynamic values.
|
||||
"""
|
||||
|
||||
CONFIG_RESPONSE_DATA = {
|
||||
"farmData": {
|
||||
"soilType": "Loamy",
|
||||
"organicMatter": "Medium (2.5%)",
|
||||
"waterEC": "1.2 dS/m",
|
||||
},
|
||||
"growthStages": [
|
||||
{"id": "prePlanting", "icon": "tabler-seedling"},
|
||||
{"id": "earlyGrowth", "icon": "tabler-leaf"},
|
||||
{"id": "flowering", "icon": "tabler-flower"},
|
||||
{"id": "fruiting", "icon": "tabler-apple"},
|
||||
{"id": "postHarvest", "icon": "tabler-basket"},
|
||||
],
|
||||
"cropOptions": [
|
||||
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
|
||||
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
|
||||
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
|
||||
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
|
||||
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
|
||||
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
|
||||
],
|
||||
}
|
||||
|
||||
RECOMMEND_RESPONSE_DATA = {
|
||||
"plan": {
|
||||
"npkRatio": "20-20-20 (NPK)",
|
||||
"amountPerHectare": "150 kg/ha",
|
||||
"applicationMethod": "Foliar spray + soil broadcast",
|
||||
"applicationInterval": "Every 14 days",
|
||||
"reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.",
|
||||
},
|
||||
}
|
||||
|
||||
FERTILIZATION_DASHBOARD_RECOMMENDATION = {
|
||||
"title": "کود: 20-20-20 (NPK)",
|
||||
"subtitle": "150 kg/ha، با روش Foliar spray + soil broadcast و هر 14 روز.",
|
||||
"avatarIcon": "tabler-leaf",
|
||||
"avatarColor": "success",
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
|
||||
class FertilizationRecommendationRequest(models.Model):
|
||||
STATUS_IN_PROGRESS = "in_progress"
|
||||
STATUS_PENDING_CONFIRMATION = "pending_confirmation"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_IN_PROGRESS, "در حال مصرف"),
|
||||
(STATUS_PENDING_CONFIRMATION, "منتظر تایید"),
|
||||
(STATUS_COMPLETED, "پایان یافته"),
|
||||
)
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey(
|
||||
FarmHub,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="fertilizations",
|
||||
)
|
||||
crop_id = models.CharField(max_length=255, blank=True, default="")
|
||||
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||
status = models.CharField(
|
||||
max_length=64,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_PENDING_CONFIRMATION,
|
||||
db_index=True,
|
||||
)
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "fertilization_requests"
|
||||
ordering = ["-created_at", "-id"]
|
||||
|
||||
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=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)
|
||||
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"])
|
||||
@@ -0,0 +1 @@
|
||||
{"info":{"name":"Fertilization Recommendation","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Fertilization Recommendation API. GET config (farm data, growth stages, crop options). POST recommend (optional body). Returns static plan. No database."},"item":[{"name":"Get config (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/fertilization-recommendation/config/","description":"Returns static farmData, growthStages, cropOptions."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"farmData\": {\n \"soilType\": \"Loamy\",\n \"organicMatter\": \"Medium (2.5%)\",\n \"waterEC\": \"1.2 dS/m\"\n },\n \"growthStages\": [\n {\"id\": \"prePlanting\", \"icon\": \"tabler-seedling\"},\n {\"id\": \"earlyGrowth\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"flowering\", \"icon\": \"tabler-flower\"},\n {\"id\": \"fruiting\", \"icon\": \"tabler-apple\"},\n {\"id\": \"postHarvest\", \"icon\": \"tabler-basket\"}\n ],\n \"cropOptions\": [\n {\"id\": \"wheat\", \"labelKey\": \"wheat\", \"icon\": \"tabler-wheat\"},\n {\"id\": \"corn\", \"labelKey\": \"corn\", \"icon\": \"tabler-plant-2\"},\n {\"id\": \"cotton\", \"labelKey\": \"cotton\", \"icon\": \"tabler-flower\"},\n {\"id\": \"saffron\", \"labelKey\": \"saffron\", \"icon\": \"tabler-flower-2\"},\n {\"id\": \"canola\", \"labelKey\": \"canola\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"vegetables\", \"labelKey\": \"vegetables\", \"icon\": \"tabler-carrot\"}\n ]\n }\n}"}]},{"name":"Get recommendation (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"crop_id\": \"wheat\",\n \"growth_stage\": \"flowering\",\n \"soilType\": \"Loamy\",\n \"organicMatter\": \"Medium (2.5%)\",\n \"waterEC\": \"1.2 dS/m\"\n}"},"url":"{{baseUrl}}/api/fertilization-recommendation/recommend/","description":"Optional body: crop_id, growth_stage, farm_data. Returns static plan. Input not processed."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"plan\": {\n \"npkRatio\": \"20-20-20 (NPK)\",\n \"amountPerHectare\": \"150 kg/ha\",\n \"applicationMethod\": \"Foliar spray + soil broadcast\",\n \"applicationInterval\": \"Every 14 days\",\n \"reasoning\": \"Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.\"\n }\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]}
|
||||
@@ -0,0 +1,205 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class FertilizationFarmDataSerializer(serializers.Serializer):
|
||||
soilType = serializers.CharField(required=False, allow_blank=True)
|
||||
organicMatter = serializers.CharField(required=False, allow_blank=True)
|
||||
waterEC = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FertilizationRecommendRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.")
|
||||
crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول. این فیلد همان plant_name است.")
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه. این فیلد همان crop_id است.")
|
||||
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
|
||||
|
||||
|
||||
class FertilizationRecommendationListQuerySerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های کودی.")
|
||||
page = serializers.IntegerField(required=False, min_value=1)
|
||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100)
|
||||
|
||||
|
||||
class FertilizationSectionSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"])
|
||||
title = serializers.CharField(required=False, allow_blank=True)
|
||||
icon = serializers.CharField(required=False, allow_blank=True)
|
||||
content = serializers.CharField(required=False, allow_blank=True)
|
||||
items = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
fertilizerType = serializers.CharField(required=False, allow_blank=True)
|
||||
amount = serializers.CharField(required=False, allow_blank=True)
|
||||
applicationMethod = serializers.CharField(required=False, allow_blank=True)
|
||||
timing = serializers.CharField(required=False, allow_blank=True)
|
||||
validityPeriod = serializers.CharField(required=False, allow_blank=True)
|
||||
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class NpkRatioSerializer(serializers.Serializer):
|
||||
n = serializers.FloatField(required=False)
|
||||
p = serializers.FloatField(required=False)
|
||||
k = serializers.FloatField(required=False)
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ApplicationMethodSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(required=False, allow_blank=True)
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ApplicationIntervalSerializer(serializers.Serializer):
|
||||
value = serializers.FloatField(required=False)
|
||||
unit = serializers.CharField(required=False, allow_blank=True)
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class DosageSerializer(serializers.Serializer):
|
||||
base_amount_per_hectare = serializers.FloatField(required=False)
|
||||
base_amount_per_square_meter = serializers.FloatField(required=False)
|
||||
unit = serializers.CharField(required=False, allow_blank=True)
|
||||
label = serializers.CharField(required=False, allow_blank=True)
|
||||
calculation_basis = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class PrimaryRecommendationSerializer(serializers.Serializer):
|
||||
fertilizer_code = serializers.CharField(required=False, allow_blank=True)
|
||||
fertilizer_name = serializers.CharField(required=False, allow_blank=True)
|
||||
display_title = serializers.CharField(required=False, allow_blank=True)
|
||||
fertilizer_type = serializers.CharField(required=False, allow_blank=True)
|
||||
npk_ratio = NpkRatioSerializer(required=False)
|
||||
application_method = ApplicationMethodSerializer(required=False)
|
||||
application_interval = ApplicationIntervalSerializer(required=False)
|
||||
dosage = DosageSerializer(required=False)
|
||||
reasoning = serializers.CharField(required=False, allow_blank=True)
|
||||
summary = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class NutrientItemSerializer(serializers.Serializer):
|
||||
key = serializers.CharField(required=False, allow_blank=True)
|
||||
name = serializers.CharField(required=False, allow_blank=True)
|
||||
value = serializers.FloatField(required=False)
|
||||
unit = serializers.CharField(required=False, allow_blank=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class NutrientAnalysisSerializer(serializers.Serializer):
|
||||
macro = NutrientItemSerializer(many=True, read_only=True)
|
||||
micro = NutrientItemSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
class ApplicationGuideStepSerializer(serializers.Serializer):
|
||||
step_number = serializers.IntegerField(required=False)
|
||||
title = serializers.CharField(required=False, allow_blank=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ApplicationGuideSerializer(serializers.Serializer):
|
||||
safety_warning = serializers.CharField(required=False, allow_blank=True)
|
||||
steps = ApplicationGuideStepSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
class AlternativeRecommendationSerializer(serializers.Serializer):
|
||||
fertilizer_code = serializers.CharField(required=False, allow_blank=True)
|
||||
fertilizer_name = serializers.CharField(required=False, allow_blank=True)
|
||||
fertilizer_type = serializers.CharField(required=False, allow_blank=True)
|
||||
usage_method = serializers.CharField(required=False, allow_blank=True)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FertilizationRecommendationListItemSerializer(serializers.Serializer):
|
||||
recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True)
|
||||
crop_id = serializers.CharField(read_only=True)
|
||||
plant_name = serializers.CharField(source="crop_id", read_only=True)
|
||||
growth_stage = serializers.CharField(read_only=True)
|
||||
fertilizer_type = serializers.CharField(read_only=True, allow_blank=True)
|
||||
status = serializers.CharField(read_only=True)
|
||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||
requested_at = serializers.DateTimeField(source="created_at", read_only=True)
|
||||
|
||||
|
||||
class FreeTextPlanParserRequestSerializer(serializers.Serializer):
|
||||
message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.")
|
||||
answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.")
|
||||
partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.")
|
||||
farm_uuid = serializers.UUIDField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
initial="11111111-1111-1111-1111-111111111111",
|
||||
help_text="UUID مزرعه برای context اختیاری.",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
has_message = bool((attrs.get("message") or "").strip())
|
||||
has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers"))
|
||||
has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan"))
|
||||
if not (has_message or has_answers or has_partial_plan):
|
||||
raise serializers.ValidationError(
|
||||
{"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class PlanParserQuestionSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(required=False, allow_blank=True)
|
||||
field = serializers.CharField(required=False, allow_blank=True)
|
||||
question = serializers.CharField(required=False, allow_blank=True)
|
||||
rationale = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FreeTextPlanParserResponseDataSerializer(serializers.Serializer):
|
||||
status = serializers.CharField(required=False, allow_blank=True)
|
||||
status_fa = serializers.CharField(required=False, allow_blank=True)
|
||||
summary = serializers.CharField(required=False, allow_blank=True)
|
||||
missing_fields = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
questions = PlanParserQuestionSerializer(many=True, required=False)
|
||||
collected_data = serializers.DictField(required=False)
|
||||
final_plan = serializers.DictField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class FertilizationRecommendResponseDataSerializer(serializers.Serializer):
|
||||
recommendation_uuid = serializers.UUIDField(read_only=True, required=False)
|
||||
crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True)
|
||||
plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True)
|
||||
growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True)
|
||||
status = serializers.CharField(read_only=True, required=False)
|
||||
status_label = serializers.CharField(read_only=True, required=False)
|
||||
primary_recommendation = PrimaryRecommendationSerializer(read_only=True)
|
||||
nutrient_analysis = NutrientAnalysisSerializer(read_only=True)
|
||||
application_guide = ApplicationGuideSerializer(read_only=True)
|
||||
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)
|
||||
@@ -0,0 +1,100 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from .defaults import FERTILIZATION_DASHBOARD_TEMPLATE
|
||||
from .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||
|
||||
|
||||
def _extract_result(response_payload):
|
||||
if not isinstance(response_payload, dict):
|
||||
return {}
|
||||
|
||||
data = response_payload.get("data")
|
||||
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
||||
return data["result"]
|
||||
|
||||
result = response_payload.get("result")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _get_latest_result(farm):
|
||||
if farm is None:
|
||||
return {}
|
||||
|
||||
for request in FertilizationRecommendationRequest.objects.filter(farm=farm):
|
||||
result = _extract_result(request.response_payload)
|
||||
if result:
|
||||
return result
|
||||
|
||||
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_TEMPLATE)
|
||||
result = _get_latest_result(farm)
|
||||
plan = result.get("plan") or {}
|
||||
if not isinstance(plan, dict) or not plan:
|
||||
return default_item
|
||||
|
||||
npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)"
|
||||
amount = plan.get("amountPerHectare")
|
||||
method = plan.get("applicationMethod")
|
||||
interval = plan.get("applicationInterval")
|
||||
|
||||
subtitle_parts = [part for part in [amount, method, interval] if part]
|
||||
|
||||
default_item["title"] = f"کود: {npk_ratio}"
|
||||
if subtitle_parts:
|
||||
default_item["subtitle"] = "، ".join(subtitle_parts)
|
||||
default_item["status"] = "success"
|
||||
default_item["source"] = "db"
|
||||
default_item["warnings"] = []
|
||||
|
||||
return default_item
|
||||
@@ -0,0 +1,553 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
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,
|
||||
FertilizationPlanListView,
|
||||
FertilizationPlanStatusView,
|
||||
PlanFromTextView,
|
||||
RecommendationDetailView,
|
||||
RecommendationListView,
|
||||
RecommendView,
|
||||
)
|
||||
|
||||
|
||||
class FertilizationRecommendViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="fert-user",
|
||||
password="secret123",
|
||||
email="fert@example.com",
|
||||
phone_number="09125556677",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="زراعی")
|
||||
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-farm")
|
||||
|
||||
@patch("fertilization.views.external_api_request")
|
||||
def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": {
|
||||
"status": "needs_clarification",
|
||||
"status_fa": "نیازمند پرسش تکمیلی",
|
||||
"summary": "need more",
|
||||
"missing_fields": ["growth_stage"],
|
||||
"questions": [{"id": "growth_stage", "field": "growth_stage", "question": "?", "rationale": "!"}],
|
||||
"collected_data": {"crop_name": "گندم"},
|
||||
"final_plan": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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(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/",
|
||||
method="POST",
|
||||
payload={"message": "متن کودهی", "farm_uuid": str(self.farm.farm_uuid)},
|
||||
)
|
||||
|
||||
def test_plan_from_text_requires_message_or_answers_or_partial_plan(self):
|
||||
request = self.factory.post("/api/fertilization/plan-from-text/", {}, format="json")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = PlanFromTextView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("non_field_errors", response.data)
|
||||
|
||||
@patch("fertilization.views.external_api_request")
|
||||
def test_recommend_returns_updated_response_shape(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": "npk-202020",
|
||||
"fertilizer_name": "NPK 20-20-20",
|
||||
"display_title": "کود کامل متعادل",
|
||||
"fertilizer_type": "NPK",
|
||||
"npk_ratio": {"n": 20, "p": 20, "k": 20, "label": "20-20-20"},
|
||||
"application_method": {"id": "fertigation", "label": "کودآبیاری"},
|
||||
"application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"},
|
||||
"dosage": {
|
||||
"base_amount_per_hectare": 65,
|
||||
"base_amount_per_square_meter": 0.0065,
|
||||
"unit": "kg",
|
||||
"label": "65 کیلوگرم در هکتار",
|
||||
"calculation_basis": "engine-v2",
|
||||
},
|
||||
"reasoning": "متعادل برای فاز رشد",
|
||||
"summary": "مصرف منظم در این مرحله توصیه می شود",
|
||||
},
|
||||
"nutrient_analysis": {
|
||||
"macro": [
|
||||
{"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent", "description": "تقویت رشد رویشی"}
|
||||
],
|
||||
"micro": [
|
||||
{"key": "zn", "name": "Zinc", "value": 2.5, "unit": "percent", "description": "بهبود رشد"}
|
||||
],
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": "در ساعات خنک مصرف شود",
|
||||
"steps": [
|
||||
{"step_number": 1, "title": "حل کردن", "description": "کود را در آب حل کنید"}
|
||||
],
|
||||
},
|
||||
"alternative_recommendations": [
|
||||
{
|
||||
"fertilizer_code": "npk-121236",
|
||||
"fertilizer_name": "NPK 12-12-36",
|
||||
"fertilizer_type": "NPK",
|
||||
"usage_method": "fertigation",
|
||||
"description": "برای نیاز پتاس بالا",
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{"type": "recommendation", "title": "پیشنهاد اصلی", "icon": "leaf", "content": "NPK 20-20-20"}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
self.assertEqual(response.data["code"], 200)
|
||||
self.assertIn("primary_recommendation", response.data["data"])
|
||||
self.assertIn("nutrient_analysis", response.data["data"])
|
||||
self.assertIn("application_guide", response.data["data"])
|
||||
self.assertIn("alternative_recommendations", response.data["data"])
|
||||
self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
||||
self.assertEqual(response.data["data"]["primary_recommendation"]["application_interval"]["value"], 14.0)
|
||||
self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation")
|
||||
self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation")
|
||||
self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1)
|
||||
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.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(
|
||||
saved_request.status,
|
||||
FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||
)
|
||||
self.assertEqual(
|
||||
saved_request.response_payload["data"]["primary_recommendation"]["fertilizer_code"],
|
||||
"npk-202020",
|
||||
)
|
||||
mock_external_api_request.assert_called_once_with(
|
||||
"ai",
|
||||
"/api/fertilization/recommend/",
|
||||
method="POST",
|
||||
payload={
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"crop_id": "گندم",
|
||||
"plant_name": "گندم",
|
||||
"growth_stage": "vegetative",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("fertilization.views.external_api_request")
|
||||
def test_recommend_accepts_plant_name_and_passes_it_directly_to_ai(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}})
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/fertilization/recommend/",
|
||||
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "جو", "growth_stage": "flowering"},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = RecommendView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
saved_request = FertilizationRecommendationRequest.objects.latest("created_at")
|
||||
self.assertEqual(saved_request.crop_id, "جو")
|
||||
mock_external_api_request.assert_called_once_with(
|
||||
"ai",
|
||||
"/api/fertilization/recommend/",
|
||||
method="POST",
|
||||
payload={
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"crop_id": "جو",
|
||||
"plant_name": "جو",
|
||||
"growth_stage": "flowering",
|
||||
},
|
||||
)
|
||||
|
||||
@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.assertFalse(plan.is_active)
|
||||
self.assertFalse(plan.is_deleted)
|
||||
|
||||
def test_recommendation_list_returns_paginated_summary_items(self):
|
||||
first = FertilizationRecommendationRequest.objects.create(
|
||||
farm=self.farm,
|
||||
crop_id="گندم",
|
||||
growth_stage="vegetative",
|
||||
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||
response_payload={
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_type": "NPK",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
second = FertilizationRecommendationRequest.objects.create(
|
||||
farm=self.farm,
|
||||
crop_id="ذرت",
|
||||
growth_stage="flowering",
|
||||
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||
response_payload={
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_type": "Micronutrient",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.get(
|
||||
f"/api/fertilization/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1"
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = RecommendationListView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["code"], 200)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(response.data["pagination"]["page"], 1)
|
||||
self.assertEqual(response.data["pagination"]["page_size"], 1)
|
||||
self.assertEqual(response.data["pagination"]["total_pages"], 2)
|
||||
self.assertEqual(response.data["pagination"]["total_items"], 2)
|
||||
self.assertTrue(response.data["pagination"]["has_next"])
|
||||
self.assertFalse(response.data["pagination"]["has_previous"])
|
||||
self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid))
|
||||
self.assertEqual(response.data["data"][0]["plant_name"], "ذرت")
|
||||
self.assertEqual(response.data["data"][0]["growth_stage"], "flowering")
|
||||
self.assertEqual(response.data["data"][0]["fertilizer_type"], "Micronutrient")
|
||||
self.assertEqual(response.data["data"][0]["status"], "pending_confirmation")
|
||||
self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید")
|
||||
self.assertIn("requested_at", response.data["data"][0])
|
||||
self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid))
|
||||
|
||||
def test_recommendation_detail_returns_same_shape_as_recommend_endpoint(self):
|
||||
recommendation = FertilizationRecommendationRequest.objects.create(
|
||||
farm=self.farm,
|
||||
crop_id="گندم",
|
||||
growth_stage="vegetative",
|
||||
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||
response_payload={
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": "npk-202020",
|
||||
"fertilizer_type": "NPK",
|
||||
"summary": "خلاصه توصیه",
|
||||
},
|
||||
"nutrient_analysis": {
|
||||
"macro": [{"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent"}],
|
||||
"micro": [],
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": "در هوای خنک استفاده شود",
|
||||
"steps": [{"step_number": 1, "title": "آماده سازی", "description": "در آب حل شود"}],
|
||||
},
|
||||
"alternative_recommendations": [
|
||||
{"fertilizer_code": "alt-1", "fertilizer_name": "Alt", "fertilizer_type": "NPK"}
|
||||
],
|
||||
"sections": [{"type": "warning", "title": "هشدار", "content": "اختلاط نشود"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["code"], 200)
|
||||
self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
||||
self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_type"], "NPK")
|
||||
self.assertEqual(response.data["data"]["nutrient_analysis"]["macro"][0]["value"], 20.0)
|
||||
self.assertEqual(response.data["data"]["application_guide"]["steps"][0]["step_number"], 1)
|
||||
self.assertEqual(response.data["data"]["sections"][0]["type"], "warning")
|
||||
self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid))
|
||||
self.assertEqual(response.data["data"]["crop_id"], "گندم")
|
||||
self.assertEqual(response.data["data"]["plant_name"], "گندم")
|
||||
self.assertEqual(response.data["data"]["status"], "pending_confirmation")
|
||||
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
|
||||
|
||||
def test_recommendation_detail_falls_back_to_top_level_fertilizer_code(self):
|
||||
recommendation = FertilizationRecommendationRequest.objects.create(
|
||||
farm=self.farm,
|
||||
crop_id="گندم",
|
||||
growth_stage="vegetative",
|
||||
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||
response_payload={
|
||||
"data": {
|
||||
"fertilizer_code": "legacy-code-101",
|
||||
"fertilizer_type": "NPK",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.data["data"]["primary_recommendation"]["fertilizer_code"],
|
||||
"legacy-code-101",
|
||||
)
|
||||
|
||||
|
||||
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": 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()
|
||||
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()
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.urls import path
|
||||
|
||||
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"),
|
||||
path("plan-from-text/", PlanFromTextView.as_view(), name="fertilization-plan-from-text"),
|
||||
]
|
||||
@@ -0,0 +1,708 @@
|
||||
"""
|
||||
Fertilization Recommendation API views.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
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 .defaults import CONFIG_RESPONSE_TEMPLATE
|
||||
from .serializers import (
|
||||
FreeTextPlanParserRequestSerializer,
|
||||
FreeTextPlanParserResponseDataSerializer,
|
||||
FertilizationPlanDetailSerializer,
|
||||
FertilizationPlanListItemSerializer,
|
||||
FertilizationPlanListQuerySerializer,
|
||||
FertilizationPlanStatusUpdateSerializer,
|
||||
FertilizationRecommendationListItemSerializer,
|
||||
FertilizationRecommendationListQuerySerializer,
|
||||
FertilizationRecommendRequestSerializer,
|
||||
FertilizationRecommendResponseDataSerializer,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FertilizationRecommendationPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
page_size = self.get_page_size(self.request) or self.page.paginator.per_page
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data,
|
||||
"pagination": {
|
||||
"page": self.page.number,
|
||||
"page_size": page_size,
|
||||
"total_pages": self.page.paginator.num_pages,
|
||||
"total_items": self.page.paginator.count,
|
||||
"has_next": self.page.has_next(),
|
||||
"has_previous": self.page.has_previous(),
|
||||
"next": self.get_next_link(),
|
||||
"previous": self.get_previous_link(),
|
||||
},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class FarmAccessMixin:
|
||||
@staticmethod
|
||||
def _get_farm(request, farm_uuid):
|
||||
if not farm_uuid:
|
||||
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||
try:
|
||||
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||
except FarmHub.DoesNotExist as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
||||
|
||||
|
||||
class ConfigView(FarmAccessMixin, APIView):
|
||||
@extend_schema(
|
||||
tags=["Fertilization Recommendation"],
|
||||
responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
|
||||
data = dict(CONFIG_RESPONSE_TEMPLATE)
|
||||
data["farm_uuid"] = str(farm.farm_uuid)
|
||||
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RecommendView(FarmAccessMixin, APIView):
|
||||
@staticmethod
|
||||
def _to_string(value):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sections(raw_sections):
|
||||
if not isinstance(raw_sections, list):
|
||||
return []
|
||||
|
||||
allowed_keys = {
|
||||
"type",
|
||||
"title",
|
||||
"icon",
|
||||
"content",
|
||||
"items",
|
||||
"fertilizerType",
|
||||
"amount",
|
||||
"applicationMethod",
|
||||
"timing",
|
||||
"validityPeriod",
|
||||
"expandableExplanation",
|
||||
}
|
||||
|
||||
normalized_sections = []
|
||||
for section in raw_sections:
|
||||
if not isinstance(section, dict) or not section.get("type"):
|
||||
continue
|
||||
|
||||
normalized_section = {}
|
||||
for key in allowed_keys:
|
||||
value = section.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if key == "items":
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
normalized_section[key] = [str(item) for item in value]
|
||||
continue
|
||||
normalized_section[key] = str(value) if key != "type" else value
|
||||
|
||||
normalized_sections.append(normalized_section)
|
||||
return normalized_sections
|
||||
|
||||
@staticmethod
|
||||
def _extract_public_payload(adapter_data):
|
||||
if not isinstance(adapter_data, dict):
|
||||
return {}
|
||||
|
||||
data = adapter_data.get("data")
|
||||
if isinstance(data, dict):
|
||||
result = data.get("result")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return data
|
||||
|
||||
result = adapter_data.get("result")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
|
||||
return adapter_data
|
||||
|
||||
def _normalize_npk_ratio(self, raw_ratio):
|
||||
if not isinstance(raw_ratio, dict):
|
||||
return {}
|
||||
|
||||
normalized = {}
|
||||
for key in ("n", "p", "k"):
|
||||
numeric_value = self._to_float(raw_ratio.get(key))
|
||||
if numeric_value is not None:
|
||||
normalized[key] = numeric_value
|
||||
|
||||
label = self._to_string(raw_ratio.get("label")).strip()
|
||||
if label:
|
||||
normalized["label"] = label
|
||||
|
||||
return normalized
|
||||
|
||||
def _normalize_named_object(self, raw_object):
|
||||
if not isinstance(raw_object, dict):
|
||||
return {}
|
||||
|
||||
normalized = {}
|
||||
for key in ("id", "label", "unit", "calculation_basis"):
|
||||
value = self._to_string(raw_object.get(key)).strip()
|
||||
if value:
|
||||
normalized[key] = value
|
||||
|
||||
for key in ("value", "base_amount_per_hectare", "base_amount_per_square_meter"):
|
||||
numeric_value = self._to_float(raw_object.get(key))
|
||||
if numeric_value is not None:
|
||||
normalized[key] = numeric_value
|
||||
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _first_non_empty(*values):
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _normalize_primary_recommendation(self, payload):
|
||||
raw_data = payload.get("primary_recommendation")
|
||||
if not isinstance(raw_data, dict):
|
||||
raw_data = {}
|
||||
|
||||
normalized = {}
|
||||
scalar_fields = {
|
||||
"fertilizer_code": (
|
||||
raw_data.get("fertilizer_code"),
|
||||
payload.get("fertilizer_code"),
|
||||
),
|
||||
"fertilizer_name": (
|
||||
raw_data.get("fertilizer_name"),
|
||||
payload.get("fertilizer_name"),
|
||||
),
|
||||
"display_title": (
|
||||
raw_data.get("display_title"),
|
||||
payload.get("display_title"),
|
||||
),
|
||||
"fertilizer_type": (
|
||||
raw_data.get("fertilizer_type"),
|
||||
payload.get("fertilizer_type"),
|
||||
),
|
||||
"reasoning": (
|
||||
raw_data.get("reasoning"),
|
||||
payload.get("reasoning"),
|
||||
),
|
||||
"summary": (
|
||||
raw_data.get("summary"),
|
||||
payload.get("summary"),
|
||||
),
|
||||
}
|
||||
for key, values in scalar_fields.items():
|
||||
value = self._first_non_empty(*values)
|
||||
if value:
|
||||
normalized[key] = value
|
||||
|
||||
npk_ratio = self._normalize_npk_ratio(raw_data.get("npk_ratio"))
|
||||
if npk_ratio:
|
||||
normalized["npk_ratio"] = npk_ratio
|
||||
|
||||
application_method = self._normalize_named_object(raw_data.get("application_method"))
|
||||
if application_method:
|
||||
normalized["application_method"] = {
|
||||
key: value for key, value in application_method.items() if key in {"id", "label"}
|
||||
}
|
||||
|
||||
application_interval = self._normalize_named_object(raw_data.get("application_interval"))
|
||||
if application_interval:
|
||||
normalized["application_interval"] = {
|
||||
key: value for key, value in application_interval.items() if key in {"value", "unit", "label"}
|
||||
}
|
||||
|
||||
dosage = self._normalize_named_object(raw_data.get("dosage"))
|
||||
if dosage:
|
||||
dosage_label = self._to_string(raw_data.get("dosage", {}).get("label")).strip()
|
||||
if dosage_label:
|
||||
dosage["label"] = dosage_label
|
||||
normalized["dosage"] = {
|
||||
key: value
|
||||
for key, value in dosage.items()
|
||||
if key in {"base_amount_per_hectare", "base_amount_per_square_meter", "unit", "label", "calculation_basis"}
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
def _normalize_nutrient_items(self, items):
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
normalized_items = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized_item = {}
|
||||
for key in ("key", "name", "unit", "description"):
|
||||
value = self._to_string(item.get(key)).strip()
|
||||
if value:
|
||||
normalized_item[key] = value
|
||||
numeric_value = self._to_float(item.get("value"))
|
||||
if numeric_value is not None:
|
||||
normalized_item["value"] = numeric_value
|
||||
if normalized_item:
|
||||
normalized_items.append(normalized_item)
|
||||
return normalized_items
|
||||
|
||||
def _normalize_application_guide(self, payload):
|
||||
raw_data = payload.get("application_guide")
|
||||
if not isinstance(raw_data, dict):
|
||||
return {}
|
||||
|
||||
normalized = {}
|
||||
safety_warning = self._to_string(raw_data.get("safety_warning")).strip()
|
||||
if safety_warning:
|
||||
normalized["safety_warning"] = safety_warning
|
||||
|
||||
raw_steps = raw_data.get("steps")
|
||||
if isinstance(raw_steps, list):
|
||||
steps = []
|
||||
for step in raw_steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
normalized_step = {}
|
||||
step_number = self._to_int(step.get("step_number"))
|
||||
if step_number is not None:
|
||||
normalized_step["step_number"] = step_number
|
||||
for key in ("title", "description"):
|
||||
value = self._to_string(step.get(key)).strip()
|
||||
if value:
|
||||
normalized_step[key] = value
|
||||
if normalized_step:
|
||||
steps.append(normalized_step)
|
||||
normalized["steps"] = steps
|
||||
|
||||
return normalized
|
||||
|
||||
def _normalize_alternatives(self, payload):
|
||||
raw_items = payload.get("alternative_recommendations")
|
||||
if not isinstance(raw_items, list):
|
||||
return []
|
||||
|
||||
alternatives = []
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized_item = {}
|
||||
for key in ("fertilizer_code", "fertilizer_name", "fertilizer_type", "usage_method", "description"):
|
||||
value = self._to_string(item.get(key)).strip()
|
||||
if value:
|
||||
normalized_item[key] = value
|
||||
if normalized_item:
|
||||
alternatives.append(normalized_item)
|
||||
return alternatives
|
||||
|
||||
def _normalize_response_payload(self, adapter_data):
|
||||
payload = self._extract_public_payload(adapter_data)
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
normalized_sections = self._normalize_sections(payload.get("sections"))
|
||||
nutrient_analysis = payload.get("nutrient_analysis") if isinstance(payload.get("nutrient_analysis"), dict) else {}
|
||||
|
||||
return {
|
||||
"primary_recommendation": self._normalize_primary_recommendation(payload),
|
||||
"nutrient_analysis": {
|
||||
"macro": self._normalize_nutrient_items(nutrient_analysis.get("macro")),
|
||||
"micro": self._normalize_nutrient_items(nutrient_analysis.get("micro")),
|
||||
},
|
||||
"application_guide": self._normalize_application_guide(payload),
|
||||
"alternative_recommendations": self._normalize_alternatives(payload),
|
||||
"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", {})
|
||||
plan = 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,
|
||||
)
|
||||
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
|
||||
|
||||
@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,
|
||||
responses={200: code_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = FertilizationRecommendRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data.copy()
|
||||
farm = self._get_farm(request, payload.get("farm_uuid"))
|
||||
crop_id = self._first_non_empty(payload.get("crop_id"), payload.get("plant_name"))
|
||||
plant_name = self._first_non_empty(payload.get("plant_name"), payload.get("crop_id"))
|
||||
payload["farm_uuid"] = str(farm.farm_uuid)
|
||||
payload["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=ai_payload,
|
||||
)
|
||||
|
||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||
public_data = self._normalize_response_payload(response_data)
|
||||
|
||||
logger.warning(
|
||||
"Fertilization recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
|
||||
str(farm.farm_uuid),
|
||||
adapter_response.status_code,
|
||||
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
|
||||
len(public_data.get("sections", [])),
|
||||
)
|
||||
|
||||
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=ai_payload,
|
||||
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
|
||||
)
|
||||
if adapter_response.status_code >= 400:
|
||||
return Response(
|
||||
{
|
||||
"code": adapter_response.status_code,
|
||||
"msg": "error",
|
||||
"data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)},
|
||||
},
|
||||
status=adapter_response.status_code,
|
||||
)
|
||||
|
||||
self._create_plan_from_recommendation(recommendation, public_data)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": public_data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class RecommendationListView(FarmAccessMixin, APIView):
|
||||
permission_classes = RecommendView.permission_classes
|
||||
pagination_class = FertilizationRecommendationPagination
|
||||
|
||||
@extend_schema(
|
||||
tags=["Fertilization Recommendation"],
|
||||
parameters=[FertilizationRecommendationListQuerySerializer],
|
||||
responses={200: code_response("FertilizationRecommendationListResponse")},
|
||||
)
|
||||
def get(self, request):
|
||||
serializer = FertilizationRecommendationListQuerySerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
|
||||
recommendations = farm.fertilizations.all().order_by("-created_at", "-id")
|
||||
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(recommendations, request, view=self)
|
||||
|
||||
items = []
|
||||
view_helper = RecommendView()
|
||||
for recommendation in page:
|
||||
normalized_payload = view_helper._normalize_response_payload(recommendation.response_payload)
|
||||
recommendation.fertilizer_type = (
|
||||
normalized_payload.get("primary_recommendation", {}).get("fertilizer_type", "")
|
||||
)
|
||||
items.append(recommendation)
|
||||
|
||||
data = FertilizationRecommendationListItemSerializer(items, many=True).data
|
||||
return paginator.get_paginated_response(data)
|
||||
|
||||
|
||||
class RecommendationDetailView(FarmAccessMixin, APIView):
|
||||
@extend_schema(
|
||||
tags=["Fertilization Recommendation"],
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="recommendation_uuid",
|
||||
type=OpenApiTypes.UUID,
|
||||
location=OpenApiParameter.PATH,
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
responses={
|
||||
200: code_response("FertilizationRecommendationDetailResponse", data=FertilizationRecommendResponseDataSerializer()),
|
||||
404: code_response("FertilizationRecommendationDetailNotFoundResponse"),
|
||||
},
|
||||
)
|
||||
def get(self, request, recommendation_uuid):
|
||||
recommendation = FertilizationRecommendationRequest.objects.filter(
|
||||
uuid=recommendation_uuid,
|
||||
farm__owner=request.user,
|
||||
).select_related("farm").first()
|
||||
if recommendation is None:
|
||||
return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
view_helper = RecommendView()
|
||||
data = view_helper._normalize_response_payload(recommendation.response_payload)
|
||||
data["recommendation_uuid"] = str(recommendation.uuid)
|
||||
data["crop_id"] = recommendation.crop_id
|
||||
data["plant_name"] = recommendation.crop_id
|
||||
data["growth_stage"] = recommendation.growth_stage
|
||||
data["status"] = recommendation.status
|
||||
data["status_label"] = recommendation.get_status_display()
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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,
|
||||
responses={200: code_response("FertilizationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = FreeTextPlanParserRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data.copy()
|
||||
|
||||
farm_uuid = payload.get("farm_uuid")
|
||||
if farm_uuid:
|
||||
farm = self._get_farm(request, farm_uuid)
|
||||
payload["farm_uuid"] = str(farm.farm_uuid)
|
||||
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/fertilization/plan-from-text/",
|
||||
method="POST",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}
|
||||
if adapter_response.status_code >= 400:
|
||||
return Response(
|
||||
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
|
||||
status=adapter_response.status_code,
|
||||
)
|
||||
|
||||
final_plan = self._extract_final_plan(response_data)
|
||||
if final_plan and farm_uuid:
|
||||
plan = 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,
|
||||
)
|
||||
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user