UPDATE
This commit is contained in:
@@ -4,11 +4,23 @@ import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def _create_model_if_missing(app_label, model_name):
|
||||
def _operation(apps, schema_editor):
|
||||
model = apps.get_model(app_label, model_name)
|
||||
existing_tables = set(schema_editor.connection.introspection.table_names())
|
||||
if model._meta.db_table in existing_tables:
|
||||
return
|
||||
schema_editor.create_model(model)
|
||||
|
||||
return _operation
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("farm_hub", "0009_farmhub_irrigation_method_fields"),
|
||||
("farm_hub", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -33,24 +45,48 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
_create_model_if_missing("device_hub", "SensorCatalog"),
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="FarmDevice",
|
||||
name="FarmSensor",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("physical_device_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("specifications", models.JSONField(blank=True, default=dict)),
|
||||
("power_source", models.JSONField(blank=True, default=dict)),
|
||||
("customization", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")),
|
||||
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.sensorcatalog")),
|
||||
(
|
||||
"farm",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sensors",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
_create_model_if_missing("device_hub", "FarmSensor"),
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="SensorExternalRequestLog",
|
||||
fields=[
|
||||
@@ -65,4 +101,8 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
_create_model_if_missing("device_hub", "SensorExternalRequestLog"),
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
from django.db import migrations
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("device_hub", "0003_absorb_sensor_external_api"),
|
||||
("farm_hub", "0003_farmsensor_catalog_and_physical_device"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.AddField(
|
||||
model_name="farmsensor",
|
||||
name="physical_device_uuid",
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmsensor",
|
||||
name="sensor_catalog",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="farm_sensors",
|
||||
to="device_hub.sensorcatalog",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
+29
-18
@@ -9,23 +9,34 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name="SensorCatalog",
|
||||
new_name="DeviceCatalog",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="devicecatalog",
|
||||
name="device_communication_type",
|
||||
field=models.CharField(
|
||||
choices=[("output_only", "Output Only"), ("input_only", "Input Only")],
|
||||
db_index=True,
|
||||
default="output_only",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="farmdevice",
|
||||
name="sensor_catalog",
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.devicecatalog"),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.RenameModel(
|
||||
old_name="SensorCatalog",
|
||||
new_name="DeviceCatalog",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="devicecatalog",
|
||||
name="device_communication_type",
|
||||
field=models.CharField(
|
||||
choices=[("output_only", "Output Only"), ("input_only", "Input Only")],
|
||||
db_index=True,
|
||||
default="output_only",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="farmdevice",
|
||||
name="sensor_catalog",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="farm_devices",
|
||||
to="device_hub.devicecatalog",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def ensure_device_catalogs_m2m_table(apps, schema_editor):
|
||||
FarmDevice = apps.get_model("device_hub", "FarmDevice")
|
||||
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
|
||||
existing_tables = set(schema_editor.connection.introspection.table_names())
|
||||
if through_model._meta.db_table not in existing_tables:
|
||||
schema_editor.create_model(through_model)
|
||||
|
||||
|
||||
def copy_sensor_catalog_to_device_catalogs(apps, schema_editor):
|
||||
FarmDevice = apps.get_model("device_hub", "FarmDevice")
|
||||
for farm_device in FarmDevice.objects.exclude(sensor_catalog__isnull=True).iterator():
|
||||
farm_device.device_catalogs.add(farm_device.sensor_catalog_id)
|
||||
through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through
|
||||
through_table = through_model._meta.db_table
|
||||
farm_device_column = through_model._meta.get_field("farmdevice").column
|
||||
device_catalog_column = through_model._meta.get_field("devicecatalog").column
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for farm_device_id, sensor_catalog_id in FarmDevice.objects.exclude(sensor_catalog__isnull=True).values_list("pk", "sensor_catalog_id").iterator():
|
||||
cursor.execute(
|
||||
f"""
|
||||
INSERT IGNORE INTO {schema_editor.quote_name(through_table)}
|
||||
({schema_editor.quote_name(farm_device_column)}, {schema_editor.quote_name(device_catalog_column)})
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
[farm_device_id, sensor_catalog_id],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("device_hub", "0007_devicecatalog_dynamic_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="farmdevice",
|
||||
name="device_catalogs",
|
||||
field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"),
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.AddField(
|
||||
model_name="farmdevice",
|
||||
name="device_catalogs",
|
||||
field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(ensure_device_catalogs_m2m_table, migrations.RunPython.noop),
|
||||
migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def add_column_if_missing(schema_editor, table_name, column_name, field):
|
||||
existing_columns = {
|
||||
column.name
|
||||
for column in schema_editor.connection.introspection.get_table_description(
|
||||
schema_editor.connection.cursor(),
|
||||
table_name,
|
||||
)
|
||||
}
|
||||
if column_name in existing_columns:
|
||||
return
|
||||
field.set_attributes_from_name(column_name)
|
||||
schema_editor.add_field(
|
||||
field.model,
|
||||
field,
|
||||
)
|
||||
|
||||
|
||||
def sync_devicecatalog_schema(apps, schema_editor):
|
||||
DeviceCatalog = apps.get_model("device_hub", "DeviceCatalog")
|
||||
table_name = DeviceCatalog._meta.db_table
|
||||
|
||||
fields = [
|
||||
DeviceCatalog._meta.get_field("device_communication_type"),
|
||||
DeviceCatalog._meta.get_field("payload_mapping"),
|
||||
DeviceCatalog._meta.get_field("display_schema"),
|
||||
DeviceCatalog._meta.get_field("supported_widgets"),
|
||||
DeviceCatalog._meta.get_field("commands_schema"),
|
||||
DeviceCatalog._meta.get_field("capabilities"),
|
||||
]
|
||||
|
||||
for field in fields:
|
||||
add_column_if_missing(schema_editor, table_name, field.column, field)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("device_hub", "0008_farmdevice_device_catalogs"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(sync_devicecatalog_schema, migrations.RunPython.noop),
|
||||
]
|
||||
+270
-123
@@ -6,12 +6,13 @@ from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from config.failure_contract import StructuredServiceError
|
||||
from external_api_adapter import request as external_api_request
|
||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||
from notifications.services import create_notification_for_farm_uuid
|
||||
|
||||
from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP
|
||||
from .models import FarmDevice, SensorExternalRequestLog
|
||||
from .templates import AVG_SOIL_MOISTURE_TEMPLATE, SENSOR_META_TEMPLATE, SOIL_MOISTURE_HEATMAP_TEMPLATE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +20,17 @@ logger = logging.getLogger(__name__)
|
||||
class FarmDataForwardError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceDataUnavailableError(StructuredServiceError):
|
||||
def __init__(self, *, error_code: str, message: str, details: dict | None = None, retriable: bool = False):
|
||||
super().__init__(
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
source="db",
|
||||
details=details,
|
||||
retriable=retriable,
|
||||
)
|
||||
|
||||
SENSOR_FIELDS = [
|
||||
{"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"},
|
||||
{"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"},
|
||||
@@ -257,21 +269,37 @@ def get_primary_soil_sensor(*, farm):
|
||||
|
||||
def _get_sensor_context(farm=None):
|
||||
if farm is None:
|
||||
return None
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="missing_farm",
|
||||
message="Farm instance is required for sensor context lookup.",
|
||||
)
|
||||
primary_sensor = get_primary_soil_sensor(farm=farm)
|
||||
if primary_sensor is None:
|
||||
return None
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="sensor_not_found",
|
||||
message=f"No primary soil sensor found for farm_uuid={farm.farm_uuid}.",
|
||||
details={"farm_uuid": str(farm.farm_uuid)},
|
||||
)
|
||||
try:
|
||||
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
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="history_unavailable",
|
||||
message=f"Sensor history lookup failed for farm_uuid={farm.farm_uuid}.",
|
||||
details={"farm_uuid": str(farm.farm_uuid)},
|
||||
retriable=True,
|
||||
) from exc
|
||||
history = []
|
||||
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||
readings = _extract_readings(log.payload)
|
||||
if readings:
|
||||
history.append((log, readings))
|
||||
if not history:
|
||||
return None
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_sensor_readings",
|
||||
message=f"No sensor readings found for farm_uuid={farm.farm_uuid}.",
|
||||
details={"farm_uuid": str(farm.farm_uuid)},
|
||||
)
|
||||
latest_log, latest_readings = history[0]
|
||||
farm_device_map = get_farm_device_map_for_logs(logs=[latest_log])
|
||||
farm_device = farm_device_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
|
||||
@@ -304,40 +332,46 @@ def _calculate_status_chip(value):
|
||||
|
||||
|
||||
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_VALUES_LIST)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
data["sensor"] = _build_sensor_meta(context, data["sensor"])
|
||||
if not context:
|
||||
return data
|
||||
data = {
|
||||
"sensor": _build_sensor_meta(context, SENSOR_META_TEMPLATE),
|
||||
"sensors": [],
|
||||
}
|
||||
latest_readings = context["latest_readings"]
|
||||
previous_readings = context["previous_readings"]
|
||||
sensors = []
|
||||
for field in SENSOR_FIELDS:
|
||||
value = latest_readings.get(field["id"])
|
||||
if value is None:
|
||||
continue
|
||||
previous = previous_readings.get(field["id"])
|
||||
change = 0.0 if previous is None else round(value - previous, 2)
|
||||
sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
|
||||
if sensors:
|
||||
data["sensors"] = sensors
|
||||
data["sensors"].append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
|
||||
if not data["sensors"]:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_numeric_readings",
|
||||
message=f"Latest sensor payload has no usable numeric values for farm_uuid={farm.farm_uuid if farm else None}.",
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
|
||||
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
moisture = context["latest_readings"].get("soil_moisture")
|
||||
if moisture is None:
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="missing_soil_moisture",
|
||||
message=f"Latest sensor payload is missing soil_moisture for farm_uuid={farm.farm_uuid if farm else None}.",
|
||||
)
|
||||
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
|
||||
data["stats"] = _format_value(moisture, "%")
|
||||
data["chipText"] = chip_text
|
||||
data["chipColor"] = chip_color
|
||||
data["avatarColor"] = avatar_color
|
||||
return data
|
||||
return {
|
||||
**deepcopy(AVG_SOIL_MOISTURE_TEMPLATE),
|
||||
"stats": _format_value(moisture, "%"),
|
||||
"chipText": chip_text,
|
||||
"chipColor": chip_color,
|
||||
"avatarColor": avatar_color,
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def _score_field(value, field):
|
||||
@@ -353,10 +387,7 @@ def _score_field(value, field):
|
||||
|
||||
|
||||
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_RADAR_CHART)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
latest_readings = context["latest_readings"]
|
||||
scores, labels = [], []
|
||||
for field in SENSOR_FIELDS:
|
||||
@@ -365,32 +396,42 @@ def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
|
||||
continue
|
||||
labels.append(field["radar_label"])
|
||||
scores.append(_score_field(value, field))
|
||||
if labels:
|
||||
data["labels"] = labels
|
||||
data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}]
|
||||
return data
|
||||
if not labels:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_radar_data",
|
||||
message=f"No usable sensor readings found for radar chart farm_uuid={farm.farm_uuid if farm else None}.",
|
||||
)
|
||||
return {
|
||||
"labels": labels,
|
||||
"series": [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}],
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_COMPARISON_CHART)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||
moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None]
|
||||
if not moisture_points:
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_comparison_data",
|
||||
message=f"No soil moisture history found for comparison chart farm_uuid={farm.farm_uuid if farm else None}.",
|
||||
)
|
||||
categories = [item[0] for item in moisture_points]
|
||||
values = [round(item[1], 2) for item in moisture_points]
|
||||
current_value = values[-1]
|
||||
baseline_value = values[0] if len(values) > 1 else 55.0
|
||||
percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0
|
||||
data["currentValue"] = round(current_value, 2)
|
||||
data["vsLastWeekValue"] = round(percent_change, 2)
|
||||
data["vsLastWeek"] = f"{percent_change:+.1f}%"
|
||||
data["categories"] = categories
|
||||
data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}]
|
||||
return data
|
||||
return {
|
||||
"currentValue": round(current_value, 2),
|
||||
"vsLastWeekValue": round(percent_change, 2),
|
||||
"vsLastWeek": f"{percent_change:+.1f}%",
|
||||
"categories": categories,
|
||||
"series": [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}],
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def _build_anomaly_item(field, value):
|
||||
@@ -408,10 +449,7 @@ def _build_anomaly_item(field, value):
|
||||
|
||||
|
||||
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
|
||||
data = deepcopy(ANOMALY_DETECTION_CARD)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
anomalies = []
|
||||
for field in SENSOR_FIELDS:
|
||||
value = context["latest_readings"].get(field["id"])
|
||||
@@ -420,27 +458,38 @@ def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
|
||||
anomaly = _build_anomaly_item(field, value)
|
||||
if anomaly is not None:
|
||||
anomalies.append(anomaly)
|
||||
data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخصها در بازه مجاز هستند", "deviation": "0", "severity": "success"}]
|
||||
return data
|
||||
return {
|
||||
"anomalies": anomalies,
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
"warnings": [] if anomalies else ["No anomalies detected from the latest sensor readings."],
|
||||
}
|
||||
|
||||
|
||||
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
|
||||
data = deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||
chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None]
|
||||
if not chart_points:
|
||||
return data
|
||||
sensor_name = data["zones"][0]
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_heatmap_data",
|
||||
message=f"No soil moisture history found for heatmap farm_uuid={farm.farm_uuid if farm else None}.",
|
||||
)
|
||||
sensor_name = (
|
||||
SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"][0]
|
||||
if SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"]
|
||||
else "سنسور خاک"
|
||||
)
|
||||
farm_device = context.get("farm_device")
|
||||
if farm_device is not None and farm_device.name:
|
||||
sensor_name = farm_device.name
|
||||
data["zones"] = [sensor_name]
|
||||
data["hours"] = [point["x"] for point in chart_points]
|
||||
data["series"] = [{"name": sensor_name, "data": chart_points}]
|
||||
return data
|
||||
return {
|
||||
"zones": [sensor_name],
|
||||
"hours": [point["x"] for point in chart_points],
|
||||
"series": [{"name": sensor_name, "data": chart_points}],
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def get_sensor_7_in_1_summary_data(farm=None):
|
||||
@@ -473,8 +522,10 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, 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%"}
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
f"Sensor comparison chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
) from exc
|
||||
grouped_logs = {}
|
||||
for log in reversed(list(logs_queryset[: days * 24])):
|
||||
bucket_date = timezone.localtime(log.created_at).date()
|
||||
@@ -482,7 +533,9 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value)
|
||||
if numeric_payload:
|
||||
grouped_logs[bucket_date] = numeric_payload
|
||||
if not grouped_logs:
|
||||
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||
raise DeviceDataUnavailableError(
|
||||
f"No sensor history found for comparison chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
)
|
||||
sorted_dates = sorted(grouped_logs.keys())
|
||||
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
|
||||
series_map = {}
|
||||
@@ -500,13 +553,17 @@ 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": []}
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
f"Sensor values list data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
) from exc
|
||||
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": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
f"No sensor logs found for values list farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
)
|
||||
logs = [latest_log]
|
||||
earliest_payload, latest_payload = {}, {}
|
||||
for log in logs:
|
||||
@@ -517,7 +574,9 @@ def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
|
||||
earliest_payload = numeric_payload
|
||||
latest_payload = numeric_payload
|
||||
if not latest_payload:
|
||||
return {"sensors": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
f"Latest sensor payload has no numeric values for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
)
|
||||
sensors = []
|
||||
for field_name, title, unit in VALUES_LIST_FIELDS:
|
||||
current_value = latest_payload.get(field_name)
|
||||
@@ -533,13 +592,17 @@ 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": []}
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
f"Sensor radar chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
) from exc
|
||||
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": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
f"No sensor logs found for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
)
|
||||
logs = [latest_log]
|
||||
latest_payload = {}
|
||||
for log in logs:
|
||||
@@ -547,7 +610,9 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
|
||||
if numeric_payload:
|
||||
latest_payload = numeric_payload
|
||||
if not latest_payload:
|
||||
return {"labels": [], "series": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
f"Latest sensor payload has no numeric values for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}."
|
||||
)
|
||||
labels, current_data, ideal_data = [], [], []
|
||||
for field_name, label, ideal_value in RADAR_CHART_FIELDS:
|
||||
current_value = latest_payload.get(field_name)
|
||||
@@ -728,11 +793,24 @@ def _get_device_supported_widgets(device_catalog):
|
||||
|
||||
def _get_device_history_context(farm_device):
|
||||
if farm_device is None:
|
||||
return None
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="device_not_found",
|
||||
message="Farm device instance is required for history lookup.",
|
||||
)
|
||||
try:
|
||||
logs_queryset = get_device_logs(farm_device)
|
||||
except ValueError:
|
||||
return None
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Device history lookup failed for farm_device_id=%s: %s",
|
||||
getattr(farm_device, "id", None),
|
||||
exc,
|
||||
)
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="history_unavailable",
|
||||
message=f"Device history lookup failed for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
retriable=True,
|
||||
) from exc
|
||||
history = []
|
||||
device_catalog = get_device_catalog_for_farm_device(farm_device)
|
||||
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||
@@ -741,14 +819,11 @@ def _get_device_history_context(farm_device):
|
||||
if readings or normalized_payload:
|
||||
history.append((log, readings, normalized_payload))
|
||||
if not history:
|
||||
return {
|
||||
"farm_device": farm_device,
|
||||
"latest_log": None,
|
||||
"latest_readings": {},
|
||||
"latest_payload": {},
|
||||
"previous_readings": {},
|
||||
"history": [],
|
||||
}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_device_history",
|
||||
message=f"No device history found for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
latest_log, latest_readings, latest_payload = history[0]
|
||||
return {
|
||||
"farm_device": farm_device,
|
||||
@@ -775,15 +850,11 @@ def build_device_latest_payload(farm_device, *, device_code):
|
||||
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
|
||||
latest_log = get_latest_device_log(farm_device, device_catalog=device_catalog)
|
||||
if latest_log is None:
|
||||
return {
|
||||
"physical_device_uuid": farm_device.physical_device_uuid,
|
||||
"device_code": device_code,
|
||||
"device_catalog_code": device_catalog.code if device_catalog else None,
|
||||
"raw_payload": {},
|
||||
"normalized_payload": {},
|
||||
"readings": {},
|
||||
"created_at": None,
|
||||
}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_device_payload",
|
||||
message=f"No device payload log found for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
return {
|
||||
"physical_device_uuid": farm_device.physical_device_uuid,
|
||||
"device_code": device_code,
|
||||
@@ -798,14 +869,23 @@ def build_device_latest_payload(farm_device, *, device_code):
|
||||
def build_device_values_list(farm_device, range_value, *, device_code):
|
||||
try:
|
||||
logs_queryset = get_device_logs(farm_device)
|
||||
except ValueError:
|
||||
return {"sensors": []}
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="history_unavailable",
|
||||
message=f"Device values list data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
retriable=True,
|
||||
) from exc
|
||||
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
|
||||
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": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_device_history",
|
||||
message=f"No device logs found for values list farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
logs = [latest_log]
|
||||
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
|
||||
earliest_payload = {}
|
||||
@@ -818,7 +898,11 @@ def build_device_values_list(farm_device, range_value, *, device_code):
|
||||
earliest_payload = normalized_payload
|
||||
latest_payload = normalized_payload
|
||||
if not latest_payload:
|
||||
return {"sensors": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_numeric_readings",
|
||||
message=f"Latest device payload has no numeric values for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
sensors = []
|
||||
for field in _get_device_field_definitions(device_catalog):
|
||||
current_value = latest_payload.get(field["id"])
|
||||
@@ -835,7 +919,13 @@ def build_device_values_list(farm_device, range_value, *, device_code):
|
||||
"unit": field["unit"],
|
||||
}
|
||||
)
|
||||
return {"sensors": sensors}
|
||||
if not sensors:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_numeric_readings",
|
||||
message=f"No device values could be derived for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
return {"sensors": sensors, "status": "success", "source": "db"}
|
||||
|
||||
|
||||
def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None):
|
||||
@@ -860,6 +950,12 @@ def build_device_summary_values_list(farm_device, context=None, *, device_catalo
|
||||
"unit": field["unit"],
|
||||
}
|
||||
)
|
||||
if not data["sensors"]:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_numeric_readings",
|
||||
message=f"No summary values available for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -867,7 +963,11 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code):
|
||||
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
|
||||
context = _get_device_history_context(farm_device)
|
||||
if not context or not context.get("latest_readings"):
|
||||
return {"labels": [], "series": []}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_radar_data",
|
||||
message=f"Device radar chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
labels, current_data, ideal_data = [], [], []
|
||||
for field in _get_device_field_definitions(device_catalog):
|
||||
current_value = context["latest_readings"].get(field["id"])
|
||||
@@ -877,7 +977,13 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code):
|
||||
current_data.append(round(current_value, 2))
|
||||
midpoint = (field["ideal_min"] + field["ideal_max"]) / 2
|
||||
ideal_data.append(round(midpoint, 2))
|
||||
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]}
|
||||
if not labels:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_radar_data",
|
||||
message=f"No usable readings found for radar chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}], "status": "success", "source": "db"}
|
||||
|
||||
|
||||
def build_device_comparison_chart(farm_device, range_value, *, device_code):
|
||||
@@ -885,8 +991,13 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
|
||||
start_date = timezone.localdate() - timedelta(days=days - 1)
|
||||
try:
|
||||
logs_queryset = get_device_logs(farm_device, date_from=start_date)
|
||||
except ValueError:
|
||||
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||
except ValueError as exc:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="history_unavailable",
|
||||
message=f"Device comparison chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
retriable=True,
|
||||
) from exc
|
||||
device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code)
|
||||
field_definitions = _get_device_field_definitions(device_catalog)
|
||||
grouped_logs = {}
|
||||
@@ -894,7 +1005,11 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
|
||||
bucket_date = timezone.localtime(log.created_at).date()
|
||||
grouped_logs[bucket_date] = extract_device_readings(device_catalog, log.payload)
|
||||
if not grouped_logs:
|
||||
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_device_history",
|
||||
message=f"No device history found for comparison chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
sorted_dates = sorted(grouped_logs.keys())
|
||||
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
|
||||
series = []
|
||||
@@ -912,12 +1027,18 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code):
|
||||
if not primary_data:
|
||||
primary_data = data_points
|
||||
if not series or not primary_data:
|
||||
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_comparison_data",
|
||||
message=f"Device comparison chart has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code},
|
||||
)
|
||||
return {
|
||||
"series": series,
|
||||
"categories": categories,
|
||||
"currentValue": round(primary_data[-1], 2),
|
||||
"vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]),
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
@@ -933,29 +1054,37 @@ def build_device_anomaly_detection_card(farm_device, context=None, *, device_cat
|
||||
anomaly = _build_anomaly_item(field, value)
|
||||
if anomaly is not None:
|
||||
anomalies.append(anomaly)
|
||||
if not latest_readings:
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_numeric_readings",
|
||||
message=f"No latest readings available for anomaly detection farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
return {
|
||||
"anomalies": anomalies or [
|
||||
{
|
||||
"sensor": farm_device.name if farm_device else "Device",
|
||||
"value": "نرمال",
|
||||
"expected": "تمام شاخصها در بازه مجاز هستند",
|
||||
"deviation": "0",
|
||||
"severity": "success",
|
||||
}
|
||||
]
|
||||
"anomalies": anomalies,
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
"warnings": [] if anomalies else ["No anomalies detected from the latest device readings."],
|
||||
}
|
||||
|
||||
|
||||
def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_catalog=None):
|
||||
data = deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||
context = _get_device_history_context(farm_device) if context is None else context
|
||||
if not context or not context.get("history"):
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_heatmap_data",
|
||||
message=f"Device heatmap data is unavailable for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
|
||||
field_definitions = _get_device_field_definitions(device_catalog)
|
||||
primary_field = field_definitions[0] if field_definitions else None
|
||||
if primary_field is None:
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="invalid_schema",
|
||||
message=f"Device field schema is missing for heatmap farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
chart_points = []
|
||||
for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]):
|
||||
value = readings.get(primary_field["id"])
|
||||
@@ -963,33 +1092,51 @@ def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_cata
|
||||
continue
|
||||
chart_points.append({"x": log.created_at.strftime("%H:%M"), "y": round(value, 2)})
|
||||
if not chart_points:
|
||||
return data
|
||||
sensor_name = farm_device.name if farm_device and farm_device.name else data["zones"][0]
|
||||
data["zones"] = [sensor_name]
|
||||
data["hours"] = [point["x"] for point in chart_points]
|
||||
data["series"] = [{"name": sensor_name, "data": chart_points}]
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="no_heatmap_data",
|
||||
message=f"Device heatmap has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
sensor_name = farm_device.name if farm_device and farm_device.name else SOIL_MOISTURE_HEATMAP["zones"][0]
|
||||
return {
|
||||
"zones": [sensor_name],
|
||||
"hours": [point["x"] for point in chart_points],
|
||||
"series": [{"name": sensor_name, "data": chart_points}],
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def build_device_avg_primary_metric(farm_device, context=None, *, device_catalog=None):
|
||||
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||
context = _get_device_history_context(farm_device) if context is None else context
|
||||
latest_readings = context.get("latest_readings", {}) if context else {}
|
||||
device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device)
|
||||
field_definitions = _get_device_field_definitions(device_catalog)
|
||||
primary_field = field_definitions[0] if field_definitions else None
|
||||
if primary_field is None:
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="invalid_schema",
|
||||
message=f"Device field schema is missing for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
primary_value = latest_readings.get(primary_field["id"])
|
||||
if primary_value is None:
|
||||
return data
|
||||
raise DeviceDataUnavailableError(
|
||||
error_code="missing_primary_metric",
|
||||
message=f"Primary metric is missing for farm_device_id={getattr(farm_device, 'id', None)}.",
|
||||
details={"farm_device_id": getattr(farm_device, "id", None)},
|
||||
)
|
||||
chip_text, chip_color, avatar_color = _calculate_status_chip(primary_value)
|
||||
data["title"] = primary_field["label"]
|
||||
data["stats"] = _format_value(primary_value, primary_field["unit"])
|
||||
data["chipText"] = chip_text
|
||||
data["chipColor"] = chip_color
|
||||
data["avatarColor"] = avatar_color
|
||||
return data
|
||||
return {
|
||||
**deepcopy(AVG_SOIL_MOISTURE),
|
||||
"title": primary_field["label"],
|
||||
"stats": _format_value(primary_value, primary_field["unit"]),
|
||||
"chipText": chip_text,
|
||||
"chipColor": chip_color,
|
||||
"avatarColor": avatar_color,
|
||||
"status": "success",
|
||||
"source": "db",
|
||||
}
|
||||
|
||||
|
||||
def build_device_summary(farm_device, *, device_code):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
AVG_SOIL_MOISTURE_TEMPLATE = {
|
||||
"id": "avg_soil_moisture",
|
||||
"title": "میانگین رطوبت خاک",
|
||||
"subtitle": "سنسور 7 در 1 خاک",
|
||||
"stats": None,
|
||||
"avatarColor": "secondary",
|
||||
"avatarIcon": "tabler-droplet",
|
||||
"chipText": "بدون داده",
|
||||
"chipColor": "secondary",
|
||||
}
|
||||
|
||||
SENSOR_META_TEMPLATE = {
|
||||
"name": "سنسور 7 در 1 خاک",
|
||||
"physicalDeviceUuid": None,
|
||||
"sensorCatalogCode": "sensor-7-in-1",
|
||||
"updatedAt": None,
|
||||
}
|
||||
|
||||
SOIL_MOISTURE_HEATMAP_TEMPLATE = {
|
||||
"zones": [],
|
||||
"hours": [],
|
||||
"series": [],
|
||||
}
|
||||
@@ -5,6 +5,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
|
||||
from .models import DeviceCatalog, SensorExternalRequestLog
|
||||
from .services import DeviceDataUnavailableError, build_device_anomaly_detection_card
|
||||
from .views import DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView
|
||||
|
||||
|
||||
@@ -91,6 +92,27 @@ class DeviceHubGenericViewsTests(TestCase):
|
||||
self.assertIn("values_list", response.data["data"]["supportedWidgets"])
|
||||
self.assertIn("sensorValuesList", response.data["data"])
|
||||
|
||||
def test_device_summary_view_returns_validation_error_when_history_missing(self):
|
||||
SensorExternalRequestLog.objects.all().delete()
|
||||
request = self.factory.get(
|
||||
f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/",
|
||||
{"device_code": self.catalog.code},
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("no device history found", response.data["device_code"][0].lower())
|
||||
|
||||
def test_build_device_anomaly_detection_card_returns_explicit_empty_success(self):
|
||||
payload = build_device_anomaly_detection_card(self.device)
|
||||
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertEqual(payload["source"], "db")
|
||||
self.assertEqual(payload["anomalies"], [])
|
||||
self.assertTrue(payload["warnings"])
|
||||
|
||||
def test_input_only_device_command_view_rejects_input_only_device_code(self):
|
||||
input_catalog = DeviceCatalog.objects.create(
|
||||
code="valve_v1",
|
||||
|
||||
+37
-13
@@ -13,7 +13,7 @@ from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerial
|
||||
from .authentication import SensorExternalAPIKeyAuthentication
|
||||
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
|
||||
from .serializers import DeviceCatalogSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
|
||||
from .services import FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
|
||||
from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
|
||||
|
||||
|
||||
class DeviceCatalogListView(APIView):
|
||||
@@ -114,6 +114,12 @@ class DeviceRadarChartView(DeviceBaseView):
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorExternalRequestLogPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class DeviceLogListView(DeviceBaseView):
|
||||
pagination_class = SensorExternalRequestLogPagination
|
||||
|
||||
@@ -194,21 +200,33 @@ class Sensor7In1SummaryView(APIView):
|
||||
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK)
|
||||
try:
|
||||
data = get_sensor_7_in_1_summary_data(farm)
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class Sensor7In1RadarChartView(Sensor7In1SummaryView):
|
||||
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK)
|
||||
try:
|
||||
data = get_sensor_7_in_1_radar_chart_data(farm)
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
|
||||
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK)
|
||||
try:
|
||||
data = get_sensor_7_in_1_comparison_chart_data(farm)
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorComparisonChartView(Sensor7In1SummaryView):
|
||||
@@ -218,7 +236,11 @@ class SensorComparisonChartView(Sensor7In1SummaryView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
farm = self._get_farm(request)
|
||||
sensor = self._get_primary_sensor(farm=farm)
|
||||
return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||
try:
|
||||
data = get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorValuesListView(Sensor7In1SummaryView):
|
||||
@@ -228,7 +250,11 @@ class SensorValuesListView(Sensor7In1SummaryView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
farm = self._get_farm(request)
|
||||
sensor = self._get_primary_sensor(farm=farm)
|
||||
return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||
try:
|
||||
data = get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorRadarChartView(Sensor7In1SummaryView):
|
||||
@@ -238,13 +264,11 @@ class SensorRadarChartView(Sensor7In1SummaryView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
farm = self._get_farm(request)
|
||||
sensor = self._get_primary_sensor(farm=farm)
|
||||
return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorExternalRequestLogPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
try:
|
||||
data = get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"])
|
||||
except DeviceDataUnavailableError as exc:
|
||||
raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorExternalAPIView(APIView):
|
||||
|
||||
Reference in New Issue
Block a user