This commit is contained in:
2026-05-05 21:01:58 +03:30
parent 39efd537bf
commit 4e28bacad6
54 changed files with 2729 additions and 1115 deletions
+45 -5
View File
@@ -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",
),
),
],
),
]
@@ -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
View File
@@ -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):
+23
View File
@@ -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": [],
}
+22
View File
@@ -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
View File
@@ -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):