This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class SoilConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "soil"
verbose_name = "Soil"
+83
View File
@@ -0,0 +1,83 @@
AVG_SOIL_MOISTURE = {
"id": "avg_soil_moisture",
"title": "میانگین رطوبت خاک",
"subtitle": "کل مزرعه",
"stats": "65%",
"avatarColor": "primary",
"avatarIcon": "tabler-plant-2",
"chipText": "بهینه",
"chipColor": "success",
}
SENSOR_RADAR_CHART = {
"labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"],
"series": [
{"name": "امروز", "data": [75, 65, 80, 70, 85, 60]},
{"name": "ایده آل", "data": [80, 70, 75, 75, 90, 50]},
],
}
SENSOR_COMPARISON_CHART = {
"currentValue": 48,
"vsLastWeek": "+5%",
"vsLastWeekValue": 5,
"categories": ["دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه", "شنبه", "یکشنبه"],
"series": [
{"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]},
{"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]},
],
}
ANOMALY_DETECTION_CARD = {
"anomalies": [
{
"sensor": "رطوبت خاک زون 3",
"value": "38%",
"expected": "45-65%",
"deviation": "-12%",
"severity": "warning",
},
{
"sensor": "pH بخش 2",
"value": "5.2",
"expected": "6.0-7.0",
"deviation": "-0.8",
"severity": "error",
},
]
}
SOIL_MOISTURE_HEATMAP = {
"zones": ["زون 1", "زون 2", "زون 3", "زون 4", "زون 5", "زون 6", "زون 7"],
"hours": ["6 ص", "8 ص", "10 ص", "12 ظ", "14 ع", "16 ع", "18 ع"],
"series": [
{
"name": "زون 1",
"data": [
{"x": "6 ص", "y": 52},
{"x": "8 ص", "y": 48},
{"x": "10 ص", "y": 55},
{"x": "12 ظ", "y": 60},
{"x": "14 ع", "y": 58},
{"x": "16 ع", "y": 54},
{"x": "18 ع", "y": 50},
],
},
{
"name": "زون 2",
"data": [
{"x": "6 ص", "y": 45},
{"x": "8 ص", "y": 42},
{"x": "10 ص", "y": 48},
{"x": "12 ظ", "y": 52},
{"x": "14 ع", "y": 50},
{"x": "16 ع", "y": 47},
{"x": "18 ع", "y": 44},
],
},
],
}
+1
View File
@@ -0,0 +1 @@
from django.db import models
+94
View File
@@ -0,0 +1,94 @@
from rest_framework import serializers
class SoilKpiSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه کارت KPI.")
title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان کارت KPI.")
subtitle = serializers.CharField(required=False, allow_blank=True, help_text="زیرعنوان کارت KPI.")
stats = serializers.CharField(required=False, allow_blank=True, help_text="مقدار اصلی KPI.")
avatarColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ آواتار کارت.")
avatarIcon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون کارت.")
chipText = serializers.CharField(required=False, allow_blank=True, help_text="متن وضعیت KPI.")
chipColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ وضعیت KPI.")
class SoilRadarSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = serializers.ListField(child=serializers.FloatField(), required=False)
class SoilRadarChartSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField(), required=False)
series = SoilRadarSeriesSerializer(many=True, required=False)
class SoilComparisonChartSerializer(serializers.Serializer):
currentValue = serializers.FloatField(required=False)
vsLastWeek = serializers.CharField(required=False, allow_blank=True)
vsLastWeekValue = serializers.FloatField(required=False)
categories = serializers.ListField(child=serializers.CharField(), required=False)
series = SoilRadarSeriesSerializer(many=True, required=False)
class SoilAnomalyItemSerializer(serializers.Serializer):
sensor = serializers.CharField(required=False, allow_blank=True)
value = serializers.CharField(required=False, allow_blank=True)
expected = serializers.CharField(required=False, allow_blank=True)
deviation = serializers.CharField(required=False, allow_blank=True)
severity = serializers.CharField(required=False, allow_blank=True)
class SoilAnomalyDetectionSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
summary = serializers.CharField(required=False, allow_blank=True, help_text="خلاصه کوتاه ناهنجاری خاک.")
explanation = serializers.CharField(required=False, allow_blank=True, help_text="توضیح کوتاه درباره ناهنجاری.")
likely_cause = serializers.CharField(required=False, allow_blank=True, help_text="علت محتمل ناهنجاری.")
recommended_action = serializers.CharField(required=False, allow_blank=True, help_text="اقدام پیشنهادی برای رفع مشکل.")
monitoring_priority = serializers.CharField(required=False, allow_blank=True, help_text="اولویت پایش؛ low/medium/high/urgent.")
confidence = serializers.FloatField(required=False, help_text="میزان اطمینان مدل به تحلیل.")
generated_at = serializers.CharField(required=False, allow_blank=True, help_text="زمان تولید تحلیل.")
anomalies = SoilAnomalyItemSerializer(many=True, required=False)
interpretation = serializers.DictField(required=False, help_text="تفسیر ساختاریافته ناهنجاری‌ها.")
knowledge_base = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="مرجع دانشی استفاده‌شده.")
raw_response = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="پاسخ خام upstream در صورت وجود.")
class SoilHeatmapPointSerializer(serializers.Serializer):
x = serializers.CharField(required=False, allow_blank=True)
y = serializers.FloatField(required=False)
class SoilHeatmapSeriesSerializer(serializers.Serializer):
name = serializers.CharField(required=False, allow_blank=True)
data = SoilHeatmapPointSerializer(many=True, required=False)
class SoilGenericDictSerializer(serializers.Serializer):
class Meta:
ref_name = "SoilGenericDict"
class SoilMoistureHeatmapSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
location = serializers.DictField(required=False, help_text="اطلاعات مکانی مزرعه یا ناحیه تحلیل.")
current_sensor = serializers.DictField(required=False, help_text="مشخصات سنسور فعال فعلی.")
soil_profile = serializers.ListField(child=serializers.DictField(), required=False, help_text="پروفایل خاک در لایه‌های مختلف.")
timestamp = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="زمان تولید heatmap.")
grid_resolution = serializers.DictField(required=False, help_text="رزولوشن شبکه heatmap.")
grid_cells = serializers.ListField(child=serializers.DictField(), required=False, help_text="سلول‌های شبکه heatmap.")
sensor_points = serializers.ListField(child=serializers.DictField(), required=False, help_text="نقاط سنسور مؤثر در heatmap.")
quality_legend = serializers.DictField(required=False, help_text="legend یا بازه‌بندی کیفیت رطوبت.")
depth_layers = serializers.ListField(child=serializers.DictField(), required=False, help_text="لایه‌های عمقی خاک.")
model_metadata = serializers.DictField(required=False, help_text="متادیتای مدل تولیدکننده heatmap.")
summary = serializers.DictField(required=False, help_text="خلاصه تفسیری heatmap.")
class SoilSummarySerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.")
healthScore = serializers.IntegerField(required=False, help_text="امتیاز سلامت کلی خاک.")
profileSource = serializers.CharField(required=False, allow_blank=True, help_text="منبع پروفایل مرجع یا محصول هدف.")
healthScoreDetails = serializers.DictField(required=False, help_text="جزئیات تشکیل‌دهنده health score.")
healthLanguage = serializers.DictField(required=False, help_text="توضیحات متنی قابل نمایش برای سلامت خاک.")
avgSoilMoisture = serializers.IntegerField(required=False, help_text="میانگین رطوبت خاک به‌صورت عدد گرد شده.")
avgSoilMoistureRaw = serializers.FloatField(required=False, help_text="میانگین خام رطوبت خاک.")
avgSoilMoistureStatus = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت متنی رطوبت خاک.")
+84
View File
@@ -0,0 +1,84 @@
from copy import deepcopy
from farm_alerts.models import AnomalyDetection
from .mock_data import (
ANOMALY_DETECTION_CARD,
AVG_SOIL_MOISTURE,
SENSOR_COMPARISON_CHART,
SENSOR_RADAR_CHART,
SOIL_MOISTURE_HEATMAP,
)
def get_avg_soil_moisture_data(farm=None):
data = deepcopy(AVG_SOIL_MOISTURE)
heatmap = get_soil_moisture_heatmap_data(farm)
values = [
point.get("y")
for series in heatmap.get("series", [])
for point in series.get("data", [])
if point.get("y") is not None
]
if not values:
return data
average = round(sum(values) / len(values))
data["stats"] = f"{average}%"
if average >= 60:
data["chipText"] = "بهینه"
data["chipColor"] = "success"
elif average >= 45:
data["chipText"] = "متوسط"
data["chipColor"] = "warning"
else:
data["chipText"] = "کم"
data["chipColor"] = "error"
data["avatarColor"] = "warning"
return data
def get_sensor_radar_chart_data(farm=None):
return deepcopy(SENSOR_RADAR_CHART)
def get_sensor_comparison_chart_data(farm=None):
return deepcopy(SENSOR_COMPARISON_CHART)
def get_anomaly_detection_card_data(farm=None):
if farm is None:
return deepcopy(ANOMALY_DETECTION_CARD)
anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10])
if not anomalies:
return deepcopy(ANOMALY_DETECTION_CARD)
return {
"anomalies": [
{
"sensor": anomaly.sensor,
"value": anomaly.value,
"expected": anomaly.expected,
"deviation": anomaly.deviation,
"severity": anomaly.severity,
}
for anomaly in anomalies
]
}
def get_soil_moisture_heatmap_data(farm=None):
return deepcopy(SOIL_MOISTURE_HEATMAP)
def get_soil_summary_data(farm=None):
return {
"avgSoilMoisture": get_avg_soil_moisture_data(farm),
"sensorRadarChart": get_sensor_radar_chart_data(farm),
"sensorComparisonChart": get_sensor_comparison_chart_data(farm),
"anomalyDetectionCard": get_anomaly_detection_card_data(farm),
"soilMoistureHeatmap": get_soil_moisture_heatmap_data(farm),
}
+367
View File
@@ -0,0 +1,367 @@
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from account.models import User
from .views import SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView
TEST_CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "soil-tests",
}
}
TEST_SOIL_SUMMARY_CACHE_TTL = 14400
TEST_SOIL_ANOMALIES_CACHE_TTL = 14400
@override_settings(
CACHES=TEST_CACHES,
SOIL_ANOMALIES_CACHE_TTL=TEST_SOIL_ANOMALIES_CACHE_TTL,
)
class SoilAnomalyDetectionViewTests(TestCase):
def setUp(self):
cache.clear()
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
username="soil-user",
password="secret123",
email="soil@example.com",
phone_number="09120000100",
)
self.farm_type = FarmType.objects.create(name="Soil Farm Type")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Soil Farm",
)
@patch("soil.views.external_api_request")
def test_anomalies_proxy_to_soile_anomaly_detection(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"summary": "summary",
"explanation": "explanation",
"likely_cause": "cause",
"recommended_action": "action",
"monitoring_priority": "high",
"confidence": 0.91,
"generated_at": "2026-04-26T10:00:00Z",
"anomalies": [],
"interpretation": {},
"knowledge_base": None,
"raw_response": None,
}
},
)
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["monitoring_priority"], "high")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/soile/anomaly-detection/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
)
@patch("soil.views.external_api_request")
def test_anomalies_cache_last_four_responses(self, mock_external_api_request):
for index in range(5):
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Farm Cache {index}")
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(farm.farm_uuid),
"summary": f"summary {index}",
"anomalies": [],
}
},
)
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={farm.farm_uuid}")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 200)
cached_items = cache.get("soil:anomalies:recent")
self.assertEqual(len(cached_items), 4)
self.assertEqual(cached_items[0]["summary"], "summary 4")
self.assertEqual(cached_items[-1]["summary"], "summary 1")
@patch("soil.views.external_api_request")
def test_anomalies_return_cached_response_for_same_farm(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"summary": "cached summary",
"anomalies": [],
}
},
)
for _ in range(2):
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["summary"], "cached summary")
self.assertEqual(cache.get(f"soil:anomalies:{self.farm.farm_uuid}")["summary"], "cached summary")
mock_external_api_request.assert_called_once()
@patch("soil.views.cache.set")
@patch("soil.views.external_api_request")
def test_anomalies_use_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"summary": "summary",
"anomalies": [],
}
},
)
request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_ANOMALIES_CACHE_TTL for call in mock_cache_set.call_args_list))
def test_anomalies_require_farm_uuid(self):
request = self.factory.get("/api/soil/anomalies/")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], 400)
self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.")
def test_anomalies_return_404_for_missing_farm(self):
request = self.factory.get("/api/soil/anomalies/?farm_uuid=11111111-1111-1111-1111-111111111111")
response = SoilAnomalyDetectionView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
class SoilMoistureHeatmapViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
username="soil-heatmap-user",
password="secret123",
email="soil-heatmap@example.com",
phone_number="09120000101",
)
self.farm_type = FarmType.objects.create(name="Soil Heatmap Farm Type")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Heatmap Farm",
)
@patch("soil.views.external_api_request")
def test_heatmap_proxies_to_soile_moisture_heatmap(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"location": {},
"current_sensor": {},
"soil_profile": [],
"timestamp": "2026-04-26T10:00:00Z",
"grid_resolution": {},
"grid_cells": [],
"sensor_points": [],
"quality_legend": {},
"depth_layers": [],
"model_metadata": {},
"summary": {},
}
},
)
request = self.factory.get(f"/api/soil/moisture-heatmap/?farm_uuid={self.farm.farm_uuid}")
response = SoilMoistureHeatmapView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid))
mock_external_api_request.assert_called_once_with(
"ai",
"/api/soile/moisture-heatmap/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
)
def test_heatmap_requires_farm_uuid(self):
request = self.factory.get("/api/soil/moisture-heatmap/")
response = SoilMoistureHeatmapView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], 400)
self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.")
def test_heatmap_returns_404_for_missing_farm(self):
request = self.factory.get("/api/soil/moisture-heatmap/?farm_uuid=11111111-1111-1111-1111-111111111111")
response = SoilMoistureHeatmapView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
@override_settings(
CACHES=TEST_CACHES,
SOIL_SUMMARY_CACHE_TTL=TEST_SOIL_SUMMARY_CACHE_TTL,
)
class SoilSummaryViewTests(TestCase):
def setUp(self):
cache.clear()
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
username="soil-summary-user",
password="secret123",
email="soil-summary@example.com",
phone_number="09120000102",
)
self.farm_type = FarmType.objects.create(name="Soil Summary Farm Type")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Summary Farm",
)
@patch("soil.views.external_api_request")
def test_summary_proxies_to_soile_health_summary(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"healthScore": 82,
"profileSource": "Tomato",
"healthScoreDetails": {},
"healthLanguage": {},
"avgSoilMoisture": 46,
"avgSoilMoistureRaw": 46.0,
"avgSoilMoistureStatus": "بهینه",
}
},
)
request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["healthScore"], 82)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/soile/health-summary/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
)
@patch("soil.views.external_api_request")
def test_summary_returns_cached_response_for_same_farm(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"healthScore": 82,
"profileSource": "Tomato",
}
},
)
for _ in range(2):
request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["healthScore"], 82)
self.assertEqual(cache.get(f"soil:summary:{self.farm.farm_uuid}")["healthScore"], 82)
mock_external_api_request.assert_called_once()
@patch("soil.views.cache.set")
@patch("soil.views.external_api_request")
def test_summary_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(self.farm.farm_uuid),
"healthScore": 82,
}
},
)
request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_SUMMARY_CACHE_TTL for call in mock_cache_set.call_args_list))
@patch("soil.views.external_api_request")
def test_summary_caches_last_four_responses(self, mock_external_api_request):
for index in range(5):
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Summary Cache {index}")
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"farm_uuid": str(farm.farm_uuid),
"healthScore": 80 + index,
}
},
)
request = self.factory.get(f"/api/soil/summary/?farm_uuid={farm.farm_uuid}")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
cached_items = cache.get("soil:summary:recent")
self.assertEqual(len(cached_items), 4)
self.assertEqual(cached_items[0]["healthScore"], 84)
self.assertEqual(cached_items[-1]["healthScore"], 81)
def test_summary_requires_farm_uuid(self):
request = self.factory.get("/api/soil/summary/")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], 400)
self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.")
def test_summary_returns_404_for_missing_farm(self):
request = self.factory.get("/api/soil/summary/?farm_uuid=11111111-1111-1111-1111-111111111111")
response = SoilSummaryView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from .views import (
AvgSoilMoistureView,
SoilAnomalyDetectionView,
SoilMoistureHeatmapView,
SoilSummaryView,
)
urlpatterns = [
path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"),
path("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"),
path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"),
path("summary/", SoilSummaryView.as_view(), name="soil-summary"),
]
+257
View File
@@ -0,0 +1,257 @@
from django.conf import settings
from django.core.cache import cache
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import farm_uuid_query_param, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .serializers import (
SoilAnomalyDetectionSerializer,
SoilKpiSerializer,
SoilMoistureHeatmapSerializer,
SoilSummarySerializer,
)
from .services import (
get_anomaly_detection_card_data,
get_avg_soil_moisture_data,
get_soil_moisture_heatmap_data,
)
SOIL_ANOMALIES_CACHE_KEY = "soil:anomalies:recent"
SOIL_ANOMALIES_CACHE_LIMIT = 4
SOIL_SUMMARY_CACHE_KEY = "soil:summary:recent"
SOIL_SUMMARY_CACHE_LIMIT = 4
def _store_recent_soil_anomalies(payload):
cached_items = cache.get(SOIL_ANOMALIES_CACHE_KEY, [])
if not isinstance(cached_items, list):
cached_items = []
cached_items.insert(0, payload)
cache.set(SOIL_ANOMALIES_CACHE_KEY, cached_items[:SOIL_ANOMALIES_CACHE_LIMIT], timeout=None)
def _store_recent_soil_summary(payload):
cached_items = cache.get(SOIL_SUMMARY_CACHE_KEY, [])
if not isinstance(cached_items, list):
cached_items = []
cached_items.insert(0, payload)
cache.set(SOIL_SUMMARY_CACHE_KEY, cached_items[:SOIL_SUMMARY_CACHE_LIMIT], timeout=None)
def _build_soil_summary_cache_key(farm_uuid):
return f"soil:summary:{farm_uuid}"
def _build_soil_anomalies_cache_key(farm_uuid):
return f"soil:anomalies:{farm_uuid}"
def _get_farm_from_request(request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return None
try:
return FarmHub.objects.get(farm_uuid=farm_uuid)
except (FarmHub.DoesNotExist, Exception):
return None
def _extract_adapter_result(adapter_data):
if not isinstance(adapter_data, dict):
return {}
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
if isinstance(data, dict):
return data
result = adapter_data.get("result")
if isinstance(result, dict):
return result
return adapter_data
class AvgSoilMoistureView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
farm_uuid_query_param(required=False, description="UUID of the farm for average soil moisture."),
],
responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_avg_soil_moisture_data(_get_farm_from_request(request))},
status=status.HTTP_200_OK,
)
class SoilAnomalyDetectionView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm for soil anomaly detection."),
],
responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}},
status=status.HTTP_400_BAD_REQUEST,
)
farm = _get_farm_from_request(request)
if farm is None:
return Response(
{"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
cache_key = _build_soil_anomalies_cache_key(farm.farm_uuid)
cached_anomalies = cache.get(cache_key)
if isinstance(cached_anomalies, dict):
return Response(
{"code": 200, "msg": "success", "data": cached_anomalies},
status=status.HTTP_200_OK,
)
adapter_response = external_api_request(
"ai",
"/api/soile/anomaly-detection/",
method="POST",
payload={"farm_uuid": str(farm.farm_uuid)},
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
response_payload = _extract_adapter_result(adapter_response.data)
cache.set(cache_key, response_payload, timeout=settings.SOIL_ANOMALIES_CACHE_TTL)
_store_recent_soil_anomalies(response_payload)
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)
class SoilMoistureHeatmapView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm for soil moisture heatmap."),
],
responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}},
status=status.HTTP_400_BAD_REQUEST,
)
farm = _get_farm_from_request(request)
if farm is None:
return Response(
{"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
adapter_response = external_api_request(
"ai",
"/api/soile/moisture-heatmap/",
method="POST",
payload={"farm_uuid": str(farm.farm_uuid)},
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
return Response(
{"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)},
status=status.HTTP_200_OK,
)
class SoilSummaryView(APIView):
@extend_schema(
tags=["Soil"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm for soil health summary."),
],
responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())},
)
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}},
status=status.HTTP_400_BAD_REQUEST,
)
farm = _get_farm_from_request(request)
if farm is None:
return Response(
{"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
cache_key = _build_soil_summary_cache_key(farm.farm_uuid)
cached_summary = cache.get(cache_key)
if isinstance(cached_summary, dict):
return Response(
{"code": 200, "msg": "success", "data": cached_summary},
status=status.HTTP_200_OK,
)
adapter_response = external_api_request(
"ai",
"/api/soile/health-summary/",
method="POST",
payload={"farm_uuid": str(farm.farm_uuid)},
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
response_payload = _extract_adapter_result(adapter_response.data)
cache.set(cache_key, response_payload, timeout=settings.SOIL_SUMMARY_CACHE_TTL)
_store_recent_soil_summary(response_payload)
return Response(
{"code": 200, "msg": "success", "data": response_payload},
status=status.HTTP_200_OK,
)