This commit is contained in:
2026-04-05 03:33:23 +03:30
parent 32dbbed1af
commit df15d03d7c
7 changed files with 492 additions and 28 deletions
@@ -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"],
},
),
]
+16
View File
@@ -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()}"
+81
View File
@@ -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",
]
+83 -12
View File
@@ -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
+186 -6
View File
@@ -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),
)
+2 -1
View File
@@ -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"),
]
+97 -9
View File
@@ -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,
)