UPDATE
This commit is contained in:
@@ -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"),
|
||||
]
|
||||
@@ -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']}"
|
||||
)
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user