This commit is contained in:
2026-04-29 01:27:16 +03:30
parent a75c4ca9c8
commit f0f2ac34b7
20 changed files with 2840 additions and 65 deletions
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
urlpatterns = [
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
]
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand, CommandError
from sensor_7_in_1.seeds import seed_sensor_7_in_1_demo_data
class Command(BaseCommand):
help = "Create or refresh demo sensor 7 in 1 data for summary and chart endpoints."
def handle(self, *args, **options):
try:
result = seed_sensor_7_in_1_demo_data()
except ValueError as exc:
raise CommandError(str(exc)) from exc
self.stdout.write(
self.style.SUCCESS(
"Sensor 7 in 1 demo data seeded: "
f"farm_uuid={result['farm'].farm_uuid}, "
f"sensor_catalog={result['sensor_catalog'].code}, "
f"physical_device_uuid={result['sensor'].physical_device_uuid}, "
f"logs={result['log_count']}"
)
)
+174
View File
@@ -0,0 +1,174 @@
from datetime import timedelta
import uuid
from django.db import transaction
from django.utils import timezone
from farm_hub.models import FarmSensor
from farm_hub.seeds import seed_admin_farm
from sensor_catalog.models import SensorCatalog
from sensor_external_api.models import SensorExternalRequestLog
SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1"
SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777")
SENSOR_7_IN_1_LOG_SERIES = [
{
"days_ago": 6,
"payload": {
"soil_moisture": 44.0,
"soil_temperature": 20.6,
"soil_ph": 6.3,
"electrical_conductivity": 1.0,
"nitrogen": 25.0,
"phosphorus": 13.0,
"potassium": 21.0,
},
},
{
"days_ago": 5,
"payload": {
"soil_moisture": 45.5,
"soil_temperature": 21.1,
"soil_ph": 6.4,
"electrical_conductivity": 1.1,
"nitrogen": 26.0,
"phosphorus": 13.8,
"potassium": 21.8,
},
},
{
"days_ago": 4,
"payload": {
"soil_moisture": 46.8,
"soil_temperature": 21.7,
"soil_ph": 6.5,
"electrical_conductivity": 1.1,
"nitrogen": 27.4,
"phosphorus": 14.2,
"potassium": 22.5,
},
},
{
"days_ago": 3,
"payload": {
"soil_moisture": 48.2,
"soil_temperature": 22.0,
"soil_ph": 6.6,
"electrical_conductivity": 1.2,
"nitrogen": 28.9,
"phosphorus": 15.1,
"potassium": 23.3,
},
},
{
"days_ago": 2,
"payload": {
"soil_moisture": 49.6,
"soil_temperature": 22.4,
"soil_ph": 6.6,
"electrical_conductivity": 1.2,
"nitrogen": 29.7,
"phosphorus": 15.7,
"potassium": 24.1,
},
},
{
"days_ago": 1,
"payload": {
"soil_moisture": 50.9,
"soil_temperature": 22.8,
"soil_ph": 6.7,
"electrical_conductivity": 1.3,
"nitrogen": 30.8,
"phosphorus": 16.2,
"potassium": 24.8,
},
},
{
"days_ago": 0,
"payload": {
"soil_moisture": 52.4,
"soil_temperature": 23.1,
"soil_ph": 6.8,
"electrical_conductivity": 1.3,
"nitrogen": 32.0,
"phosphorus": 16.8,
"potassium": 25.6,
},
},
]
def seed_sensor_7_in_1_catalog():
sensor_catalog, created = SensorCatalog.objects.update_or_create(
code=SENSOR_7_IN_1_CATALOG_CODE,
defaults={
"name": "Sensor 7 in 1 Soil Sensor",
"description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.",
"customizable_fields": [],
"supported_power_sources": ["solar", "battery", "direct_power"],
"returned_data_fields": [
"soil_moisture",
"soil_temperature",
"soil_ph",
"electrical_conductivity",
"nitrogen",
"phosphorus",
"potassium",
],
"sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"],
"is_active": True,
},
)
return sensor_catalog, created
@transaction.atomic
def seed_sensor_7_in_1_demo_data():
farm, _ = seed_admin_farm()
sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog()
sensor, sensor_created = FarmSensor.objects.update_or_create(
farm=farm,
physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID,
defaults={
"sensor_catalog": sensor_catalog,
"name": "Sensor 7 in 1 Demo",
"sensor_type": "soil_7_in_1",
"is_active": True,
"specifications": {
"capabilities": sensor_catalog.returned_data_fields,
"demo_seed": True,
},
"power_source": {"type": "solar"},
},
)
SensorExternalRequestLog.objects.filter(
farm_uuid=farm.farm_uuid,
physical_device_uuid=sensor.physical_device_uuid,
).delete()
base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0)
created_logs = []
for item in SENSOR_7_IN_1_LOG_SERIES:
log = SensorExternalRequestLog.objects.create(
farm_uuid=farm.farm_uuid,
sensor_catalog_uuid=sensor_catalog.uuid,
physical_device_uuid=sensor.physical_device_uuid,
payload=item["payload"],
)
created_at = base_time - timedelta(days=item["days_ago"])
SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at)
log.created_at = created_at
created_logs.append(log)
return {
"farm": farm,
"sensor_catalog": sensor_catalog,
"sensor": sensor,
"catalog_created": catalog_created,
"sensor_created": sensor_created,
"log_count": len(created_logs),
}
+44 -1
View File
@@ -30,6 +30,50 @@ class Sensor7In1ValuesListSerializer(serializers.Serializer):
sensors = Sensor7In1ValueSerializer(many=True, required=False)
class SensorComparisonChartQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["7d", "30d"], required=False, default="7d")
class SensorValuesListQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["1h", "24h", "7d"], required=False, default="7d")
class SensorRadarChartQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
range = serializers.ChoiceField(choices=["today", "7d", "30d"], required=False, default="7d")
class SensorComparisonChartSeriesSerializer(serializers.Serializer):
name = serializers.CharField()
data = serializers.ListField(child=serializers.FloatField())
class SensorComparisonChartResponseSerializer(serializers.Serializer):
series = SensorComparisonChartSeriesSerializer(many=True)
categories = serializers.ListField(child=serializers.CharField())
currentValue = serializers.FloatField()
vsLastWeek = serializers.CharField()
class SensorValuesListItemSerializer(serializers.Serializer):
title = serializers.CharField()
subtitle = serializers.CharField()
trendNumber = serializers.FloatField()
trend = serializers.ChoiceField(choices=["positive", "negative"])
unit = serializers.CharField()
class SensorValuesListResponseSerializer(serializers.Serializer):
sensors = SensorValuesListItemSerializer(many=True)
class SensorRadarChartResponseSerializer(serializers.Serializer):
labels = serializers.ListField(child=serializers.CharField())
series = SensorComparisonChartSeriesSerializer(many=True)
class Sensor7In1SummarySerializer(serializers.Serializer):
sensor = Sensor7In1MetaSerializer(required=False)
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
@@ -38,4 +82,3 @@ class Sensor7In1SummarySerializer(serializers.Serializer):
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
+288 -20
View File
@@ -1,6 +1,8 @@
from copy import deepcopy
from datetime import timedelta
from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm
from django.utils import timezone
from .mock_data import (
ANOMALY_DETECTION_CARD,
@@ -81,6 +83,59 @@ SENSOR_FIELDS = [
MIN_REQUIRED_SENSOR_FIELDS = 4
MAX_HISTORY_ITEMS = 20
MAX_CHART_POINTS = 7
COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30}
VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)}
RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)}
PERSIAN_WEEKDAYS = {
0: "دوشنبه",
1: "سه شنبه",
2: "چهارشنبه",
3: "پنج شنبه",
4: "جمعه",
5: "شنبه",
6: "یکشنبه",
}
COMPARISON_CHART_FIELD_ALIASES = {
"soil_moisture": "moisture",
"soilMoisture": "moisture",
"moisture": "moisture",
"soil_temperature": "temperature",
"soilTemperature": "temperature",
"temperature": "temperature",
"humidity": "humidity",
"soil_ph": "ph",
"soilPh": "ph",
"ph": "ph",
"electrical_conductivity": "ec",
"electricalConductivity": "ec",
"ec": "ec",
"nitrogen": "nitrogen",
"n": "nitrogen",
"phosphorus": "phosphorus",
"p": "phosphorus",
"potassium": "potassium",
"k": "potassium",
}
COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium")
VALUES_LIST_FIELDS = [
("moisture", "Moisture", "%"),
("temperature", "Temperature", "°C"),
("humidity", "Humidity", "%"),
("ph", "pH", "pH"),
("ec", "EC", "dS/m"),
("nitrogen", "Nitrogen", "mg/kg"),
("phosphorus", "Phosphorus", "mg/kg"),
("potassium", "Potassium", "mg/kg"),
]
RADAR_CHART_FIELDS = [
("moisture", "Moisture", 60.0),
("temperature", "Temperature", 26.0),
("humidity", "Humidity", 55.0),
("ph", "PH", 6.5),
("ec", "EC", 1.3),
("nitrogen", "Nitrogen", 42.0),
("potassium", "Potassium", 38.0),
]
def _to_float(value):
@@ -107,6 +162,16 @@ def _extract_payload(payload):
return payload
def _extract_numeric_payload(payload):
payload = _extract_payload(payload)
numeric_payload = {}
for key, value in payload.items():
numeric_value = _to_float(value)
if numeric_value is not None:
numeric_payload[key] = numeric_value
return numeric_payload
def _extract_readings(payload):
payload = _extract_payload(payload)
readings = {}
@@ -151,46 +216,69 @@ def _get_sensor_context(farm=None):
if farm is None:
return None
primary_sensor = get_primary_soil_sensor(farm=farm)
if primary_sensor is None:
return None
try:
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid)
logs_queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=farm.farm_uuid,
physical_device_uuid=primary_sensor.physical_device_uuid,
)
except ValueError:
return None
candidate_log = None
candidate_readings = {}
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
readings = _extract_readings(log.payload)
if len(readings) >= MIN_REQUIRED_SENSOR_FIELDS:
candidate_log = log
candidate_readings = readings
break
if candidate_log is None:
return None
history = []
for log in logs_queryset.filter(physical_device_uuid=candidate_log.physical_device_uuid)[:MAX_HISTORY_ITEMS]:
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
readings = _extract_readings(log.payload)
if readings:
history.append((log, readings))
if not history:
history = [(candidate_log, candidate_readings)]
return None
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[candidate_log])
latest_log, latest_readings = history[0]
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log])
farm_sensor = farm_sensor_map.get(
(candidate_log.farm_uuid, candidate_log.sensor_catalog_uuid, candidate_log.physical_device_uuid)
)
(latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)
) or primary_sensor
return {
"farm_sensor": farm_sensor,
"latest_log": history[0][0],
"latest_readings": history[0][1],
"latest_log": latest_log,
"latest_readings": latest_readings,
"previous_readings": history[1][1] if len(history) > 1 else {},
"history": history,
}
def get_primary_soil_sensor(*, farm):
soil_sensors = list(
farm.sensors.select_related("sensor_catalog")
.order_by("created_at", "id")
)
def _sensor_priority(sensor):
sensor_type = (sensor.sensor_type or "").lower()
catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower()
catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower()
sensor_name = (sensor.name or "").lower()
haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name])
if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type:
return 0
if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack:
return 1
if "soil" in haystack:
return 2
return 3
prioritized_sensors = sorted(soil_sensors, key=_sensor_priority)
if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3:
return prioritized_sensors[0]
return soil_sensors[0] if soil_sensors else None
def _build_sensor_meta(context, fallback_sensor):
sensor = deepcopy(fallback_sensor)
if not context:
@@ -434,3 +522,183 @@ def get_sensor_7_in_1_summary_data(farm=None):
"anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context),
"soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context),
}
def _normalize_comparison_chart_field(field_name):
return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name)
def _format_comparison_category(bucket_date, range_value):
if range_value == "7d":
return PERSIAN_WEEKDAYS[bucket_date.weekday()]
return bucket_date.strftime("%m/%d")
def _format_percent_change(current_value, baseline_value):
if not baseline_value:
return "+0.0%"
percent_change = ((current_value - baseline_value) / baseline_value) * 100
return f"{percent_change:+.1f}%"
def _format_current_value_subtitle(title, value, unit):
rendered_value = _format_value(value, unit)
return f"مقدار فعلی: {rendered_value or title}"
def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value):
days = COMPARISON_CHART_RANGES[range_value]
start_date = timezone.localdate() - timedelta(days=days - 1)
try:
logs_queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=farm.farm_uuid,
physical_device_uuid=physical_device_uuid,
date_from=start_date,
)
except ValueError:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
grouped_logs = {}
for log in reversed(list(logs_queryset[: days * 24])):
bucket_date = timezone.localtime(log.created_at).date()
numeric_payload = _extract_numeric_payload(log.payload)
if not numeric_payload:
continue
grouped_logs[bucket_date] = numeric_payload
if not grouped_logs:
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
sorted_dates = sorted(grouped_logs.keys())
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
series_map = {}
for bucket_date in sorted_dates:
payload = grouped_logs[bucket_date]
normalized_payload = {}
for key, value in payload.items():
normalized_key = _normalize_comparison_chart_field(key)
normalized_payload[normalized_key] = value
for key, value in normalized_payload.items():
series_map.setdefault(key, []).append(round(value, 2))
ordered_field_names = [
field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map
] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS)
series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names]
primary_field = ordered_field_names[0]
primary_data = series_map[primary_field]
return {
"series": series,
"categories": categories,
"currentValue": round(primary_data[-1], 2),
"vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]),
}
def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
try:
logs_queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=farm.farm_uuid,
physical_device_uuid=physical_device_uuid,
)
except ValueError:
return {"sensors": []}
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"sensors": []}
logs = [latest_log]
earliest_payload = {}
latest_payload = {}
for log in logs:
numeric_payload = {
_normalize_comparison_chart_field(key): value
for key, value in _extract_numeric_payload(log.payload).items()
}
if not numeric_payload:
continue
if not earliest_payload:
earliest_payload = numeric_payload
latest_payload = numeric_payload
if not latest_payload:
return {"sensors": []}
sensors = []
for field_name, title, unit in VALUES_LIST_FIELDS:
current_value = latest_payload.get(field_name)
if current_value is None:
continue
previous_value = earliest_payload.get(field_name, current_value)
delta = round(current_value - previous_value, 2)
sensors.append(
{
"title": title,
"subtitle": _format_current_value_subtitle(title, current_value, unit),
"trendNumber": abs(delta),
"trend": "positive" if delta >= 0 else "negative",
"unit": unit,
}
)
return {"sensors": sensors}
def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
start_time = timezone.now() - RADAR_CHART_RANGES[range_value]
try:
logs_queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=farm.farm_uuid,
physical_device_uuid=physical_device_uuid,
)
except ValueError:
return {"labels": [], "series": []}
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
if not logs:
latest_log = logs_queryset.order_by("-created_at", "-id").first()
if latest_log is None:
return {"labels": [], "series": []}
logs = [latest_log]
latest_payload = {}
for log in logs:
numeric_payload = {
_normalize_comparison_chart_field(key): value
for key, value in _extract_numeric_payload(log.payload).items()
}
if numeric_payload:
latest_payload = numeric_payload
if not latest_payload:
return {"labels": [], "series": []}
labels = []
current_data = []
ideal_data = []
for field_name, label, ideal_value in RADAR_CHART_FIELDS:
current_value = latest_payload.get(field_name)
if current_value is None:
continue
labels.append(label)
current_data.append(round(current_value, 2))
ideal_data.append(round(ideal_value, 2))
return {
"labels": labels,
"series": [
{"name": "وضعیت فعلی", "data": current_data},
{"name": "بازه ایده آل", "data": ideal_data},
],
}
+154 -13
View File
@@ -1,5 +1,7 @@
from datetime import datetime, timedelta, timezone as dt_timezone
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmSensor, FarmType
@@ -8,8 +10,20 @@ from sensor_external_api.models import SensorExternalRequestLog
from dashboard.services import get_farm_dashboard_cards
from .services import get_sensor_7_in_1_summary_data
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
from .seeds import seed_sensor_7_in_1_demo_data
from .services import (
get_sensor_7_in_1_summary_data,
get_sensor_comparison_chart_data,
get_primary_soil_sensor,
get_sensor_radar_chart_data,
get_sensor_values_list_data,
)
from .views import (
Sensor7In1SummaryView,
SensorComparisonChartView,
SensorRadarChartView,
SensorValuesListView,
)
class Sensor7In1BaseTestCase(TestCase):
@@ -48,6 +62,13 @@ class Sensor7In1BaseTestCase(TestCase):
name="Soil Sensor 7-in-1",
sensor_type="soil_7_in_1",
)
self.chart_sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.sensor_catalog,
physical_device_uuid="44444444-4444-4444-4444-444444444444",
name="Comparison Sensor 7-in-1",
sensor_type="soil_7_in_1",
)
SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
@@ -76,9 +97,31 @@ class Sensor7In1BaseTestCase(TestCase):
"potassium": 24.0,
},
)
now_utc = datetime.now(dt_timezone.utc)
base_time = now_utc.replace(hour=12, minute=0, second=0, microsecond=0)
for index, moisture in enumerate([56, 58, 55, 60, 62, 61, 59]):
log = SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.chart_sensor.physical_device_uuid,
payload={
"moisture": moisture,
"temperature": round(26.2 + (index * 0.2), 1),
"humidity": 50 + index,
},
)
SensorExternalRequestLog.objects.filter(id=log.id).update(
created_at=base_time - timedelta(days=6 - index)
)
class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
def test_primary_sensor_prefers_7_in_1_sensor_over_generic_soil_probe(self):
sensor = get_primary_soil_sensor(farm=self.farm)
self.assertEqual(sensor.id, self.sensor.id)
self.assertEqual(str(sensor.physical_device_uuid), str(self.sensor.physical_device_uuid))
def test_summary_returns_latest_specific_sensor_data(self):
data = get_sensor_7_in_1_summary_data(self.farm)
@@ -98,6 +141,52 @@ class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%")
def test_comparison_chart_service_returns_raw_chart_data(self):
data = get_sensor_comparison_chart_data(
farm=self.farm,
physical_device_uuid=self.sensor.physical_device_uuid,
range_value="7d",
)
self.assertEqual(data["series"][0]["name"], "moisture")
self.assertEqual(data["series"][0]["data"], [41.0, 48.5])
self.assertEqual(data["currentValue"], 48.5)
self.assertEqual(data["vsLastWeek"], "+18.3%")
self.assertEqual(len(data["categories"]), 2)
def test_values_list_service_returns_formatted_sensor_items(self):
data = get_sensor_values_list_data(
farm=self.farm,
physical_device_uuid=self.sensor.physical_device_uuid,
range_value="7d",
)
self.assertEqual(data["sensors"][0]["title"], "Moisture")
self.assertEqual(data["sensors"][0]["subtitle"], "مقدار فعلی: 48.5%")
self.assertEqual(data["sensors"][0]["trendNumber"], 7.5)
self.assertEqual(data["sensors"][0]["trend"], "positive")
self.assertEqual(data["sensors"][1]["title"], "Temperature")
self.assertEqual(data["sensors"][1]["subtitle"], "مقدار فعلی: 23.2°C")
self.assertEqual(data["sensors"][1]["trend"], "positive")
def test_radar_chart_service_returns_aligned_labels_and_series(self):
data = get_sensor_radar_chart_data(
farm=self.farm,
physical_device_uuid=self.sensor.physical_device_uuid,
range_value="7d",
)
self.assertEqual(
data["labels"],
["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"],
)
self.assertEqual(data["series"][0]["name"], "وضعیت فعلی")
self.assertEqual(data["series"][0]["data"], [48.5, 23.2, 6.8, 1.4, 31.0, 24.0])
self.assertEqual(data["series"][1]["name"], "بازه ایده آل")
self.assertEqual(data["series"][1]["data"], [60.0, 26.0, 6.5, 1.3, 42.0, 38.0])
self.assertEqual(len(data["labels"]), len(data["series"][0]["data"]))
self.assertEqual(len(data["labels"]), len(data["series"][1]["data"]))
class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
def test_summary_view_returns_sensor_cards(self):
@@ -119,22 +208,74 @@ class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
def test_radar_chart_view_returns_sensor_chart(self):
request = self.factory.get(f"/api/sensor-7-in-1/sensor-radar-chart/?farm_uuid={self.farm.farm_uuid}")
def test_sensor_comparison_chart_view_returns_raw_payload(self):
request = self.factory.get(
(
"/api/sensors/comparison-chart/"
f"?farm_uuid={self.farm.farm_uuid}"
)
)
force_authenticate(request, user=self.user)
response = Sensor7In1RadarChartView.as_view()(request)
response = SensorComparisonChartView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["series"][0]["name"], "اکنون")
self.assertIn("series", response.data)
self.assertNotIn("code", response.data)
self.assertEqual(response.data["currentValue"], 48.5)
self.assertEqual(response.data["vsLastWeek"], "+18.3%")
def test_comparison_chart_view_returns_sensor_chart(self):
request = self.factory.get(f"/api/sensor-7-in-1/sensor-comparison-chart/?farm_uuid={self.farm.farm_uuid}")
def test_sensor_values_list_view_returns_raw_payload(self):
request = self.factory.get(
(
"/api/sensors/values-list/"
f"?farm_uuid={self.farm.farm_uuid}"
)
)
force_authenticate(request, user=self.user)
response = Sensor7In1ComparisonChartView.as_view()(request)
response = SensorValuesListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["currentValue"], 48.5)
self.assertEqual(response.data["sensors"][0]["title"], "Moisture")
self.assertEqual(response.data["sensors"][0]["trendNumber"], 7.5)
self.assertEqual(response.data["sensors"][0]["trend"], "positive")
def test_sensor_radar_chart_view_returns_raw_payload(self):
request = self.factory.get(
(
"/api/sensors/radar-chart/"
f"?farm_uuid={self.farm.farm_uuid}"
)
)
force_authenticate(request, user=self.user)
response = SensorRadarChartView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["labels"], ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"])
self.assertEqual(response.data["series"][0]["name"], "وضعیت فعلی")
self.assertEqual(response.data["series"][1]["name"], "بازه ایده آل")
self.assertEqual(len(response.data["labels"]), len(response.data["series"][0]["data"]))
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class Sensor7In1SeedTests(TestCase):
def test_seed_sensor_7_in_1_demo_data_creates_idempotent_sensor_logs(self):
first_result = seed_sensor_7_in_1_demo_data()
second_result = seed_sensor_7_in_1_demo_data()
sensor = second_result["sensor"]
logs = SensorExternalRequestLog.objects.filter(
farm_uuid=second_result["farm"].farm_uuid,
physical_device_uuid=sensor.physical_device_uuid,
)
self.assertTrue(SensorCatalog.objects.filter(code="sensor-7-in-1").exists())
self.assertEqual(first_result["farm"].id, second_result["farm"].id)
self.assertEqual(first_result["sensor"].id, second_result["sensor"].id)
self.assertEqual(logs.count(), 7)
self.assertEqual(logs.first().payload["soil_moisture"], 52.4)
+3 -7
View File
@@ -1,14 +1,10 @@
from django.urls import path
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
from .views import (
Sensor7In1SummaryView,
)
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("sensor-radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
path(
"sensor-comparison-chart/",
Sensor7In1ComparisonChartView.as_view(),
name="sensor-7-in-1-comparison-chart",
),
]
+108 -2
View File
@@ -1,18 +1,30 @@
from rest_framework import serializers, status
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from rest_framework.permissions import IsAuthenticated
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, farm_uuid_query_param
from farm_hub.models import FarmHub
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
from .serializers import Sensor7In1SummarySerializer
from .serializers import (
Sensor7In1SummarySerializer,
SensorComparisonChartQuerySerializer,
SensorComparisonChartResponseSerializer,
SensorRadarChartQuerySerializer,
SensorRadarChartResponseSerializer,
SensorValuesListQuerySerializer,
SensorValuesListResponseSerializer,
)
from .services import (
get_sensor_comparison_chart_data,
get_sensor_7_in_1_comparison_chart_data,
get_sensor_7_in_1_radar_chart_data,
get_sensor_7_in_1_summary_data,
get_primary_soil_sensor,
get_sensor_radar_chart_data,
get_sensor_values_list_data,
)
@@ -30,6 +42,13 @@ class Sensor7In1SummaryView(APIView):
except FarmHub.DoesNotExist as exc:
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
@staticmethod
def _get_primary_sensor(*, farm):
sensor = get_primary_soil_sensor(farm=farm)
if sensor is None:
raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]})
return sensor
@extend_schema(
tags=["Sensor 7 in 1"],
parameters=[
@@ -75,3 +94,90 @@ class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
{"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)},
status=status.HTTP_200_OK,
)
class SensorComparisonChartView(Sensor7In1SummaryView):
@extend_schema(
tags=["Sensor 7 in 1"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm."),
OpenApiParameter(
name="range",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Chart range, supported values: 7d, 30d. Defaults to 7d.",
),
],
responses={200: SensorComparisonChartResponseSerializer},
)
def get(self, request):
serializer = SensorComparisonChartQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
data = get_sensor_comparison_chart_data(
farm=farm,
physical_device_uuid=sensor.physical_device_uuid,
range_value=serializer.validated_data["range"],
)
return Response(data, status=status.HTTP_200_OK)
class SensorValuesListView(Sensor7In1SummaryView):
@extend_schema(
tags=["Sensor 7 in 1"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm."),
OpenApiParameter(
name="range",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.",
),
],
responses={200: SensorValuesListResponseSerializer},
)
def get(self, request):
serializer = SensorValuesListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
data = get_sensor_values_list_data(
farm=farm,
physical_device_uuid=sensor.physical_device_uuid,
range_value=serializer.validated_data["range"],
)
return Response(data, status=status.HTTP_200_OK)
class SensorRadarChartView(Sensor7In1SummaryView):
@extend_schema(
tags=["Sensor 7 in 1"],
parameters=[
farm_uuid_query_param(required=True, description="UUID of the farm."),
OpenApiParameter(
name="range",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.",
),
],
responses={200: SensorRadarChartResponseSerializer},
)
def get(self, request):
serializer = SensorRadarChartQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request)
sensor = self._get_primary_sensor(farm=farm)
data = get_sensor_radar_chart_data(
farm=farm,
physical_device_uuid=sensor.physical_device_uuid,
range_value=serializer.validated_data["range"],
)
return Response(data, status=status.HTTP_200_OK)