From df15d03d7cbfe94f76dde1d0532ab32792b7f483 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 5 Apr 2026 03:33:23 +0330 Subject: [PATCH] UPDATE --- .../migrations/0001_initial.py | 27 +++ sensor_external_api/models.py | 16 ++ sensor_external_api/serializers.py | 81 ++++++++ sensor_external_api/services.py | 95 +++++++-- sensor_external_api/tests.py | 192 +++++++++++++++++- sensor_external_api/urls.py | 3 +- sensor_external_api/views.py | 106 +++++++++- 7 files changed, 492 insertions(+), 28 deletions(-) create mode 100644 sensor_external_api/migrations/0001_initial.py create mode 100644 sensor_external_api/models.py diff --git a/sensor_external_api/migrations/0001_initial.py b/sensor_external_api/migrations/0001_initial.py new file mode 100644 index 0000000..30e154b --- /dev/null +++ b/sensor_external_api/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.15 on 2026-04-04 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SensorExternalRequestLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(db_index=True)), + ("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)), + ("physical_device_uuid", models.UUIDField(db_index=True)), + ("payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "db_table": "sensor_external_request_logs", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/sensor_external_api/models.py b/sensor_external_api/models.py new file mode 100644 index 0000000..9a91ca1 --- /dev/null +++ b/sensor_external_api/models.py @@ -0,0 +1,16 @@ +from django.db import models + + +class SensorExternalRequestLog(models.Model): + farm_uuid = models.UUIDField(db_index=True) + sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) + physical_device_uuid = models.UUIDField(db_index=True) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "sensor_external_request_logs" + ordering = ["-created_at", "-id"] + + def __str__(self): + return f"{self.physical_device_uuid}:{self.created_at.isoformat()}" diff --git a/sensor_external_api/serializers.py b/sensor_external_api/serializers.py index 6763bc8..c3a9d60 100644 --- a/sensor_external_api/serializers.py +++ b/sensor_external_api/serializers.py @@ -1,5 +1,86 @@ from rest_framework import serializers +from farm_hub.models import FarmSensor +from sensor_catalog.models import SensorCatalog + +from .models import SensorExternalRequestLog + class SensorExternalRequestSerializer(serializers.Serializer): + uuid = serializers.UUIDField() payload = serializers.JSONField(required=False, default=dict) + + +class SensorExternalRequestLogQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + page = serializers.IntegerField(required=False, min_value=1, default=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) + + +class SensorExternalRequestLogSerializer(serializers.ModelSerializer): + farm_sensor = serializers.SerializerMethodField() + sensor_catalog = serializers.SerializerMethodField() + + class Meta: + model = SensorExternalRequestLog + fields = [ + "id", + "farm_uuid", + "sensor_catalog_uuid", + "physical_device_uuid", + "farm_sensor", + "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((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)) + if farm_sensor is None: + return None + return FarmSensorLogSerializer(farm_sensor).data + + def get_sensor_catalog(self, obj): + farm_sensor_map = self.context.get("farm_sensor_map", {}) + farm_sensor = farm_sensor_map.get((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)) + if farm_sensor is None or farm_sensor.sensor_catalog is None: + return None + return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data + + +class FarmSensorLogSerializer(serializers.ModelSerializer): + sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) + + class Meta: + model = FarmSensor + fields = [ + "uuid", + "sensor_catalog_uuid", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "created_at", + "updated_at", + ] + + +class SensorCatalogLogSerializer(serializers.ModelSerializer): + class Meta: + model = SensorCatalog + fields = [ + "uuid", + "code", + "name", + "description", + "customizable_fields", + "supported_power_sources", + "returned_data_fields", + "sample_payload", + "is_active", + "created_at", + "updated_at", + ] diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py index 582220a..ba239e1 100644 --- a/sensor_external_api/services.py +++ b/sensor_external_api/services.py @@ -1,20 +1,91 @@ -from django.db import ProgrammingError, OperationalError +from django.db import OperationalError, ProgrammingError, transaction +from farm_hub.models import FarmSensor from notifications.services import create_notification_for_farm_uuid - -DEFAULT_SENSOR_EXTERNAL_FARM_UUID = "11111111-1111-1111-1111-111111111111" +from .models import SensorExternalRequestLog -def create_sensor_external_notification(*, payload=None): - payload = payload or {} +def get_sensor_external_request_logs_for_farm(*, farm_uuid): try: - return create_notification_for_farm_uuid( - farm_uuid=DEFAULT_SENSOR_EXTERNAL_FARM_UUID, - title="Sensor external API request", - message="A request was received by sensor_external_api.", - level="info", - metadata={"payload": payload}, + return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id") + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def get_farm_sensor_map_for_logs(*, logs): + try: + logs = list(logs) + if not logs: + return {} + + farm_sensor_queryset = ( + FarmSensor.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: + key = ( + farm_sensor.farm.farm_uuid, + farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, + farm_sensor.physical_device_uuid, + ) + farm_sensor_map.setdefault(key, farm_sensor) + + return farm_sensor_map + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, physical_device_uuid): + try: + return ( + SensorExternalRequestLog.objects.filter( + farm_uuid=farm_uuid, + sensor_catalog_uuid=sensor_catalog_uuid, + physical_device_uuid=physical_device_uuid, + ) + .order_by("-created_at", "-id") + .first() ) except (ProgrammingError, OperationalError) as exc: - raise ValueError("Notifications table is not migrated.") from exc + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def create_sensor_external_notification(*, physical_device_uuid, payload=None): + payload = payload or {} + sensor = ( + FarmSensor.objects.select_related("farm", "sensor_catalog") + .filter(physical_device_uuid=physical_device_uuid) + .first() + ) + if sensor is None: + raise ValueError("Physical device not found.") + + try: + with transaction.atomic(): + SensorExternalRequestLog.objects.create( + farm_uuid=sensor.farm.farm_uuid, + sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, + physical_device_uuid=sensor.physical_device_uuid, + payload=payload, + ) + return create_notification_for_farm_uuid( + farm_uuid=sensor.farm.farm_uuid, + title="Sensor external API request", + message=f"Payload received from device {sensor.physical_device_uuid}.", + level="info", + metadata={ + "farm_uuid": str(sensor.farm.farm_uuid), + "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, + "physical_device_uuid": str(sensor.physical_device_uuid), + "payload": payload, + }, + ) + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py index bb1a1f5..0a378c7 100644 --- a/sensor_external_api/tests.py +++ b/sensor_external_api/tests.py @@ -2,10 +2,13 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory -from farm_hub.models import FarmHub, FarmType +from farm_hub.models import FarmHub, FarmSensor, FarmType from notifications.models import FarmNotification +from sensor_catalog.models import SensorCatalog -from .views import SensorExternalAPIView +from .models import SensorExternalRequestLog +from .services import get_latest_sensor_external_request_log +from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView @override_settings(SENSOR_EXTERNAL_API_KEY="12345") @@ -23,20 +26,34 @@ class SensorExternalAPIViewTests(TestCase): owner=self.user, farm_type=self.farm_type, name="Farm External", - farm_uuid="11111111-1111-1111-1111-111111111111", + ) + self.sensor_catalog = SensorCatalog.objects.create( + code="ext-sensor-v1", + name="External Sensor", + ) + self.sensor = FarmSensor.objects.create( + farm=self.farm, + sensor_catalog=self.sensor_catalog, + physical_device_uuid="11111111-1111-1111-1111-111111111111", + name="External device", + sensor_type="weather_station", ) def test_requires_api_key(self): - request = self.factory.post("/api/sensor-external-api/", {"payload": {"temp": 12}}, format="json") + request = self.factory.post( + "/api/sensor-external-api/", + {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, + format="json", + ) response = SensorExternalAPIView.as_view()(request) self.assertEqual(response.status_code, 401) - def test_creates_notification_for_fixed_farm_uuid(self): + def test_creates_notification_and_request_log_for_device_uuid(self): request = self.factory.post( "/api/sensor-external-api/", - {"payload": {"temp": 12}}, + {"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}}, format="json", HTTP_X_API_KEY="12345", ) @@ -50,3 +67,166 @@ class SensorExternalAPIViewTests(TestCase): title="Sensor external API request", ).exists() ) + self.assertTrue( + SensorExternalRequestLog.objects.filter( + farm_uuid=self.farm.farm_uuid, + sensor_catalog_uuid=self.sensor_catalog.uuid, + physical_device_uuid=self.sensor.physical_device_uuid, + payload={"temp": 12}, + ).exists() + ) + + def test_returns_404_for_unknown_device_uuid(self): + request = self.factory.post( + "/api/sensor-external-api/", + {"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"temp": 12}}, + format="json", + HTTP_X_API_KEY="12345", + ) + + response = SensorExternalAPIView.as_view()(request) + + self.assertEqual(response.status_code, 404) + + +class SensorExternalServiceTests(TestCase): + def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self): + first_log = SensorExternalRequestLog.objects.create( + farm_uuid="11111111-1111-1111-1111-111111111111", + sensor_catalog_uuid="22222222-2222-2222-2222-222222222222", + physical_device_uuid="33333333-3333-3333-3333-333333333333", + payload={"temp": 12}, + ) + latest_log = SensorExternalRequestLog.objects.create( + farm_uuid=first_log.farm_uuid, + sensor_catalog_uuid=first_log.sensor_catalog_uuid, + physical_device_uuid=first_log.physical_device_uuid, + payload={"temp": 18}, + ) + SensorExternalRequestLog.objects.create( + farm_uuid=first_log.farm_uuid, + sensor_catalog_uuid=first_log.sensor_catalog_uuid, + physical_device_uuid="44444444-4444-4444-4444-444444444444", + payload={"temp": 25}, + ) + + log = get_latest_sensor_external_request_log( + farm_uuid=first_log.farm_uuid, + sensor_catalog_uuid=first_log.sensor_catalog_uuid, + physical_device_uuid=first_log.physical_device_uuid, + ) + + self.assertIsNotNone(log) + self.assertEqual(log.id, latest_log.id) + self.assertEqual(log.payload, {"temp": 18}) + + +@override_settings(SENSOR_EXTERNAL_API_KEY="12345") +class SensorExternalRequestLogListAPIViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="sensor-external-log-user", + password="secret123", + email="sensor-external-log@example.com", + phone_number="09120000016", + ) + self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm Log External", + farm_uuid="11111111-1111-1111-1111-111111111111", + ) + self.farm_uuid = self.farm.farm_uuid + self.other_farm_uuid = "aaaaaaaa-1111-1111-1111-111111111111" + self.first_catalog = SensorCatalog.objects.create( + code="ext-sensor-log-1", + name="External Sensor Log 1", + description="Sensor catalog for first log", + returned_data_fields=["temp"], + ) + self.second_catalog = SensorCatalog.objects.create( + code="ext-sensor-log-2", + name="External Sensor Log 2", + description="Sensor catalog for second log", + returned_data_fields=["humidity"], + ) + self.first_sensor = FarmSensor.objects.create( + farm=self.farm, + sensor_catalog=self.first_catalog, + physical_device_uuid="33333333-3333-3333-3333-333333333333", + name="External device 1", + sensor_type="weather_station", + specifications={"model": "FH-1"}, + power_source={"type": "battery"}, + ) + self.second_sensor = FarmSensor.objects.create( + farm=self.farm, + sensor_catalog=self.second_catalog, + physical_device_uuid="55555555-5555-5555-5555-555555555555", + name="External device 2", + sensor_type="soil_sensor", + specifications={"model": "FH-2"}, + power_source={"type": "solar"}, + ) + + self.first_log = SensorExternalRequestLog.objects.create( + farm_uuid=self.farm_uuid, + sensor_catalog_uuid=self.first_catalog.uuid, + physical_device_uuid=self.first_sensor.physical_device_uuid, + payload={"temp": 12}, + ) + self.second_log = SensorExternalRequestLog.objects.create( + farm_uuid=self.farm_uuid, + sensor_catalog_uuid=self.second_catalog.uuid, + physical_device_uuid=self.second_sensor.physical_device_uuid, + payload={"temp": 18}, + ) + SensorExternalRequestLog.objects.create( + farm_uuid=self.other_farm_uuid, + sensor_catalog_uuid="66666666-6666-6666-6666-666666666666", + physical_device_uuid="77777777-7777-7777-7777-777777777777", + payload={"temp": 24}, + ) + + def test_requires_api_key(self): + request = self.factory.get(f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}") + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 401) + + def test_returns_paginated_logs_for_farm_uuid(self): + request = self.factory.get( + f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1", + HTTP_X_API_KEY="12345", + ) + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["count"], 2) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["id"], self.second_log.id) + self.assertEqual( + response.data["data"][0]["physical_device_uuid"], + str(self.second_log.physical_device_uuid), + ) + self.assertEqual( + response.data["data"][0]["sensor_catalog"]["uuid"], + str(self.second_catalog.uuid), + ) + self.assertEqual( + response.data["data"][0]["sensor_catalog"]["name"], + self.second_catalog.name, + ) + self.assertEqual( + response.data["data"][0]["farm_sensor"]["uuid"], + str(self.second_sensor.uuid), + ) + self.assertEqual( + response.data["data"][0]["farm_sensor"]["physical_device_uuid"], + str(self.second_sensor.physical_device_uuid), + ) diff --git a/sensor_external_api/urls.py b/sensor_external_api/urls.py index 05e08df..97e486d 100644 --- a/sensor_external_api/urls.py +++ b/sensor_external_api/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from .views import SensorExternalAPIView +from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView urlpatterns = [ path("", SensorExternalAPIView.as_view(), name="sensor-external-api"), + path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), ] diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py index f410fa3..1c85380 100644 --- a/sensor_external_api/views.py +++ b/sensor_external_api/views.py @@ -1,4 +1,5 @@ -from rest_framework import status +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView @@ -8,8 +9,22 @@ from config.swagger import code_response from notifications.serializers import FarmNotificationSerializer from .authentication import SensorExternalAPIKeyAuthentication -from .serializers import SensorExternalRequestSerializer -from .services import create_sensor_external_notification +from .serializers import ( + SensorExternalRequestLogQuerySerializer, + SensorExternalRequestLogSerializer, + SensorExternalRequestSerializer, +) +from .services import ( + create_sensor_external_notification, + get_farm_sensor_map_for_logs, + get_sensor_external_request_logs_for_farm, +) + + +class SensorExternalRequestLogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 class SensorExternalAPIView(APIView): @@ -32,8 +47,8 @@ class SensorExternalAPIView(APIView): responses={ 201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), - 404: code_response("SensorExternalAPIFarmNotFoundResponse"), - 503: code_response("SensorExternalAPINotificationsUnavailableResponse"), + 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), + 503: code_response("SensorExternalAPIUnavailableResponse"), }, ) def post(self, request): @@ -41,14 +56,87 @@ class SensorExternalAPIView(APIView): serializer.is_valid(raise_exception=True) try: - notification = create_sensor_external_notification(payload=serializer.validated_data.get("payload")) + notification = create_sensor_external_notification( + physical_device_uuid=serializer.validated_data["uuid"], + payload=serializer.validated_data.get("payload"), + ) except ValueError as exc: - if str(exc) == "Notifications table is not migrated.": + if "not migrated" in str(exc): return Response( - {"code": 503, "msg": "Notifications table is not ready. Run migrations."}, + {"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) - return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) data = FarmNotificationSerializer(notification).data return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) + + +class SensorExternalRequestLogListAPIView(APIView): + authentication_classes = [SensorExternalAPIKeyAuthentication] + permission_classes = [AllowAny] + pagination_class = SensorExternalRequestLogPagination + + @extend_schema( + tags=["Sensor External API"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter( + name="X-API-Key", + type=OpenApiTypes.STR, + location=OpenApiParameter.HEADER, + required=True, + default="12345", + description="API key for sensor external API.", + ), + ], + responses={ + 200: code_response( + "SensorExternalRequestLogListResponse", + data=SensorExternalRequestLogSerializer(many=True), + extra_fields={ + "count": serializers.IntegerField(), + "next": serializers.CharField(allow_null=True), + "previous": serializers.CharField(allow_null=True), + }, + ), + 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), + 503: code_response("SensorExternalRequestLogListUnavailableResponse"), + }, + ) + def get(self, request): + serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + try: + queryset = get_sensor_external_request_logs_for_farm( + farm_uuid=serializer.validated_data["farm_uuid"], + ) + except ValueError: + return Response( + {"code": 503, "msg": "Required tables are not ready. Run migrations."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + 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 + 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, + )