This commit is contained in:
2026-04-07 01:09:27 +03:30
parent 6f098a1021
commit d95eff3187
6 changed files with 496 additions and 4 deletions
+6
View File
@@ -28,6 +28,12 @@ AI_SERVICE_API_KEY=
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
FARM_HUB_SERVICE_API_KEY=
FARM_DATA_API_HOST=http://localhost
FARM_DATA_API_PORT=8020
FARM_DATA_API_PATH=/api/farm-data/
FARM_DATA_API_KEY=
FARM_DATA_API_TIMEOUT=30
CROP_ZONE_CHUNK_AREA_SQM=10000
CROP_ZONE_TASK_STALE_SECONDS=300
+5
View File
@@ -185,6 +185,11 @@ CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL)
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345")
FARM_DATA_API_HOST = os.getenv("FARM_DATA_API_HOST", "")
FARM_DATA_API_PORT = os.getenv("FARM_DATA_API_PORT", "")
FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/")
FARM_DATA_API_KEY = os.getenv("FARM_DATA_API_KEY", "")
FARM_DATA_API_TIMEOUT = int(os.getenv("FARM_DATA_API_TIMEOUT", str(EXTERNAL_API_TIMEOUT)))
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
@@ -0,0 +1,317 @@
# مستند API لاگ درخواست های سنسور خارجی
این فایل نحوه کار endpoint زیر را توضیح می دهد:
`GET /sensor_external_api/logs/`
مسیر مربوطه در فایل `sensor_external_api/urls.py` به این صورت ثبت شده است:
```python
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list")
```
## هدف API
این API برای مشاهده لیست لاگ درخواست هایی استفاده می شود که از سنسورهای خارجی برای یک مزرعه مشخص ثبت شده اند.
هر لاگ شامل اطلاعات زیر است:
- شناسه لاگ
- `farm_uuid`
- `sensor_catalog_uuid`
- `physical_device_uuid`
- `payload` دریافتی از سنسور
- زمان ثبت لاگ
- اطلاعات سنسور مزرعه (`farm_sensor`)
- اطلاعات کاتالوگ سنسور (`sensor_catalog`)
## کلاس View
این endpoint در کلاس `SensorExternalRequestLogListAPIView` داخل فایل `sensor_external_api/views.py` پیاده سازی شده است.
ویژگی های مهم این View:
- فقط متد `GET` را پشتیبانی می کند.
- نیاز به احراز هویت دارد.
- از صفحه بندی استفاده می کند.
- لاگ ها را بر اساس `farm_uuid` فیلتر می کند.
## احراز هویت و دسترسی
در این View مقدار زیر تعریف شده است:
```python
permission_classes = [IsAuthenticated]
```
یعنی کاربر باید authenticated باشد تا بتواند این API را صدا بزند.
نکته مهم:
در تست ها، اگر درخواست بدون اعتبارنامه ارسال شود، پاسخ `401 Unauthorized` برمی گردد.
## پارامترهای ورودی
این API پارامترهای query string زیر را دریافت می کند:
- `farm_uuid` اجباری
- `page` اختیاری، پیش فرض `1`
- `page_size` اختیاری، پیش فرض `20` و حداکثر `100`
اعتبارسنجی پارامترها توسط `SensorExternalRequestLogQuerySerializer` در فایل `sensor_external_api/serializers.py` انجام می شود:
```python
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)
```
### نمونه درخواست
```http
GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20
```
## روند اجرای API
### 1) اعتبارسنجی query params
ابتدا `request.query_params` توسط serializer اعتبارسنجی می شود:
```python
serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
```
اگر `farm_uuid` معتبر نباشد یا `page_size` خارج از بازه باشد، پاسخ خطای validation از DRF برمی گردد.
### 2) گرفتن لاگ های مربوط به مزرعه
سپس سرویس `get_sensor_external_request_logs_for_farm` فراخوانی می شود:
```python
queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=serializer.validated_data["farm_uuid"],
)
```
این سرویس در فایل `sensor_external_api/services.py` تعریف شده و لاگ ها را از جدول `sensor_external_request_logs` می خواند:
```python
SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
```
پس ترتیب خروجی به این صورت است:
- جدیدترین لاگ ها اول نمایش داده می شوند.
- اگر `created_at` برابر باشد، لاگ با `id` بزرگ تر زودتر می آید.
### 3) مدیریت خطای migration
اگر جدول های لازم هنوز migrate نشده باشند، سرویس خطا را به `ValueError` تبدیل می کند و View این پاسخ را برمی گرداند:
```json
{
"code": 503,
"msg": "Required tables are not ready. Run migrations."
}
```
با status code برابر با `503 Service Unavailable`.
### 4) صفحه بندی نتایج
این View از paginator زیر استفاده می کند:
```python
class SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
```
و در View نیز `page_size` از داده معتبرشده serializer روی paginator اعمال می شود:
```python
paginator = self.pagination_class()
paginator.page_size = serializer.validated_data["page_size"]
page = paginator.paginate_queryset(queryset, request, view=self)
```
### 5) ساخت map از سنسورهای مزرعه
برای اینکه هر لاگ همراه با اطلاعات سنسور مزرعه و کاتالوگ سنسور برگردد، این سرویس صدا زده می شود:
```python
farm_sensor_map = get_farm_sensor_map_for_logs(logs=page)
```
این سرویس:
- لاگ های همان page را می گیرد.
- `FarmSensor` های متناظر را از دیتابیس پیدا می کند.
- یک map با کلید زیر می سازد:
```python
(farm_uuid, sensor_catalog_uuid, physical_device_uuid)
```
به کمک این map، serializer می تواند برای هر لاگ اطلاعات تکمیلی را پر کند.
## ساختار serializer خروجی
خروجی هر آیتم با `SensorExternalRequestLogSerializer` ساخته می شود.
فیلدهای اصلی:
```python
fields = [
"id",
"farm_uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"farm_sensor",
"sensor_catalog",
"payload",
"created_at",
]
```
### فیلد `farm_sensor`
این فیلد از نوع `SerializerMethodField` است.
اگر سنسور متناظر پیدا شود، اطلاعاتی مثل موارد زیر را برمی گرداند:
- `uuid`
- `sensor_catalog_uuid`
- `physical_device_uuid`
- `name`
- `sensor_type`
- `is_active`
- `specifications`
- `power_source`
- `created_at`
- `updated_at`
اگر سنسور پیدا نشود، مقدار آن `null` خواهد بود.
### فیلد `sensor_catalog`
این فیلد هم از نوع `SerializerMethodField` است.
اگر `farm_sensor` و `sensor_catalog` موجود باشند، اطلاعات کاتالوگ سنسور برمی گردد، مثل:
- `uuid`
- `code`
- `name`
- `description`
- `customizable_fields`
- `supported_power_sources`
- `returned_data_fields`
- `sample_payload`
- `is_active`
- `created_at`
- `updated_at`
اگر داده متناظر وجود نداشته باشد، مقدار آن `null` خواهد بود.
## مدل لاگ
لاگ ها در مدل `SensorExternalRequestLog` ذخیره می شوند:
```python
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)
```
ویژگی مهم مدل:
- جدول دیتابیس: `sensor_external_request_logs`
- ترتیب پیش فرض: `ordering = ["-created_at", "-id"]`
## نمونه پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"count": 2,
"next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1",
"previous": null,
"data": [
{
"id": 12,
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
"farm_sensor": {
"uuid": "99999999-9999-9999-9999-999999999999",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
"name": "External device 2",
"sensor_type": "soil_sensor",
"is_active": true,
"specifications": {
"model": "FH-2"
},
"power_source": {
"type": "solar"
},
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"sensor_catalog": {
"uuid": "22222222-2222-2222-2222-222222222222",
"code": "ext-sensor-log-2",
"name": "External Sensor Log 2",
"description": "Sensor catalog for second log",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": ["humidity"],
"sample_payload": {},
"is_active": true,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"payload": {
"temp": 18
},
"created_at": "2024-01-02T10:00:00Z"
}
]
}
```
## خطاهای احتمالی
### 401 Unauthorized
اگر کاربر احراز هویت نشده باشد:
```json
{
"detail": "Authentication credentials were not provided."
}
```
### 400 Bad Request
اگر پارامترها نامعتبر باشند، مثلا `farm_uuid` فرمت درست نداشته باشد یا `page_size` بیشتر از `100` باشد، خطای validation برگردانده می شود.
### 503 Service Unavailable
اگر migration های مربوط به جدول ها اجرا نشده باشند:
```json
{
"code": 503,
"msg": "Required tables are not ready. Run migrations."
}
```
## نکات مهم پیاده سازی
- این API فقط لاگ های مربوط به یک `farm_uuid` را برمی گرداند.
- پاسخ به صورت paginated است.
- داده های `farm_sensor` و `sensor_catalog` به صورت enrich شده به هر لاگ اضافه می شوند.
- اگر برای یک لاگ، سنسور متناظر در `FarmSensor` پیدا نشود، فیلدهای `farm_sensor` و `sensor_catalog` ممکن است `null` باشند.
- ترتیب نمایش لاگ ها نزولی و از جدیدترین به قدیمی ترین است.
## فایل های مرتبط
- `sensor_external_api/urls.py`
- `sensor_external_api/views.py`
- `sensor_external_api/serializers.py`
- `sensor_external_api/services.py`
- `sensor_external_api/models.py`
- `sensor_external_api/tests.py`
## جمع بندی
API مربوط به `logs/` برای گزارش گیری و مشاهده تاریخچه درخواست های ورودی از سنسورهای خارجی یک مزرعه استفاده می شود. این endpoint با دریافت `farm_uuid`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند.
+83 -1
View File
@@ -1,3 +1,5 @@
import requests
from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction
from farm_hub.models import FarmSensor
@@ -6,6 +8,10 @@ from notifications.services import create_notification_for_farm_uuid
from .models import SensorExternalRequestLog
class FarmDataForwardError(Exception):
pass
def get_sensor_external_request_logs_for_farm(*, farm_uuid):
try:
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
@@ -60,7 +66,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", "sensor_catalog")
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
.filter(physical_device_uuid=physical_device_uuid)
.first()
)
@@ -89,3 +95,79 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
)
except (ProgrammingError, OperationalError) as exc:
raise ValueError("Sensor external API tables are not migrated.") from exc
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")
.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)
url = _build_farm_data_url()
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key:
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
request_payload = {
"farm_uuid": str(sensor.farm.farm_uuid),
"farm_boundary": farm_boundary,
"sensor_payload": {
sensor.name or str(sensor.physical_device_uuid): payload,
},
}
try:
response = requests.post(
url,
json=request_payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"X-API-Key": api_key,
"Authorization": f"Api-Key {api_key}",
},
timeout=getattr(settings, "FARM_DATA_API_TIMEOUT", 30),
)
except requests.RequestException as exc:
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
if response.status_code >= 400:
try:
response_body = response.json()
except ValueError:
response_body = response.text
raise FarmDataForwardError(
f"Farm data API returned status {response.status_code}: {response_body}"
)
return request_payload
def _get_farm_boundary(*, sensor):
crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first()
if crop_area is None:
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
geometry = crop_area.geometry or {}
if geometry.get("type") == "Feature":
geometry = geometry.get("geometry") or {}
if geometry.get("type") != "Polygon":
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
return geometry
def _build_farm_data_url():
base_url = getattr(settings, "AI_SERVICE_BASE_URL", "").rstrip("/")
path = "/api/farm-data/"
if not base_url:
raise FarmDataForwardError("FARM_DATA_API_HOST is not configured.")
return f"{base_url}/{path.lstrip('/')}"
+74 -3
View File
@@ -1,7 +1,10 @@
import requests
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from unittest.mock import Mock, patch
from crop_zoning.models import CropArea
from farm_hub.models import FarmHub, FarmSensor, FarmType
from notifications.models import FarmNotification
from sensor_catalog.models import SensorCatalog
@@ -11,7 +14,12 @@ from .services import get_latest_sensor_external_request_log
from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
@override_settings(SENSOR_EXTERNAL_API_KEY="12345")
@override_settings(
SENSOR_EXTERNAL_API_KEY="12345",
FARM_DATA_API_HOST="http://localhost",
FARM_DATA_API_PORT="8020",
FARM_DATA_API_KEY="farm-data-key",
)
class SensorExternalAPIViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@@ -31,11 +39,38 @@ class SensorExternalAPIViewTests(TestCase):
code="ext-sensor-v1",
name="External Sensor",
)
self.crop_area = CropArea.objects.create(
farm=self.farm,
geometry={
"type": "Polygon",
"coordinates": [
[
[51.39, 35.7],
[51.41, 35.7],
[51.41, 35.72],
[51.39, 35.72],
[51.39, 35.7],
]
],
},
points=[
[51.39, 35.7],
[51.41, 35.7],
[51.41, 35.72],
[51.39, 35.72],
],
center={"lat": 35.71, "lng": 51.4},
area_sqm=1000,
area_hectares=0.1,
chunk_area_sqm=1000,
)
self.farm.current_crop_area = self.crop_area
self.farm.save(update_fields=["current_crop_area"])
self.sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.sensor_catalog,
physical_device_uuid="11111111-1111-1111-1111-111111111111",
name="External device",
name="sensor-7-1",
sensor_type="weather_station",
)
@@ -50,7 +85,9 @@ class SensorExternalAPIViewTests(TestCase):
self.assertEqual(response.status_code, 401)
def test_creates_notification_and_request_log_for_device_uuid(self):
@patch("sensor_external_api.services.requests.post")
def test_creates_notification_and_request_log_for_device_uuid(self, mock_post):
mock_post.return_value = Mock(status_code=201)
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
@@ -75,6 +112,23 @@ class SensorExternalAPIViewTests(TestCase):
payload={"temp": 12},
).exists()
)
mock_post.assert_called_once_with(
"http://localhost:8020/api/farm-data/",
json={
"farm_uuid": str(self.farm.farm_uuid),
"farm_boundary": self.crop_area.geometry,
"sensor_payload": {
"sensor-7-1": {"temp": 12},
},
},
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"X-API-Key": "farm-data-key",
"Authorization": "Api-Key farm-data-key",
},
timeout=30,
)
def test_returns_404_for_unknown_device_uuid(self):
request = self.factory.post(
@@ -88,6 +142,23 @@ class SensorExternalAPIViewTests(TestCase):
self.assertEqual(response.status_code, 404)
@patch("sensor_external_api.services.requests.post")
def test_returns_503_when_farm_data_api_is_unavailable(self, mock_post):
mock_post.side_effect = requests.RequestException("connection error")
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
format="json",
HTTP_X_API_KEY="12345",
)
response = SensorExternalAPIView.as_view()(request)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.data["code"], 503)
self.assertIn("Farm data API request failed", response.data["msg"])
class SensorExternalServiceTests(TestCase):
def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self):
+11
View File
@@ -16,7 +16,9 @@ from .serializers import (
SensorExternalRequestSerializer,
)
from .services import (
FarmDataForwardError,
create_sensor_external_notification,
forward_sensor_payload_to_farm_data,
get_farm_sensor_map_for_logs,
get_sensor_external_request_logs_for_farm,
)
@@ -61,6 +63,10 @@ class SensorExternalAPIView(APIView):
physical_device_uuid=serializer.validated_data["uuid"],
payload=serializer.validated_data.get("payload"),
)
forward_sensor_payload_to_farm_data(
physical_device_uuid=serializer.validated_data["uuid"],
payload=serializer.validated_data.get("payload"),
)
except ValueError as exc:
if "not migrated" in str(exc):
return Response(
@@ -68,6 +74,11 @@ class SensorExternalAPIView(APIView):
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
except FarmDataForwardError as exc:
return Response(
{"code": 503, "msg": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
data = FarmNotificationSerializer(notification).data
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)