From 39efd537bf0cd4536277c75bd62d491274784ef7 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Tue, 5 May 2026 01:32:27 +0330 Subject: [PATCH] UPDATE --- access_control/middleware.py | 1 + access_control/models.py | 2 +- access_control/permissions.py | 1 + access_control/services.py | 13 +- access_control/views.py | 1 + config/urls.py | 1 + device_hub/catalog_seed.py | 7 +- device_hub/comparison_urls.py | 1 - device_hub/migrations/0001_initial.py | 4 +- .../0005_rename_farm_sensor_to_farm_device.py | 19 + ...evicecatalog_and_add_communication_type.py | 31 + .../0007_devicecatalog_dynamic_fields.py | 36 + .../0008_farmdevice_device_catalogs.py | 23 + device_hub/models.py | 49 +- device_hub/seeds.py | 7 +- device_hub/sensor_catalog_urls.py | 5 +- device_hub/sensor_serializers.py | 32 + device_hub/serializers.py | 116 ++- device_hub/services.py | 541 +++++++++++++- device_hub/tests.py | 118 +++ device_hub/urls.py | 14 +- device_hub/views.py | 163 +++- docs/device_catalog_dynamic_architecture.md | 706 ++++++++++++++++++ farm_hub/seeds.py | 4 +- farm_hub/serializers.py | 69 +- farm_hub/tests.py | 8 +- farm_hub/views.py | 3 +- 27 files changed, 1874 insertions(+), 101 deletions(-) create mode 100644 device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py create mode 100644 device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py create mode 100644 device_hub/migrations/0007_devicecatalog_dynamic_fields.py create mode 100644 device_hub/migrations/0008_farmdevice_device_catalogs.py create mode 100644 device_hub/tests.py create mode 100644 docs/device_catalog_dynamic_architecture.md diff --git a/access_control/middleware.py b/access_control/middleware.py index 03ec33c..da462d5 100644 --- a/access_control/middleware.py +++ b/access_control/middleware.py @@ -41,6 +41,7 @@ class RouteFeatureAccessMiddleware(MiddlewareMixin): "products", "sensors", "sensors__sensor_catalog", + "sensors__device_catalogs", ).get(farm_uuid=farm_uuid, owner=user) except FarmHub.DoesNotExist: return JsonResponse( diff --git a/access_control/models.py b/access_control/models.py index 0c16cbc..9e290da 100644 --- a/access_control/models.py +++ b/access_control/models.py @@ -70,7 +70,7 @@ class AccessRule(models.Model): subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True) farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True) products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True) - sensor_catalogs = models.ManyToManyField("device_hub.SensorCatalog", related_name="access_rules", blank=True) + sensor_catalogs = models.ManyToManyField("device_hub.DeviceCatalog", related_name="access_rules", blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/access_control/permissions.py b/access_control/permissions.py index 0eb3bb7..8b15b35 100644 --- a/access_control/permissions.py +++ b/access_control/permissions.py @@ -27,6 +27,7 @@ class FeatureAccessPermission(BasePermission): "products", "sensors", "sensors__sensor_catalog", + "sensors__device_catalogs", ).get(farm_uuid=farm_uuid, owner=request.user) except FarmHub.DoesNotExist: self.message = f"Access to feature `{feature_code}` is denied." diff --git a/access_control/services.py b/access_control/services.py index 59a2597..a0b59e4 100644 --- a/access_control/services.py +++ b/access_control/services.py @@ -109,16 +109,17 @@ def build_farm_access_profile(farm): "products", "sensors", "sensors__sensor_catalog", + "sensors__device_catalogs", ).get(pk=farm.pk) subscription_plan = get_effective_subscription_plan(farm) product_ids = list(farm.products.values_list("id", flat=True)) - sensor_catalog_ids = list( - farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog_id", flat=True) - ) - sensor_catalog_codes = set( - farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True) - ) + sensor_catalog_ids = set() + sensor_catalog_codes = set() + for sensor in farm.sensors.all(): + for catalog in sensor.get_device_catalogs(): + sensor_catalog_ids.add(catalog.id) + sensor_catalog_codes.add(catalog.code) features = { feature.code: { diff --git a/access_control/views.py b/access_control/views.py index bb812aa..37eb5d0 100644 --- a/access_control/views.py +++ b/access_control/views.py @@ -31,6 +31,7 @@ class FarmFeatureAuthorizationView(APIView): "products", "sensors", "sensors__sensor_catalog", + "sensors__device_catalogs", ).get( farm_uuid=farm_uuid, owner=request.user, diff --git a/config/urls.py b/config/urls.py index ec74d0a..44f71ca 100644 --- a/config/urls.py +++ b/config/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path("api/account/", include("account.urls")), path("api/farm-hub/", include("farm_hub.urls")), path("api/access-control/", include("access_control.urls")), + path("api/device-hub/", include("device_hub.urls")), path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")), path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard/", include("dashboard.urls")), diff --git a/device_hub/catalog_seed.py b/device_hub/catalog_seed.py index aa4f003..7015ec7 100644 --- a/device_hub/catalog_seed.py +++ b/device_hub/catalog_seed.py @@ -1,4 +1,4 @@ -from .models import SensorCatalog +from .models import DeviceCatalog SENSOR_CATALOG_ITEMS = [ @@ -10,6 +10,7 @@ SENSOR_CATALOG_ITEMS = [ "It measures only soil moisture and provides analog and digital outputs. " "It does not report soil temperature, pH, or nutrients." ), + "device_communication_type": "output_only", "customizable_fields": [], "supported_power_sources": ["solar", "direct_power"], "returned_data_fields": ["soil_moisture", "analog_output", "digital_output"], @@ -29,11 +30,12 @@ def seed_sensor_catalog(): results = [] for item in SENSOR_CATALOG_ITEMS: - sensor, created = SensorCatalog.objects.update_or_create( + sensor, created = DeviceCatalog.objects.update_or_create( code=item["code"], defaults={ "name": item["name"], "description": item["description"], + "device_communication_type": item.get("device_communication_type", "output_only"), "customizable_fields": item["customizable_fields"], "supported_power_sources": item["supported_power_sources"], "returned_data_fields": item["returned_data_fields"], @@ -48,4 +50,3 @@ def seed_sensor_catalog(): updated_count += 1 return results, created_count, updated_count - diff --git a/device_hub/comparison_urls.py b/device_hub/comparison_urls.py index e84aa71..6e1ee73 100644 --- a/device_hub/comparison_urls.py +++ b/device_hub/comparison_urls.py @@ -7,4 +7,3 @@ urlpatterns = [ path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"), path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"), ] - diff --git a/device_hub/migrations/0001_initial.py b/device_hub/migrations/0001_initial.py index a74f1ed..091a26a 100644 --- a/device_hub/migrations/0001_initial.py +++ b/device_hub/migrations/0001_initial.py @@ -34,7 +34,7 @@ class Migration(migrations.Migration): options={"db_table": "sensor_catalogs", "ordering": ["code"]}, ), migrations.CreateModel( - name="FarmSensor", + name="FarmDevice", 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)), @@ -47,7 +47,7 @@ class Migration(migrations.Migration): ("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_sensors", to="device_hub.sensorcatalog")), + ("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.sensorcatalog")), ], options={"db_table": "farm_sensors", "ordering": ["-created_at"]}, ), diff --git a/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py b/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py new file mode 100644 index 0000000..3b4f120 --- /dev/null +++ b/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0004_absorb_sensor_catalog"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RenameModel( + old_name="FarmSensor", + new_name="FarmDevice", + ), + ], + ), + ] diff --git a/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py b/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py new file mode 100644 index 0000000..4707fc2 --- /dev/null +++ b/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py @@ -0,0 +1,31 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("device_hub", "0005_rename_farm_sensor_to_farm_device"), + ] + + 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"), + ), + ] diff --git a/device_hub/migrations/0007_devicecatalog_dynamic_fields.py b/device_hub/migrations/0007_devicecatalog_dynamic_fields.py new file mode 100644 index 0000000..6e601d2 --- /dev/null +++ b/device_hub/migrations/0007_devicecatalog_dynamic_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("device_hub", "0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type"), + ] + + operations = [ + migrations.AddField( + model_name="devicecatalog", + name="capabilities", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="devicecatalog", + name="commands_schema", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="devicecatalog", + name="display_schema", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="devicecatalog", + name="payload_mapping", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="devicecatalog", + name="supported_widgets", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/device_hub/migrations/0008_farmdevice_device_catalogs.py b/device_hub/migrations/0008_farmdevice_device_catalogs.py new file mode 100644 index 0000000..99ccb7b --- /dev/null +++ b/device_hub/migrations/0008_farmdevice_device_catalogs.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +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) + + +class Migration(migrations.Migration): + + 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.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop), + ] diff --git a/device_hub/models.py b/device_hub/models.py index 5011f28..5b0c3ad 100644 --- a/device_hub/models.py +++ b/device_hub/models.py @@ -3,14 +3,32 @@ import uuid as uuid_lib from django.db import models -class SensorCatalog(models.Model): +class DeviceCatalog(models.Model): + OUTPUT_ONLY = "output_only" + INPUT_ONLY = "input_only" + DEVICE_COMMUNICATION_TYPES = [ + (OUTPUT_ONLY, "Output Only"), + (INPUT_ONLY, "Input Only"), + ] + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) code = models.CharField(max_length=255, unique=True, db_index=True) name = models.CharField(max_length=255, unique=True, db_index=True) description = models.TextField(blank=True, default="") + device_communication_type = models.CharField( + max_length=32, + choices=DEVICE_COMMUNICATION_TYPES, + default=OUTPUT_ONLY, + db_index=True, + ) customizable_fields = models.JSONField(default=list, blank=True) supported_power_sources = models.JSONField(default=list, blank=True) returned_data_fields = models.JSONField(default=list, blank=True) + payload_mapping = models.JSONField(default=dict, blank=True) + display_schema = models.JSONField(default=dict, blank=True) + supported_widgets = models.JSONField(default=list, blank=True) + commands_schema = models.JSONField(default=list, blank=True) + capabilities = models.JSONField(default=list, blank=True) sample_payload = models.JSONField(default=dict, blank=True) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) @@ -24,16 +42,21 @@ class SensorCatalog(models.Model): return self.name -class FarmSensor(models.Model): +class FarmDevice(models.Model): uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) farm = models.ForeignKey("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="sensors") sensor_catalog = models.ForeignKey( - SensorCatalog, + DeviceCatalog, on_delete=models.PROTECT, - related_name="farm_sensors", + related_name="farm_devices", null=True, blank=True, ) + device_catalogs = models.ManyToManyField( + DeviceCatalog, + related_name="composite_farm_devices", + blank=True, + ) physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True) name = models.CharField(max_length=255) sensor_type = models.CharField(max_length=255, blank=True, default="") @@ -50,6 +73,23 @@ class FarmSensor(models.Model): def __str__(self): return f"{self.name} ({self.uuid})" + def get_device_catalogs(self): + catalogs = list(self.device_catalogs.all()) + if catalogs: + return catalogs + if self.sensor_catalog_id: + return [self.sensor_catalog] + return [] + + def get_device_catalog_by_code(self, code): + if not code: + return None + normalized_code = str(code).strip().lower() + for catalog in self.get_device_catalogs(): + if catalog.code.lower() == normalized_code: + return catalog + return None + class SensorExternalRequestLog(models.Model): farm_uuid = models.UUIDField(db_index=True) @@ -64,4 +104,3 @@ class SensorExternalRequestLog(models.Model): def __str__(self): return f"{self.physical_device_uuid}:{self.created_at.isoformat()}" - diff --git a/device_hub/seeds.py b/device_hub/seeds.py index 693d8af..a7851b2 100644 --- a/device_hub/seeds.py +++ b/device_hub/seeds.py @@ -6,7 +6,7 @@ from django.utils import timezone from farm_hub.seeds import seed_admin_farm -from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog +from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1" @@ -23,11 +23,12 @@ SENSOR_7_IN_1_LOG_SERIES = [ def seed_sensor_7_in_1_catalog(): - sensor_catalog, created = SensorCatalog.objects.update_or_create( + sensor_catalog, created = DeviceCatalog.objects.update_or_create( code=SENSOR_7_IN_1_CATALOG_CODE, defaults={ "name": "Sensor 7 in 1 Soil Sensor", "description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.", + "device_communication_type": "output_only", "customizable_fields": [], "supported_power_sources": ["solar", "battery", "direct_power"], "returned_data_fields": ["soil_moisture", "soil_temperature", "soil_ph", "electrical_conductivity", "nitrogen", "phosphorus", "potassium"], @@ -42,7 +43,7 @@ def seed_sensor_7_in_1_catalog(): def seed_sensor_7_in_1_demo_data(): farm, _ = seed_admin_farm() sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog() - sensor, sensor_created = FarmSensor.objects.update_or_create( + sensor, sensor_created = FarmDevice.objects.update_or_create( farm=farm, physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID, defaults={"sensor_catalog": sensor_catalog, "name": "Sensor 7 in 1 Demo", "sensor_type": "soil_7_in_1", "is_active": True, "specifications": {"capabilities": sensor_catalog.returned_data_fields, "demo_seed": True}, "power_source": {"type": "solar"}}, diff --git a/device_hub/sensor_catalog_urls.py b/device_hub/sensor_catalog_urls.py index 724e395..7c5747d 100644 --- a/device_hub/sensor_catalog_urls.py +++ b/device_hub/sensor_catalog_urls.py @@ -1,8 +1,7 @@ from django.urls import path -from .views import SensorCatalogListView +from .views import DeviceCatalogListView urlpatterns = [ - path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), + path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"), ] - diff --git a/device_hub/sensor_serializers.py b/device_hub/sensor_serializers.py index f836ecc..e3da0d8 100644 --- a/device_hub/sensor_serializers.py +++ b/device_hub/sensor_serializers.py @@ -77,3 +77,35 @@ class Sensor7In1SummarySerializer(serializers.Serializer): anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) + +class DeviceMetaSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + physicalDeviceUuid = serializers.CharField(required=False, allow_null=True) + sensorCatalogCode = serializers.CharField(required=False, allow_blank=True) + updatedAt = serializers.CharField(required=False, allow_null=True) + + +class DeviceFieldValueSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + trendNumber = serializers.FloatField(required=False) + trend = serializers.CharField(required=False, allow_blank=True) + unit = serializers.CharField(required=False, allow_blank=True) + + +class DeviceValuesListSerializer(serializers.Serializer): + sensor = DeviceMetaSerializer(required=False) + sensors = DeviceFieldValueSerializer(many=True, required=False) + + +class DeviceSummarySerializer(serializers.Serializer): + sensor = DeviceMetaSerializer(required=False) + supportedWidgets = serializers.ListField(child=serializers.CharField(), required=False) + sensorValuesList = DeviceValuesListSerializer(required=False) + avgSoilMoisture = SoilKpiSerializer(required=False) + sensorRadarChart = SoilRadarChartSerializer(required=False) + sensorComparisonChart = SoilComparisonChartSerializer(required=False) + anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) + soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) + commands = serializers.ListField(child=serializers.JSONField(), required=False) diff --git a/device_hub/serializers.py b/device_hub/serializers.py index a90c0d2..a562f20 100644 --- a/device_hub/serializers.py +++ b/device_hub/serializers.py @@ -1,19 +1,25 @@ from rest_framework import serializers -from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog +from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog -class SensorCatalogSerializer(serializers.ModelSerializer): +class DeviceCatalogSerializer(serializers.ModelSerializer): class Meta: - model = SensorCatalog + model = DeviceCatalog fields = [ "uuid", "code", "name", "description", + "device_communication_type", "customizable_fields", "supported_power_sources", "returned_data_fields", + "payload_mapping", + "display_schema", + "supported_widgets", + "commands_schema", + "capabilities", "sample_payload", "is_active", ] @@ -42,14 +48,16 @@ class SensorExternalRequestLogQuerySerializer(serializers.Serializer): return attrs -class FarmSensorLogSerializer(serializers.ModelSerializer): +class FarmDeviceLogSerializer(serializers.ModelSerializer): sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) + device_catalogs = DeviceCatalogSerializer(many=True, read_only=True) class Meta: - model = FarmSensor + model = FarmDevice fields = [ "uuid", "sensor_catalog_uuid", + "device_catalogs", "physical_device_uuid", "name", "sensor_type", @@ -61,17 +69,23 @@ class FarmSensorLogSerializer(serializers.ModelSerializer): ] -class SensorCatalogLogSerializer(serializers.ModelSerializer): +class DeviceCatalogLogSerializer(serializers.ModelSerializer): class Meta: - model = SensorCatalog + model = DeviceCatalog fields = [ "uuid", "code", "name", "description", + "device_communication_type", "customizable_fields", "supported_power_sources", "returned_data_fields", + "payload_mapping", + "display_schema", + "supported_widgets", + "commands_schema", + "capabilities", "sample_payload", "is_active", "created_at", @@ -79,8 +93,70 @@ class SensorCatalogLogSerializer(serializers.ModelSerializer): ] +class DeviceDetailSerializer(serializers.ModelSerializer): + device_catalog = DeviceCatalogSerializer(source="sensor_catalog", read_only=True) + device_catalogs = serializers.SerializerMethodField() + last_payload_at = serializers.SerializerMethodField() + + class Meta: + model = FarmDevice + fields = [ + "uuid", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "device_catalog", + "device_catalogs", + "last_payload_at", + "created_at", + "updated_at", + ] + + def get_last_payload_at(self, obj): + latest_log = self.context.get("latest_log") + if latest_log is None: + return None + return latest_log.created_at + + def get_device_catalogs(self, obj): + return DeviceCatalogSerializer(obj.get_device_catalogs(), many=True).data + + +class DeviceLatestPayloadSerializer(serializers.Serializer): + physical_device_uuid = serializers.UUIDField() + device_code = serializers.CharField() + device_catalog_code = serializers.CharField(allow_blank=True, allow_null=True) + raw_payload = serializers.JSONField() + normalized_payload = serializers.JSONField() + readings = serializers.JSONField() + created_at = serializers.DateTimeField(allow_null=True) + + +class DeviceCommandRequestSerializer(serializers.Serializer): + device_code = serializers.CharField() + command = serializers.CharField() + payload = serializers.JSONField(required=False, default=dict) + + +class DeviceCodeQuerySerializer(serializers.Serializer): + device_code = serializers.CharField() + + +class DeviceRangeQuerySerializer(DeviceCodeQuerySerializer): + range = serializers.CharField() + + +class DeviceCommandResponseSerializer(serializers.Serializer): + physical_device_uuid = serializers.UUIDField() + command = serializers.CharField() + status = serializers.CharField() + + class SensorExternalRequestLogSerializer(serializers.ModelSerializer): - farm_sensor = serializers.SerializerMethodField() + farm_device = serializers.SerializerMethodField() sensor_catalog = serializers.SerializerMethodField() class Meta: @@ -90,26 +166,26 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer): "farm_uuid", "sensor_catalog_uuid", "physical_device_uuid", - "farm_sensor", + "farm_device", "sensor_catalog", "payload", "created_at", ] - def get_farm_sensor(self, obj): - farm_sensor_map = self.context.get("farm_sensor_map", {}) - farm_sensor = farm_sensor_map.get( + def get_farm_device(self, obj): + farm_device_map = self.context.get("farm_device_map", {}) + farm_device = farm_device_map.get( (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) - ) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) - if farm_sensor is None: + ) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) + if farm_device is None: return None - return FarmSensorLogSerializer(farm_sensor).data + return FarmDeviceLogSerializer(farm_device).data def get_sensor_catalog(self, obj): - farm_sensor_map = self.context.get("farm_sensor_map", {}) - farm_sensor = farm_sensor_map.get( + farm_device_map = self.context.get("farm_device_map", {}) + farm_device = farm_device_map.get( (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) - ) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) - if farm_sensor is None or farm_sensor.sensor_catalog is None: + ) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) + if farm_device is None or farm_device.sensor_catalog is None: return None - return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data + return DeviceCatalogLogSerializer(farm_device.sensor_catalog).data diff --git a/device_hub/services.py b/device_hub/services.py index 368c525..1fe6dfb 100644 --- a/device_hub/services.py +++ b/device_hub/services.py @@ -11,7 +11,7 @@ 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 FarmSensor, SensorExternalRequestLog +from .models import FarmDevice, SensorExternalRequestLog logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid if physical_device_uuid: queryset = queryset.filter(physical_device_uuid=physical_device_uuid) if sensor_type: - physical_device_uuids = FarmSensor.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True) + physical_device_uuids = FarmDevice.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True) queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids) if date_from: queryset = queryset.filter(created_at__date__gte=date_from) @@ -57,22 +57,22 @@ def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid raise ValueError("Sensor external API tables are not migrated.") from exc -def get_farm_sensor_map_for_logs(*, logs): +def get_farm_device_map_for_logs(*, logs): try: logs = list(logs) if not logs: return {} - farm_sensor_queryset = FarmSensor.objects.select_related("farm", "sensor_catalog").filter( + farm_device_queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter( farm__farm_uuid__in={log.farm_uuid for log in logs}, physical_device_uuid__in={log.physical_device_uuid for log in logs}, ).order_by("-created_at", "-id") - farm_sensor_map = {} - for farm_sensor in farm_sensor_queryset: - exact_key = (farm_sensor.farm.farm_uuid, farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, farm_sensor.physical_device_uuid) - fallback_key = (farm_sensor.farm.farm_uuid, None, farm_sensor.physical_device_uuid) - farm_sensor_map.setdefault(exact_key, farm_sensor) - farm_sensor_map.setdefault(fallback_key, farm_sensor) - return farm_sensor_map + farm_device_map = {} + for farm_device in farm_device_queryset: + exact_key = (farm_device.farm.farm_uuid, farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None, farm_device.physical_device_uuid) + fallback_key = (farm_device.farm.farm_uuid, None, farm_device.physical_device_uuid) + farm_device_map.setdefault(exact_key, farm_device) + farm_device_map.setdefault(fallback_key, farm_device) + return farm_device_map except (ProgrammingError, OperationalError) as exc: raise ValueError("Sensor external API tables are not migrated.") from exc @@ -86,7 +86,7 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph def create_sensor_external_notification(*, physical_device_uuid, payload=None): payload = payload or {} - sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() + sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() if sensor is None: raise ValueError("Physical device not found.") try: @@ -110,7 +110,7 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None): def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): payload = payload or {} - sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() + sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() if sensor is None: raise ValueError("Physical device not found.") farm_boundary = _get_farm_boundary(sensor=sensor) @@ -273,23 +273,23 @@ def _get_sensor_context(farm=None): if not history: return None latest_log, latest_readings = history[0] - farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log]) - farm_sensor = farm_sensor_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor - return {"farm_sensor": farm_sensor, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history} + 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 + return {"farm_device": farm_device, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history} def _build_sensor_meta(context, fallback_sensor): sensor = deepcopy(fallback_sensor) if not context: return sensor - farm_sensor = context.get("farm_sensor") + farm_device = context.get("farm_device") latest_log = context["latest_log"] sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid) sensor["updatedAt"] = latest_log.created_at.isoformat() - if farm_sensor is not None: - sensor["name"] = farm_sensor.name or sensor["name"] - if farm_sensor.sensor_catalog is not None: - sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code + if farm_device is not None: + sensor["name"] = farm_device.name or sensor["name"] + if farm_device.sensor_catalog is not None: + sensor["sensorCatalogCode"] = farm_device.sensor_catalog.code return sensor @@ -434,9 +434,9 @@ def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None): if not chart_points: return data sensor_name = data["zones"][0] - farm_sensor = context.get("farm_sensor") - if farm_sensor is not None and farm_sensor.name: - sensor_name = farm_sensor.name + 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}] @@ -557,3 +557,496 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): current_data.append(round(current_value, 2)) ideal_data.append(round(ideal_value, 2)) return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]} + + +DEVICE_COMMAND_PAYLOAD_TYPES = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "object": dict, + "array": list, +} +DEFAULT_DEVICE_WIDGETS = [ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload", + "anomaly_card", + "soil_moisture_heatmap", +] + + +def get_farm_device_by_physical_uuid(*, physical_device_uuid, owner=None): + queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid) + if owner is not None: + queryset = queryset.filter(farm__owner=owner) + return queryset.first() + + +def get_device_catalog_for_farm_device(farm_device, *, device_code=None): + if farm_device is None: + return None + if device_code: + return farm_device.get_device_catalog_by_code(device_code) + return farm_device.sensor_catalog if farm_device.sensor_catalog_id else (farm_device.get_device_catalogs()[0] if farm_device.get_device_catalogs() else None) + + +def get_latest_device_log(farm_device, *, device_catalog=None): + if farm_device is None: + return None + return get_latest_sensor_external_request_log( + farm_uuid=farm_device.farm.farm_uuid, + sensor_catalog_uuid=device_catalog.uuid if device_catalog else (farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None), + physical_device_uuid=farm_device.physical_device_uuid, + ) + + +def get_device_logs(farm_device, *, range_value=None, date_from=None, date_to=None): + if farm_device is None: + return SensorExternalRequestLog.objects.none() + if range_value: + date_from = timezone.localdate() - timedelta(days=max(range_value - 1, 0)) + return get_sensor_external_request_logs_for_farm( + farm_uuid=farm_device.farm.farm_uuid, + physical_device_uuid=farm_device.physical_device_uuid, + date_from=date_from, + date_to=date_to, + ) + + +def validate_output_device_catalog(*, farm_device, device_code): + device_catalog = get_device_catalog_for_farm_device(farm_device, device_code=device_code) + if device_catalog is None: + raise ValueError("Device code is not attached to this farm device.") + if device_catalog.device_communication_type == "input_only": + raise ValueError("Selected device code is input-only and cannot be used for output data endpoints.") + return device_catalog + + +def _get_default_field_definition_map(): + return {field["id"]: field for field in SENSOR_FIELDS} + + +def _normalize_payload_keys(payload_keys): + if isinstance(payload_keys, str): + return [payload_keys] + if isinstance(payload_keys, (list, tuple)): + return [item for item in payload_keys if isinstance(item, str) and item] + return [] + + +def _get_device_field_definitions(device_catalog): + default_field_map = _get_default_field_definition_map() + if device_catalog is None: + return list(default_field_map.values()) + + payload_mapping = device_catalog.payload_mapping if isinstance(device_catalog.payload_mapping, dict) else {} + display_schema = device_catalog.display_schema if isinstance(device_catalog.display_schema, dict) else {} + display_fields = display_schema.get("fields", []) if isinstance(display_schema.get("fields", []), list) else [] + + ordered_ids = [] + for item in display_fields: + if isinstance(item, dict) and item.get("id"): + ordered_ids.append(item["id"]) + for item in device_catalog.returned_data_fields: + if isinstance(item, str) and item not in ordered_ids: + ordered_ids.append(item) + for item in payload_mapping.keys(): + if item not in ordered_ids: + ordered_ids.append(item) + if not ordered_ids: + ordered_ids = list(default_field_map.keys()) + + display_field_map = { + item["id"]: item for item in display_fields if isinstance(item, dict) and item.get("id") + } + field_definitions = [] + for field_id in ordered_ids: + default_field = default_field_map.get(field_id, {}) + display_field = display_field_map.get(field_id, {}) + payload_keys = _normalize_payload_keys(payload_mapping.get(field_id)) or list(default_field.get("payload_keys", [])) or [field_id] + field_definitions.append( + { + "id": field_id, + "label": display_field.get("label") or default_field.get("label") or field_id.replace("_", " ").title(), + "unit": display_field.get("unit") or default_field.get("unit") or "", + "payload_keys": payload_keys, + "ideal_min": display_field.get("ideal_min", default_field.get("ideal_min", 0.0)), + "ideal_max": display_field.get("ideal_max", default_field.get("ideal_max", 100.0)), + "radar_label": display_field.get("radar_label") or default_field.get("radar_label") or display_field.get("label") or default_field.get("label") or field_id, + } + ) + return field_definitions + + +def _extract_payload_with_field_definitions(payload, field_definitions): + if not isinstance(payload, dict): + return {} + if isinstance(payload.get("payload"), dict): + payload = payload["payload"] + expected_keys = {key for field in field_definitions for key in field.get("payload_keys", [])} + if isinstance(payload.get("data"), dict): + nested = payload["data"] + if not expected_keys or any(key in nested for key in expected_keys): + payload = nested + return payload + + +def normalize_device_payload(device_catalog, payload): + field_definitions = _get_device_field_definitions(device_catalog) + payload = _extract_payload_with_field_definitions(payload, field_definitions) + normalized_payload = {} + for field in field_definitions: + for key in field["payload_keys"]: + if key in payload: + normalized_payload[field["id"]] = payload[key] + break + return normalized_payload + + +def extract_device_readings(device_catalog, payload): + normalized_payload = normalize_device_payload(device_catalog, payload) + readings = {} + for key, value in normalized_payload.items(): + numeric_value = _to_float(value) + if numeric_value is not None: + readings[key] = numeric_value + return readings + + +def _get_device_supported_widgets(device_catalog): + if device_catalog is None: + return list(DEFAULT_DEVICE_WIDGETS) + widgets = device_catalog.supported_widgets if isinstance(device_catalog.supported_widgets, list) else [] + if widgets: + return widgets + if device_catalog.device_communication_type == "input_only": + return [] + return list(DEFAULT_DEVICE_WIDGETS) + + +def _get_device_history_context(farm_device): + if farm_device is None: + return None + try: + logs_queryset = get_device_logs(farm_device) + except ValueError: + return None + history = [] + device_catalog = get_device_catalog_for_farm_device(farm_device) + for log in logs_queryset[:MAX_HISTORY_ITEMS]: + readings = extract_device_readings(device_catalog, log.payload) + normalized_payload = normalize_device_payload(device_catalog, log.payload) + 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": [], + } + latest_log, latest_readings, latest_payload = history[0] + return { + "farm_device": farm_device, + "latest_log": latest_log, + "latest_readings": latest_readings, + "latest_payload": latest_payload, + "previous_readings": history[1][1] if len(history) > 1 else {}, + "history": history, + } + + +def build_device_meta(farm_device, context=None, *, device_catalog=None): + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + latest_log = (context or {}).get("latest_log") + return { + "name": farm_device.name if farm_device else "", + "physicalDeviceUuid": str(farm_device.physical_device_uuid) if farm_device else None, + "sensorCatalogCode": device_catalog.code if device_catalog else "", + "updatedAt": latest_log.created_at.isoformat() if latest_log else None, + } + + +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, + } + 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": latest_log.payload, + "normalized_payload": normalize_device_payload(device_catalog, latest_log.payload), + "readings": extract_device_readings(device_catalog, latest_log.payload), + "created_at": latest_log.created_at, + } + + +def build_device_values_list(farm_device, range_value, *, device_code): + try: + logs_queryset = get_device_logs(farm_device) + except ValueError: + return {"sensors": []} + 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": []} + logs = [latest_log] + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + earliest_payload = {} + latest_payload = {} + for log in logs: + normalized_payload = extract_device_readings(device_catalog, log.payload) + if not normalized_payload: + continue + if not earliest_payload: + earliest_payload = normalized_payload + latest_payload = normalized_payload + if not latest_payload: + return {"sensors": []} + sensors = [] + for field in _get_device_field_definitions(device_catalog): + current_value = latest_payload.get(field["id"]) + if current_value is None: + continue + previous_value = earliest_payload.get(field["id"], current_value) + delta = round(current_value - previous_value, 2) + sensors.append( + { + "title": field["label"], + "subtitle": _format_current_value_subtitle(field["label"], current_value, field["unit"]), + "trendNumber": abs(delta), + "trend": "positive" if delta >= 0 else "negative", + "unit": field["unit"], + } + ) + return {"sensors": sensors} + + +def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + data = {"sensor": build_device_meta(farm_device, context), "sensors": []} + latest_readings = context.get("latest_readings", {}) if context else {} + previous_readings = context.get("previous_readings", {}) if context else {} + for field in _get_device_field_definitions(device_catalog): + 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) + 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"], + } + ) + return data + + +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": []} + labels, current_data, ideal_data = [], [], [] + for field in _get_device_field_definitions(device_catalog): + current_value = context["latest_readings"].get(field["id"]) + if current_value is None: + continue + labels.append(field["radar_label"]) + 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}]} + + +def build_device_comparison_chart(farm_device, range_value, *, device_code): + days = COMPARISON_CHART_RANGES[range_value] + 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%"} + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + field_definitions = _get_device_field_definitions(device_catalog) + grouped_logs = {} + for log in reversed(list(logs_queryset[: days * 24])): + 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%"} + sorted_dates = sorted(grouped_logs.keys()) + categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] + series = [] + primary_data = [] + for field in field_definitions: + data_points = [] + for bucket_date in sorted_dates: + value = grouped_logs[bucket_date].get(field["id"]) + if value is None: + data_points.append(0.0) + else: + data_points.append(round(value, 2)) + if any(point != 0.0 for point in data_points): + series.append({"name": field["label"], "data": data_points}) + if not primary_data: + primary_data = data_points + if not series or not primary_data: + return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + return { + "series": series, + "categories": categories, + "currentValue": round(primary_data[-1], 2), + "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]), + } + + +def build_device_anomaly_detection_card(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + anomalies = [] + latest_readings = context.get("latest_readings", {}) if context else {} + for field in _get_device_field_definitions(device_catalog): + value = latest_readings.get(field["id"]) + if value is None: + continue + anomaly = _build_anomaly_item(field, value) + if anomaly is not None: + anomalies.append(anomaly) + return { + "anomalies": anomalies or [ + { + "sensor": farm_device.name if farm_device else "Device", + "value": "نرمال", + "expected": "تمام شاخص‌ها در بازه مجاز هستند", + "deviation": "0", + "severity": "success", + } + ] + } + + +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 + 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 + chart_points = [] + for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]): + value = readings.get(primary_field["id"]) + if value is None: + 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 + + +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 + primary_value = latest_readings.get(primary_field["id"]) + if primary_value is None: + return data + 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 + + +def build_device_summary(farm_device, *, device_code): + context = _get_device_history_context(farm_device) + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + summary = {"sensor": build_device_meta(farm_device, context, device_catalog=device_catalog), "supportedWidgets": _get_device_supported_widgets(device_catalog)} + if device_catalog and device_catalog.device_communication_type == "input_only": + summary["commands"] = device_catalog.commands_schema if isinstance(device_catalog.commands_schema, list) else [] + return summary + summary["sensorValuesList"] = build_device_summary_values_list(farm_device, context=context, device_catalog=device_catalog) + if "comparison_chart" in summary["supportedWidgets"]: + summary["sensorComparisonChart"] = build_device_comparison_chart(farm_device, "7d", device_code=device_code) + if "radar_chart" in summary["supportedWidgets"]: + summary["sensorRadarChart"] = build_device_radar_chart(farm_device, device_code=device_code) + if "anomaly_card" in summary["supportedWidgets"]: + summary["anomalyDetectionCard"] = build_device_anomaly_detection_card(farm_device, context=context, device_catalog=device_catalog) + if "soil_moisture_heatmap" in summary["supportedWidgets"]: + summary["soilMoistureHeatmap"] = build_device_soil_moisture_heatmap(farm_device, context=context, device_catalog=device_catalog) + summary["avgSoilMoisture"] = build_device_avg_primary_metric(farm_device, context=context, device_catalog=device_catalog) + return summary + + +def validate_device_command(farm_device, command, payload, *, device_code): + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + commands_schema = device_catalog.commands_schema if device_catalog and isinstance(device_catalog.commands_schema, list) else [] + if not commands_schema: + raise ValueError("This device does not support commands.") + matched_command = next( + (item for item in commands_schema if isinstance(item, dict) and item.get("command") == command), + None, + ) + if matched_command is None: + raise ValueError("Command is not supported for this device.") + payload = payload or {} + if not isinstance(payload, dict): + raise ValueError("`payload` must be an object.") + payload_schema = matched_command.get("payload_schema", {}) + if not isinstance(payload_schema, dict): + return matched_command + for key, expected_type in payload_schema.items(): + if key not in payload: + raise ValueError(f"`{key}` is required for this command.") + expected_python_type = DEVICE_COMMAND_PAYLOAD_TYPES.get(expected_type) + if expected_python_type is None: + continue + if expected_type == "integer" and isinstance(payload[key], bool): + raise ValueError(f"`{key}` must be of type {expected_type}.") + if not isinstance(payload[key], expected_python_type): + raise ValueError(f"`{key}` must be of type {expected_type}.") + return matched_command + + +def execute_device_command(*, farm_device, device_code, command, payload=None): + validate_device_command(farm_device, command, payload or {}, device_code=device_code) + return { + "physical_device_uuid": farm_device.physical_device_uuid, + "device_code": device_code, + "command": command, + "status": "queued", + } diff --git a/device_hub/tests.py b/device_hub/tests.py new file mode 100644 index 0000000..7c2585b --- /dev/null +++ b/device_hub/tests.py @@ -0,0 +1,118 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from farm_hub.models import FarmHub, FarmType + +from .models import DeviceCatalog, SensorExternalRequestLog +from .views import DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView + + +class DeviceHubGenericViewsTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="device-user", + password="secret123", + email="device@example.com", + phone_number="09120001000", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Device Farm", + ) + self.catalog = DeviceCatalog.objects.create( + code="soil_sensor_v2", + name="Soil Sensor V2", + device_communication_type=DeviceCatalog.OUTPUT_ONLY, + returned_data_fields=["soil_moisture", "soil_temperature"], + payload_mapping={ + "soil_moisture": ["moisture", "soil_moisture"], + "soil_temperature": ["temperature", "soil_temperature"], + }, + display_schema={ + "fields": [ + {"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "ideal_min": 40, "ideal_max": 70}, + {"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "ideal_min": 18, "ideal_max": 30}, + ] + }, + supported_widgets=["values_list", "comparison_chart", "radar_chart"], + ) + self.device = self.farm.sensors.create( + name="Soil Device 1", + sensor_catalog=self.catalog, + sensor_type="soil", + ) + SensorExternalRequestLog.objects.create( + farm_uuid=self.farm.farm_uuid, + sensor_catalog_uuid=self.catalog.uuid, + physical_device_uuid=self.device.physical_device_uuid, + payload={"moisture": 52.4, "temperature": 23.1}, + ) + + def test_device_detail_view_returns_generic_payload(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceDetailView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["physical_device_uuid"], str(self.device.physical_device_uuid)) + self.assertEqual(response.data["data"]["device_catalog"]["code"], self.catalog.code) + + def test_device_latest_payload_view_returns_normalized_readings(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/latest/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceLatestPayloadView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["normalized_payload"]["soil_moisture"], 52.4) + self.assertEqual(response.data["data"]["readings"]["soil_temperature"], 23.1) + + def test_device_summary_view_returns_supported_widgets(self): + 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, 200) + self.assertIn("values_list", response.data["data"]["supportedWidgets"]) + self.assertIn("sensorValuesList", response.data["data"]) + + def test_input_only_device_command_view_rejects_input_only_device_code(self): + input_catalog = DeviceCatalog.objects.create( + code="valve_v1", + name="Valve V1", + device_communication_type=DeviceCatalog.INPUT_ONLY, + commands_schema=[ + {"command": "open", "label": "Open", "payload_schema": {"duration_seconds": "integer"}}, + ], + ) + input_device = self.farm.sensors.create( + name="Valve 1", + sensor_catalog=input_catalog, + sensor_type="valve", + ) + request = self.factory.post( + f"/api/device-hub/devices/{input_device.physical_device_uuid}/commands/", + {"device_code": input_catalog.code, "command": "open", "payload": {"duration_seconds": 120}}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = DeviceCommandView.as_view()(request, physical_device_uuid=input_device.physical_device_uuid) + + self.assertEqual(response.status_code, 400) + self.assertIn("device_code", response.data) diff --git a/device_hub/urls.py b/device_hub/urls.py index da695ca..dc43f93 100644 --- a/device_hub/urls.py +++ b/device_hub/urls.py @@ -1,11 +1,19 @@ from django.urls import path -from .views import Sensor7In1SummaryView, SensorCatalogListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView +from .views import DeviceCatalogListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, Sensor7In1SummaryView, SensorExternalAPIView, SensorExternalRequestLogListAPIView urlpatterns = [ + path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"), + path("devices//", DeviceDetailView.as_view(), name="device-detail"), + path("devices//latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"), + path("devices//summary/", DeviceSummaryView.as_view(), name="device-summary"), + path("devices//values-list/", DeviceValuesListView.as_view(), name="device-values-list"), + path("devices//comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"), + path("devices//radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), + path("devices//logs/", DeviceLogListView.as_view(), name="device-log-list"), + path("devices//commands/", DeviceCommandView.as_view(), name="device-command"), path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), - path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), + path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"), path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), ] - diff --git a/device_hub/views.py b/device_hub/views.py index 7598c8d..1a1d561 100644 --- a/device_hub/views.py +++ b/device_hub/views.py @@ -11,18 +11,163 @@ from notifications.serializers import FarmNotificationSerializer from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer from .authentication import SensorExternalAPIKeyAuthentication -from .sensor_serializers import Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer -from .serializers import SensorCatalogSerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer -from .services import FarmDataForwardError, create_sensor_external_notification, forward_sensor_payload_to_farm_data, get_farm_sensor_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 +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 -class SensorCatalogListView(APIView): +class DeviceCatalogListView(APIView): permission_classes = [IsAuthenticated] - @extend_schema(tags=["Sensor Catalog"], responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))}) + @extend_schema(tags=["Sensor Catalog"], responses={200: code_response("DeviceCatalogListResponse", data=DeviceCatalogSerializer(many=True))}) def get(self, request): - from .models import SensorCatalog - return Response({"code": 200, "msg": "success", "data": SensorCatalogSerializer(SensorCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK) + from .models import DeviceCatalog + return Response({"code": 200, "msg": "success", "data": DeviceCatalogSerializer(DeviceCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK) + + +class DeviceBaseView(APIView): + permission_classes = [IsAuthenticated] + + def get_farm_device(self, request, physical_device_uuid): + farm_device = get_farm_device_by_physical_uuid(physical_device_uuid=physical_device_uuid, owner=request.user) + if farm_device is None: + raise serializers.ValidationError({"physical_device_uuid": ["Device not found."]}) + return farm_device + + def get_device_code(self, request): + serializer = DeviceCodeQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + return serializer.validated_data["device_code"] + + +class DeviceDetailView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceDetailResponse", data=DeviceDetailSerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + latest_payload = build_device_latest_payload(farm_device, device_code=device_code) + serializer = DeviceDetailSerializer(farm_device, context={"latest_log": type("LatestLog", (), {"created_at": latest_payload["created_at"]})() if latest_payload["created_at"] else None}) + return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK) + + +class DeviceLatestPayloadView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLatestPayloadResponse", data=DeviceLatestPayloadSerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + try: + data = build_device_latest_payload(farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class DeviceSummaryView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceSummaryResponse", data=DeviceSummarySerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + try: + data = build_device_summary(farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class DeviceComparisonChartView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_comparison_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class DeviceValuesListView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_values_list(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class DeviceRadarChartView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_radar_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class DeviceLogListView(DeviceBaseView): + pagination_class = SensorExternalRequestLogPagination + + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)})}) + def get(self, request, physical_device_uuid): + page = request.query_params.get("page", 1) + page_size = request.query_params.get("page_size", 20) + device_code = request.query_params.get("device_code") + serializer = SensorExternalRequestLogQuerySerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "page": page, + "page_size": page_size, + "physical_device_uuid": physical_device_uuid, + } + ) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + queryset = get_sensor_external_request_logs_for_farm( + farm_uuid=farm_device.farm.farm_uuid, + physical_device_uuid=farm_device.physical_device_uuid, + ) + queryset = queryset.filter(sensor_catalog_uuid=device_catalog.uuid) + paginator = self.pagination_class() + paginator.page_size = serializer.validated_data["page_size"] + page_obj = paginator.paginate_queryset(queryset, request, view=self) + farm_device_map = get_farm_device_map_for_logs(logs=page_obj) + data = SensorExternalRequestLogSerializer(page_obj, many=True, context={"farm_device_map": farm_device_map}).data + return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK) + + +class DeviceCommandView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], request=DeviceCommandRequestSerializer, responses={200: code_response("DeviceCommandResponse", data=DeviceCommandResponseSerializer())}) + def post(self, request, physical_device_uuid): + serializer = DeviceCommandRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + device_catalog = farm_device.get_device_catalog_by_code(serializer.validated_data["device_code"]) + if device_catalog is None: + raise ValueError("Device code is not attached to this farm device.") + result = execute_device_command( + farm_device=farm_device, + device_code=serializer.validated_data["device_code"], + command=serializer.validated_data["command"], + payload=serializer.validated_data.get("payload"), + ) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "command accepted", "data": result}, status=status.HTTP_200_OK) class Sensor7In1SummaryView(APIView): @@ -137,6 +282,6 @@ class SensorExternalRequestLogListAPIView(APIView): paginator = self.pagination_class() paginator.page_size = serializer.validated_data["page_size"] page = paginator.paginate_queryset(queryset, request, view=self) - farm_sensor_map = get_farm_sensor_map_for_logs(logs=page) - data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_sensor_map": farm_sensor_map}).data + farm_device_map = get_farm_device_map_for_logs(logs=page) + data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_device_map": farm_device_map}).data return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK) diff --git a/docs/device_catalog_dynamic_architecture.md b/docs/device_catalog_dynamic_architecture.md new file mode 100644 index 0000000..cad0068 --- /dev/null +++ b/docs/device_catalog_dynamic_architecture.md @@ -0,0 +1,706 @@ +# راهنمای طراحی Device Catalog داینامیک + +## هدف + +هدف این تغییر این است که اضافه کردن یک دیوایس جدید فقط با ثبت اطلاعات در دیتابیس یا پنل ادمین انجام شود و برای هر دیوایس جدید نیازی به اضافه کردن فایل، ویو، serializer یا service جدید در کد نباشد. + +الان ساختار پروژه برای بعضی دیوایس‌ها device-specific است؛ مثلا: + +- `device_hub/sensor_7_in_1_urls.py` +- `Sensor7In1SummaryView` +- `get_sensor_7_in_1_summary_data` +- `get_sensor_7_in_1_radar_chart_data` +- `get_sensor_7_in_1_comparison_chart_data` + +این ساختار برای یک MVP خوب است، ولی برای scale شدن مناسب نیست. چون برای هر دیوایس جدید باید: + +- route جدید بسازید +- view جدید بسازید +- serializer جدید بسازید +- service جدید بسازید +- منطق mapping payload جدید اضافه کنید + +این دقیقا چیزی است که باید حذف شود. + +--- + +## مشکل ساختار فعلی + +الان backend تا حدی بر اساس `device type` یا `sensor-7-in-1` branch می‌زند، نه بر اساس یک configuration عمومی. + +نمونه‌ها: + +- `device_hub/views.py` + - `Sensor7In1SummaryView` + - `Sensor7In1RadarChartView` + - `Sensor7In1ComparisonChartView` +- `device_hub/services.py` + - `get_primary_soil_sensor` + - `get_sensor_7_in_1_summary_data` + - `get_sensor_7_in_1_values_list_data` + - `get_sensor_7_in_1_radar_chart_data` + - `get_sensor_7_in_1_comparison_chart_data` +- `device_hub/sensor_serializers.py` + - `Sensor7In1SummarySerializer` + - `Sensor7In1MetaSerializer` + +مشکل این approach: + +1. اضافه شدن هر device جدید نیاز به deploy کد دارد. +2. naming پروژه به device خاص وابسته می‌شود. +3. APIها generic نیستند. +4. frontend مجبور می‌شود endpointهای مخصوص هر device را صدا بزند. +5. منطق business به‌جای data-driven بودن، hard-coded شده است. + +--- + +## معماری پیشنهادی + +### اصل طراحی + +به‌جای این‌که برای هر device endpoint جدا داشته باشیم، باید فقط یک سری endpoint عمومی داشته باشیم که بر اساس: + +- `physical_device_uuid` +یا +- `device_catalog_uuid` +یا +- `device_catalog.code` + +اطلاعات همان device را برگردانند. + +یعنی backend باید: + +1. device را پیدا کند +2. configuration آن device را از catalog بخواند +3. payload mapping آن device را بخواند +4. widgetهای قابل نمایش آن را تشخیص دهد +5. خروجی استاندارد بسازد + +--- + +## APIهای پیشنهادی + +### 1) لیست دیوایس‌ها + +```http +GET /api/device-hub/catalog/ +``` + +کاربرد: + +- لیست همه device catalogها +- metadata هر catalog +- نوع ارتباط device +- فیلدهای قابل نمایش + +--- + +### 2) جزئیات یک دیوایس ثبت‌شده روی مزرعه + +```http +GET /api/device-hub/devices/{physical_device_uuid}/ +``` + +پاسخ نمونه: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "uuid": "farm-device-uuid", + "physical_device_uuid": "device-uuid", + "name": "Soil Sensor #1", + "device_catalog": { + "uuid": "catalog-uuid", + "code": "soil_sensor_v1", + "name": "Soil Sensor V1", + "device_communication_type": "output_only" + }, + "specifications": {}, + "power_source": {}, + "last_payload_at": "2025-01-01T10:00:00Z" + } +} +``` + +--- + +### 3) آخرین داده‌ی یک device + +```http +GET /api/device-hub/devices/{physical_device_uuid}/latest/ +``` + +کاربرد: + +- آخرین payload خام +- آخرین payload نرمال‌شده +- آخرین readingهای قابل نمایش + +--- + +### 4) summary داینامیک برای یک device + +```http +GET /api/device-hub/devices/{physical_device_uuid}/summary/ +``` + +کاربرد: + +- به‌جای `sensor_7_in_1/summary` +- خروجی بر اساس config همان device + +--- + +### 5) نمودار مقایسه‌ای داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/comparison-chart/?range=7d +``` + +--- + +### 6) نمودار رادار داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/radar-chart/?range=7d +``` + +--- + +### 7) values list داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/values-list/?range=7d +``` + +--- + +### 8) دریافت history خام + +```http +GET /api/device-hub/devices/{physical_device_uuid}/logs/?page=1&page_size=20 +``` + +این endpoint برای debug و audit خیلی مهم است. + +--- + +## تغییر مهم در مدل‌ها + +### 1) `DeviceCatalog` + +الان این مدل شروع خوبی دارد، ولی برای dynamic شدن کافی نیست. + +مدل فعلی در: + +- `device_hub/models.py:6` + +فیلدهای پیشنهادی جدید: + +```python +display_schema = models.JSONField(default=dict, blank=True) +payload_mapping = models.JSONField(default=dict, blank=True) +supported_widgets = models.JSONField(default=list, blank=True) +commands_schema = models.JSONField(default=list, blank=True) +capabilities = models.JSONField(default=list, blank=True) +``` + +### توضیح هر فیلد + +#### `payload_mapping` + +مشخص می‌کند payload خام این device چطور به فیلدهای استاندارد سیستم map شود. + +مثال: + +```json +{ + "soil_moisture": ["soil_moisture", "soilMoisture", "moisture"], + "soil_temperature": ["soil_temperature", "soilTemperature", "temperature"], + "soil_ph": ["soil_ph", "soilPh", "ph"] +} +``` + +#### `display_schema` + +مشخص می‌کند کدام فیلدها در UI نمایش داده شوند و label و unit آن‌ها چیست. + +مثال: + +```json +{ + "fields": [ + { + "id": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + "ideal_min": 45, + "ideal_max": 65 + }, + { + "id": "soil_temperature", + "label": "دمای خاک", + "unit": "°C", + "ideal_min": 18, + "ideal_max": 28 + } + ] +} +``` + +#### `supported_widgets` + +مشخص می‌کند برای این device چه widgetهایی فعال باشند. + +مثال: + +```json +[ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload", + "anomaly_card" +] +``` + +#### `commands_schema` + +برای deviceهایی که `input_only` هستند. + +مثال: + +```json +[ + { + "command": "turn_on", + "label": "روشن کردن", + "payload_schema": { + "duration_seconds": "integer" + } + }, + { + "command": "turn_off", + "label": "خاموش کردن", + "payload_schema": {} + } +] +``` + +#### `capabilities` + +فهرست capabilityهای device: + +```json +["measure", "history", "alert", "command"] +``` + +--- + +## برای deviceهای ورودی‌محور + +شما گفتی بعضی deviceها فقط باید دستور بگیرند و خروجی نمی‌دهند. این دقیقا باید در مدل و API مشخص باشد. + +برای این نوع device: + +- `device_communication_type = "input_only"` +- `returned_data_fields = []` +- `supported_widgets = []` +- `commands_schema` باید پر باشد + +API پیشنهادی: + +```http +POST /api/device-hub/devices/{physical_device_uuid}/commands/ +``` + +payload نمونه: + +```json +{ + "command": "turn_on", + "payload": { + "duration_seconds": 120 + } +} +``` + +پاسخ نمونه: + +```json +{ + "code": 200, + "msg": "command accepted", + "data": { + "physical_device_uuid": "device-uuid", + "command": "turn_on", + "status": "queued" + } +} +``` + +--- + +## چه جاهایی باید در پروژه تغییر کند + +### 1) حذف وابستگی به `sensor_7_in_1` + +#### فایل‌هایی که باید refactor شوند + +- `device_hub/views.py` +- `device_hub/services.py` +- `device_hub/sensor_serializers.py` +- `device_hub/sensor_7_in_1_urls.py` +- `device_hub/comparison_urls.py` +- `device_hub/urls.py` + +#### چه چیزی باید تغییر کند + +- viewهای device-specific حذف شوند +- routeهای generic جایگزین شوند +- serviceهای `get_sensor_7_in_1_*` به serviceهای generic تبدیل شوند + +--- + +### 2) ساخت service عمومی برای پیدا کردن device + +در `device_hub/services.py` باید این لایه‌ها ایجاد شود: + +#### الف) resolver + +```python +get_farm_device_by_physical_uuid(physical_device_uuid) +get_device_catalog_for_farm_device(farm_device) +get_latest_device_log(farm_device) +get_device_logs(farm_device, range_value=None) +``` + +#### ب) normalizer + +```python +normalize_device_payload(device_catalog, payload) +extract_device_readings(device_catalog, payload) +``` + +این بخش باید از `payload_mapping` استفاده کند، نه از `SENSOR_FIELDS` ثابت. + +#### ج) presenter / builder + +```python +build_device_summary(farm_device) +build_device_values_list(farm_device, range_value) +build_device_comparison_chart(farm_device, range_value) +build_device_radar_chart(farm_device, range_value) +``` + +--- + +### 3) ثابت‌های hard-coded باید از کد خارج شوند + +الان این موارد hard-coded هستند: + +- `SENSOR_FIELDS` +- `COMPARISON_CHART_FIELD_ALIASES` +- `VALUES_LIST_FIELDS` +- `RADAR_CHART_FIELDS` + +این‌ها الان در: + +- `device_hub/services.py:16` + +هستند و باید به config وابسته به `DeviceCatalog` منتقل شوند. + +یعنی: + +- به‌جای constant سراسری +- از `device_catalog.display_schema` +- و `device_catalog.payload_mapping` + +استفاده شود. + +--- + +### 4) serializerهای اختصاصی باید generic شوند + +الان در: + +- `device_hub/sensor_serializers.py:6` + +serializerها مخصوص 7-in-1 هستند. + +باید این‌ها جایگزین شوند: + +- `DeviceMetaSerializer` +- `DeviceFieldValueSerializer` +- `DeviceValuesListSerializer` +- `DeviceSummarySerializer` +- `DeviceComparisonChartSerializer` +- `DeviceRadarChartSerializer` + +یعنی نام serializer نباید به یک device خاص گره خورده باشد. + +--- + +### 5) endpointهای generic بسازید + +در `device_hub/urls.py` بهتر است چیزی شبیه این داشته باشید: + +```python +urlpatterns = [ + path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"), + path("devices//", DeviceDetailView.as_view(), name="device-detail"), + path("devices//latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"), + path("devices//summary/", DeviceSummaryView.as_view(), name="device-summary"), + path("devices//values-list/", DeviceValuesListView.as_view(), name="device-values-list"), + path("devices//comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"), + path("devices//radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), + path("devices//logs/", DeviceLogListView.as_view(), name="device-log-list"), + path("devices//commands/", DeviceCommandView.as_view(), name="device-command"), + path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), +] +``` + +--- + +## روند اضافه کردن device جدید بدون تغییر کد + +بعد از این refactor، اضافه کردن device جدید باید این‌طوری باشد: + +### مرحله 1 + +یک رکورد جدید در `DeviceCatalog` ایجاد شود. + +### مرحله 2 + +این اطلاعات برایش ثبت شود: + +- `code` +- `name` +- `device_communication_type` +- `payload_mapping` +- `display_schema` +- `supported_widgets` +- `commands_schema` + +### مرحله 3 + +هنگام ثبت `FarmDevice`، آن device به همین catalog وصل شود. + +### مرحله 4 + +از این به بعد frontend فقط با `physical_device_uuid` به endpointهای generic می‌زند. + +بدون تغییر کد. + +--- + +## نمونه config برای یک سنسور خروجی‌محور + +```json +{ + "code": "soil_sensor_v2", + "name": "Soil Sensor V2", + "device_communication_type": "output_only", + "returned_data_fields": [ + "soil_moisture", + "soil_temperature", + "soil_ph" + ], + "payload_mapping": { + "soil_moisture": ["moisture", "soil_moisture"], + "soil_temperature": ["temperature", "soil_temperature"], + "soil_ph": ["ph", "soil_ph"] + }, + "display_schema": { + "fields": [ + { + "id": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + "ideal_min": 45, + "ideal_max": 65 + }, + { + "id": "soil_temperature", + "label": "دمای خاک", + "unit": "°C", + "ideal_min": 18, + "ideal_max": 28 + }, + { + "id": "soil_ph", + "label": "PH خاک", + "unit": "pH", + "ideal_min": 6, + "ideal_max": 7.5 + } + ] + }, + "supported_widgets": [ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload" + ], + "commands_schema": [] +} +``` + +--- + +## نمونه config برای یک device فقط ورودی + +مثلا شیر برقی یا پمپ: + +```json +{ + "code": "irrigation_valve_v1", + "name": "Irrigation Valve V1", + "device_communication_type": "input_only", + "returned_data_fields": [], + "payload_mapping": {}, + "display_schema": { + "fields": [] + }, + "supported_widgets": [], + "commands_schema": [ + { + "command": "open", + "label": "باز کردن شیر", + "payload_schema": { + "duration_seconds": "integer" + } + }, + { + "command": "close", + "label": "بستن شیر", + "payload_schema": {} + } + ] +} +``` + +--- + +## پیشنهاد مرحله‌بندی پیاده‌سازی + +### فاز 1: Generic read API + +اول این‌ها را بسازید: + +- `DeviceDetailView` +- `DeviceLatestPayloadView` +- `DeviceSummaryView` +- `DeviceValuesListView` +- `DeviceComparisonChartView` +- `DeviceRadarChartView` + +و فعلا داده را با fallback از منطق فعلی بسازید. + +### فاز 2: Config-driven normalization + +بعد: + +- `payload_mapping` +- `display_schema` +- `supported_widgets` + +را به `DeviceCatalog` اضافه کنید و منطق hard-coded را حذف کنید. + +### فاز 3: Command API + +برای `input_only` deviceها: + +- `DeviceCommandView` +- command validation +- queue / external broker integration + +### فاز 4: Admin / CMS support + +برای اینکه بدون کد device جدید اضافه شود، باید از طریق: + +- Django Admin +یا +- پنل داخلی + +بتوانید `DeviceCatalog` را مدیریت کنید. + +--- + +## حداقل تغییر‌هایی که همین الان باید انجام بدهید + +اگر بخواهی با کمترین تغییر از ساختار فعلی به ساختار بهتر برسی، این‌ها مهم‌ترین کارها هستند: + +### ضروری + +1. حذف endpointهای `sensor_7_in_1`-محور +2. ساخت endpointهای generic با `physical_device_uuid` +3. جدا کردن منطق extraction از device-specific code +4. انتقال field mapping از constant به دیتابیس +5. اضافه کردن schema برای commandها + +### مهم ولی فاز بعدی + +1. admin برای `DeviceCatalog` +2. validation قوی برای `payload_mapping` +3. caching برای summary/chartها +4. swagger dynamic docs برای command schema + +--- + +## جمع‌بندی + +اگر هدفت این است که: + +- device جدید بدون تغییر کد اضافه شود +- frontend فقط با `device_uuid` کار کند +- بعضی deviceها فقط command بگیرند +- بعضی deviceها telemetry بدهند + +پس باید طراحی از: + +- `device-specific code` + +به این مدل تغییر کند: + +- `catalog-driven architecture` + +یعنی: + +- `DeviceCatalog` منبع حقیقت باشد +- APIها generic باشند +- parsing و rendering بر اساس config انجام شود +- commandها هم از schema خود device خوانده شوند + +--- + +## فایل‌های کلیدی برای refactor + +- `device_hub/models.py:6` +- `device_hub/views.py:19` +- `device_hub/services.py:16` +- `device_hub/sensor_serializers.py:1` +- `device_hub/urls.py:1` +- `device_hub/sensor_7_in_1_urls.py:1` +- `device_hub/comparison_urls.py:1` +- `device_hub/seeds.py:12` + +--- + +## پیشنهاد نهایی + +بهترین مسیر این است که: + +1. endpointهای generic را اضافه کنی +2. endpointهای قدیمی `sensor_7_in_1` را deprecated کنی +3. config مورد نیاز را به `DeviceCatalog` اضافه کنی +4. frontend را به `physical_device_uuid`-based API منتقل کنی + +اگر خواستی، در مرحله بعد من می‌توانم همین طراحی را به تسک اجرایی تبدیل کنم و دقیقا بگویم: + +- چه model fieldهایی اضافه شوند +- چه serializerهایی ساخته شوند +- چه endpointهایی پیاده شوند +- و refactor را در چه ترتیب انجام بدهی diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py index 031ae3c..bb64768 100644 --- a/farm_hub/seeds.py +++ b/farm_hub/seeds.py @@ -4,7 +4,7 @@ from django.db import transaction from account.seeds import seed_admin_user from device_hub.catalog_seed import seed_sensor_catalog -from device_hub.models import SensorCatalog +from device_hub.models import DeviceCatalog from .catalog import CATALOG_SEED_DATA from .models import FarmHub, FarmType, Product @@ -68,7 +68,7 @@ def _get_default_catalog(): def _get_sensor_catalog_by_code(code): - return SensorCatalog.objects.filter(code=code).first() + return DeviceCatalog.objects.filter(code=code).first() @transaction.atomic diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index 02e4e07..1361e6b 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -3,7 +3,7 @@ from access_control.models import SubscriptionPlan from access_control.serializers import SubscriptionPlanSerializer from access_control.catalog import GOLD_PLAN_CODE from access_control.services import get_effective_subscription_plan -from device_hub.models import FarmSensor, SensorCatalog +from device_hub.models import DeviceCatalog, FarmDevice from .models import FarmHub, FarmType, Product from .services import normalize_farm_boundary_input @@ -37,15 +37,19 @@ class ProductSerializer(serializers.ModelSerializer): ] -class FarmSensorSerializer(serializers.ModelSerializer): +class FarmDeviceSerializer(serializers.ModelSerializer): last_updated = serializers.DateTimeField(source="updated_at", read_only=True) sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) + device_catalog_uuids = serializers.SerializerMethodField() + device_catalog_codes = serializers.SerializerMethodField() class Meta: - model = FarmSensor + model = FarmDevice fields = [ "uuid", "sensor_catalog_uuid", + "device_catalog_uuids", + "device_catalog_codes", "physical_device_uuid", "name", "sensor_type", @@ -56,13 +60,19 @@ class FarmSensorSerializer(serializers.ModelSerializer): ] read_only_fields = ["uuid", "last_updated"] + def get_device_catalog_uuids(self, obj): + return [str(catalog.uuid) for catalog in obj.get_device_catalogs()] + + def get_device_catalog_codes(self, obj): + return [catalog.code for catalog in obj.get_device_catalogs()] + class FarmHubSerializer(serializers.ModelSerializer): last_updated = serializers.DateTimeField(source="updated_at", read_only=True) farm_type = FarmTypeSerializer(read_only=True) subscription_plan = serializers.SerializerMethodField() products = ProductSerializer(many=True, read_only=True) - sensors = FarmSensorSerializer(many=True, read_only=True) + sensors = FarmDeviceSerializer(many=True, read_only=True) area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True) class Meta: @@ -89,13 +99,20 @@ class FarmHubSerializer(serializers.ModelSerializer): return SubscriptionPlanSerializer(subscription_plan, context=self.context).data -class FarmSensorWriteSerializer(serializers.ModelSerializer): +class FarmDeviceWriteSerializer(serializers.ModelSerializer): sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False) + device_catalog_uuids = serializers.ListField( + child=serializers.UUIDField(), + write_only=True, + required=False, + allow_empty=False, + ) class Meta: - model = FarmSensor + model = FarmDevice fields = [ "sensor_catalog_uuid", + "device_catalog_uuids", "physical_device_uuid", "name", "sensor_type", @@ -106,13 +123,27 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer): def validate(self, attrs): sensor_catalog_uuid = attrs.pop("sensor_catalog_uuid", None) + device_catalog_uuids = attrs.pop("device_catalog_uuids", None) + catalog_uuids = [] if sensor_catalog_uuid is not None: + catalog_uuids.append(sensor_catalog_uuid) + if device_catalog_uuids: + catalog_uuids.extend(device_catalog_uuids) + + if catalog_uuids: try: - sensor_catalog = SensorCatalog.objects.get(uuid=sensor_catalog_uuid) - except SensorCatalog.DoesNotExist as exc: - raise serializers.ValidationError({"sensor_catalog_uuid": ["Sensor catalog not found."]}) from exc - attrs["sensor_catalog"] = sensor_catalog - attrs.setdefault("name", sensor_catalog.name) + catalog_map = { + catalog.uuid: catalog + for catalog in DeviceCatalog.objects.filter(uuid__in=catalog_uuids) + } + except DeviceCatalog.DoesNotExist as exc: + raise serializers.ValidationError({"device_catalog_uuids": ["Device catalog not found."]}) from exc + if len(catalog_map) != len({uuid for uuid in catalog_uuids}): + raise serializers.ValidationError({"device_catalog_uuids": ["One or more device catalogs were not found."]}) + device_catalogs = [catalog_map[uuid] for uuid in dict.fromkeys(catalog_uuids)] + attrs["sensor_catalog"] = device_catalogs[0] + attrs["device_catalogs"] = device_catalogs + attrs.setdefault("name", device_catalogs[0].name) return attrs @@ -127,7 +158,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): write_only=True, allow_empty=False, ) - sensors = FarmSensorWriteSerializer(many=True, required=False) + sensors = FarmDeviceWriteSerializer(many=True, required=False) sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1") sensor_payload = serializers.JSONField(write_only=True, required=False) irrigation_method_id = serializers.IntegerField(required=False, allow_null=True) @@ -247,7 +278,13 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): if products: farm.products.set(products) if sensors_data: - FarmSensor.objects.bulk_create([FarmSensor(farm=farm, **sensor_data) for sensor_data in sensors_data]) + created_devices = [] + for sensor_data in sensors_data: + device_catalogs = sensor_data.pop("device_catalogs", []) + farm_device = FarmDevice.objects.create(farm=farm, **sensor_data) + if device_catalogs: + farm_device.device_catalogs.set(device_catalogs) + created_devices.append(farm_device) return farm def update(self, instance, validated_data): @@ -275,7 +312,11 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): if sensors_data is not None: instance.sensors.all().delete() if sensors_data: - FarmSensor.objects.bulk_create([FarmSensor(farm=instance, **sensor_data) for sensor_data in sensors_data]) + for sensor_data in sensors_data: + device_catalogs = sensor_data.pop("device_catalogs", []) + farm_device = FarmDevice.objects.create(farm=instance, **sensor_data) + if device_catalogs: + farm_device.device_catalogs.set(device_catalogs) return instance diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 2732048..90e7352 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -7,7 +7,7 @@ from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, from access_control.services import build_farm_access_profile from access_control.views import FarmAccessProfileView from crop_zoning.models import CropArea -from device_hub.models import SensorCatalog +from device_hub.models import DeviceCatalog from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product from farm_hub.serializers import FarmHubSerializer @@ -49,7 +49,7 @@ class FarmListCreateViewTests(TestCase): self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم") self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold") - self.weather_station, _ = SensorCatalog.objects.get_or_create( + self.weather_station, _ = DeviceCatalog.objects.get_or_create( code="sensor_7_soil_moisture_sensor_v1_2", name="Sensor 7 - Soil Moisture Sensor v1.2", defaults={"supported_power_sources": ["solar", "direct_power"]}, @@ -290,7 +290,7 @@ class FarmSeedTests(TestCase): self.assertEqual(farm.irrigation_method_id, 1) self.assertEqual(farm.irrigation_method_name, "آبیاری قطره ای") self.assertIsNotNone(farm.sensors.first().physical_device_uuid) - self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists()) + self.assertTrue(DeviceCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists()) def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self): first_farm, first_created = seed_admin_farm() @@ -369,7 +369,7 @@ class FarmAccessProfileTests(TestCase): self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter") self.farm_type = FarmType.objects.create(name="گلخانه ای") self.product = Product.objects.create(farm_type=self.farm_type, name="خیار") - self.sensor_catalog = SensorCatalog.objects.create(code="climate_sensor", name="Climate Sensor") + self.sensor_catalog = DeviceCatalog.objects.create(code="climate_sensor", name="Climate Sensor") self.farm = FarmHub.objects.create( owner=self.user, farm_type=self.farm_type, diff --git a/farm_hub/views.py b/farm_hub/views.py index 6a34479..a7fea33 100644 --- a/farm_hub/views.py +++ b/farm_hub/views.py @@ -23,7 +23,7 @@ class FarmHubBaseView(APIView): def _get_farm(self, request, farm_uuid): try: - return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related( + return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog", "sensors__device_catalogs").select_related( "farm_type", "subscription_plan", "current_crop_area", @@ -49,6 +49,7 @@ class FarmListCreateView(FarmHubBaseView): "products", "sensors", "sensors__sensor_catalog", + "sensors__device_catalogs", ) data = FarmHubSerializer(farms, many=True).data return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)