UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SoilConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "soil"
|
||||
verbose_name = "Soil"
|
||||
@@ -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},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from django.db import models
|
||||
@@ -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="وضعیت متنی رطوبت خاک.")
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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.")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user