UPDATE
This commit is contained in:
@@ -23,6 +23,7 @@ urlpatterns = [
|
|||||||
path("api/pest-detection/", include("pest_detection.urls")),
|
path("api/pest-detection/", include("pest_detection.urls")),
|
||||||
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
||||||
path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")),
|
path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")),
|
||||||
|
path("api/sensors/", include("sensor_7_in_1.comparison_urls")),
|
||||||
path("api/irrigation/", include("irrigation_recommendation.urls")),
|
path("api/irrigation/", include("irrigation_recommendation.urls")),
|
||||||
|
|
||||||
path("api/weather/", include("water.weather_urls")),
|
path("api/weather/", include("water.weather_urls")),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
|
|||||||
if [ "${DOCKER_VERSION}" = "develop" ]; then
|
if [ "${DOCKER_VERSION}" = "develop" ]; then
|
||||||
echo "Running develop seeders..."
|
echo "Running develop seeders..."
|
||||||
python manage.py seed_admin_farm
|
python manage.py seed_admin_farm
|
||||||
|
python manage.py seed_sensor_7_in_1
|
||||||
echo "Develop seeders done."
|
echo "Develop seeders done."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ from external_api_adapter import request as external_api_request
|
|||||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FarmDataSyncError(Exception):
|
class FarmDataSyncError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -86,8 +91,18 @@ def sync_farm_data(
|
|||||||
|
|
||||||
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
logger.error("Farm data sync failed: FARM_DATA_API_KEY missing for farm_uuid=%s", farm.farm_uuid)
|
||||||
raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.")
|
raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s irrigation_method_id=%s boundary_type=%s",
|
||||||
|
farm.farm_uuid,
|
||||||
|
request_payload.get("sensor_key"),
|
||||||
|
"sensor_payload" in request_payload,
|
||||||
|
request_payload.get("plant_ids"),
|
||||||
|
request_payload.get("irrigation_method_id"),
|
||||||
|
request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = external_api_request(
|
response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
@@ -102,12 +117,20 @@ def sync_farm_data(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except ExternalAPIRequestError as exc:
|
except ExternalAPIRequestError as exc:
|
||||||
|
logger.exception("Farm data sync request exception: farm_uuid=%s", farm.farm_uuid)
|
||||||
raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc
|
raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
response_body = response.data
|
response_body = response.data
|
||||||
|
logger.error(
|
||||||
|
"Farm data sync rejected: farm_uuid=%s status_code=%s response=%s",
|
||||||
|
farm.farm_uuid,
|
||||||
|
response.status_code,
|
||||||
|
response_body,
|
||||||
|
)
|
||||||
raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}")
|
raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}")
|
||||||
|
|
||||||
|
logger.warning("Farm data sync success: farm_uuid=%s status_code=%s", farm.farm_uuid, response.status_code)
|
||||||
return request_payload
|
return request_payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
|
||||||
|
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
|
||||||
|
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from sensor_7_in_1.seeds import seed_sensor_7_in_1_demo_data
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create or refresh demo sensor 7 in 1 data for summary and chart endpoints."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
result = seed_sensor_7_in_1_demo_data()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CommandError(str(exc)) from exc
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"Sensor 7 in 1 demo data seeded: "
|
||||||
|
f"farm_uuid={result['farm'].farm_uuid}, "
|
||||||
|
f"sensor_catalog={result['sensor_catalog'].code}, "
|
||||||
|
f"physical_device_uuid={result['sensor'].physical_device_uuid}, "
|
||||||
|
f"logs={result['log_count']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from farm_hub.models import FarmSensor
|
||||||
|
from farm_hub.seeds import seed_admin_farm
|
||||||
|
from sensor_catalog.models import SensorCatalog
|
||||||
|
from sensor_external_api.models import SensorExternalRequestLog
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1"
|
||||||
|
SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777")
|
||||||
|
SENSOR_7_IN_1_LOG_SERIES = [
|
||||||
|
{
|
||||||
|
"days_ago": 6,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 44.0,
|
||||||
|
"soil_temperature": 20.6,
|
||||||
|
"soil_ph": 6.3,
|
||||||
|
"electrical_conductivity": 1.0,
|
||||||
|
"nitrogen": 25.0,
|
||||||
|
"phosphorus": 13.0,
|
||||||
|
"potassium": 21.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 5,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 45.5,
|
||||||
|
"soil_temperature": 21.1,
|
||||||
|
"soil_ph": 6.4,
|
||||||
|
"electrical_conductivity": 1.1,
|
||||||
|
"nitrogen": 26.0,
|
||||||
|
"phosphorus": 13.8,
|
||||||
|
"potassium": 21.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 4,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 46.8,
|
||||||
|
"soil_temperature": 21.7,
|
||||||
|
"soil_ph": 6.5,
|
||||||
|
"electrical_conductivity": 1.1,
|
||||||
|
"nitrogen": 27.4,
|
||||||
|
"phosphorus": 14.2,
|
||||||
|
"potassium": 22.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 3,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 48.2,
|
||||||
|
"soil_temperature": 22.0,
|
||||||
|
"soil_ph": 6.6,
|
||||||
|
"electrical_conductivity": 1.2,
|
||||||
|
"nitrogen": 28.9,
|
||||||
|
"phosphorus": 15.1,
|
||||||
|
"potassium": 23.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 2,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 49.6,
|
||||||
|
"soil_temperature": 22.4,
|
||||||
|
"soil_ph": 6.6,
|
||||||
|
"electrical_conductivity": 1.2,
|
||||||
|
"nitrogen": 29.7,
|
||||||
|
"phosphorus": 15.7,
|
||||||
|
"potassium": 24.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 1,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 50.9,
|
||||||
|
"soil_temperature": 22.8,
|
||||||
|
"soil_ph": 6.7,
|
||||||
|
"electrical_conductivity": 1.3,
|
||||||
|
"nitrogen": 30.8,
|
||||||
|
"phosphorus": 16.2,
|
||||||
|
"potassium": 24.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"days_ago": 0,
|
||||||
|
"payload": {
|
||||||
|
"soil_moisture": 52.4,
|
||||||
|
"soil_temperature": 23.1,
|
||||||
|
"soil_ph": 6.8,
|
||||||
|
"electrical_conductivity": 1.3,
|
||||||
|
"nitrogen": 32.0,
|
||||||
|
"phosphorus": 16.8,
|
||||||
|
"potassium": 25.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_sensor_7_in_1_catalog():
|
||||||
|
sensor_catalog, created = SensorCatalog.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.",
|
||||||
|
"customizable_fields": [],
|
||||||
|
"supported_power_sources": ["solar", "battery", "direct_power"],
|
||||||
|
"returned_data_fields": [
|
||||||
|
"soil_moisture",
|
||||||
|
"soil_temperature",
|
||||||
|
"soil_ph",
|
||||||
|
"electrical_conductivity",
|
||||||
|
"nitrogen",
|
||||||
|
"phosphorus",
|
||||||
|
"potassium",
|
||||||
|
],
|
||||||
|
"sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"],
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return sensor_catalog, created
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
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(
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SensorExternalRequestLog.objects.filter(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0)
|
||||||
|
created_logs = []
|
||||||
|
for item in SENSOR_7_IN_1_LOG_SERIES:
|
||||||
|
log = SensorExternalRequestLog.objects.create(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
sensor_catalog_uuid=sensor_catalog.uuid,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
payload=item["payload"],
|
||||||
|
)
|
||||||
|
created_at = base_time - timedelta(days=item["days_ago"])
|
||||||
|
SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at)
|
||||||
|
log.created_at = created_at
|
||||||
|
created_logs.append(log)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"farm": farm,
|
||||||
|
"sensor_catalog": sensor_catalog,
|
||||||
|
"sensor": sensor,
|
||||||
|
"catalog_created": catalog_created,
|
||||||
|
"sensor_created": sensor_created,
|
||||||
|
"log_count": len(created_logs),
|
||||||
|
}
|
||||||
@@ -30,6 +30,50 @@ class Sensor7In1ValuesListSerializer(serializers.Serializer):
|
|||||||
sensors = Sensor7In1ValueSerializer(many=True, required=False)
|
sensors = Sensor7In1ValueSerializer(many=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartQuerySerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField()
|
||||||
|
range = serializers.ChoiceField(choices=["7d", "30d"], required=False, default="7d")
|
||||||
|
|
||||||
|
|
||||||
|
class SensorValuesListQuerySerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField()
|
||||||
|
range = serializers.ChoiceField(choices=["1h", "24h", "7d"], required=False, default="7d")
|
||||||
|
|
||||||
|
|
||||||
|
class SensorRadarChartQuerySerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField()
|
||||||
|
range = serializers.ChoiceField(choices=["today", "7d", "30d"], required=False, default="7d")
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartSeriesSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField()
|
||||||
|
data = serializers.ListField(child=serializers.FloatField())
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartResponseSerializer(serializers.Serializer):
|
||||||
|
series = SensorComparisonChartSeriesSerializer(many=True)
|
||||||
|
categories = serializers.ListField(child=serializers.CharField())
|
||||||
|
currentValue = serializers.FloatField()
|
||||||
|
vsLastWeek = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SensorValuesListItemSerializer(serializers.Serializer):
|
||||||
|
title = serializers.CharField()
|
||||||
|
subtitle = serializers.CharField()
|
||||||
|
trendNumber = serializers.FloatField()
|
||||||
|
trend = serializers.ChoiceField(choices=["positive", "negative"])
|
||||||
|
unit = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SensorValuesListResponseSerializer(serializers.Serializer):
|
||||||
|
sensors = SensorValuesListItemSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorRadarChartResponseSerializer(serializers.Serializer):
|
||||||
|
labels = serializers.ListField(child=serializers.CharField())
|
||||||
|
series = SensorComparisonChartSeriesSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
class Sensor7In1SummarySerializer(serializers.Serializer):
|
class Sensor7In1SummarySerializer(serializers.Serializer):
|
||||||
sensor = Sensor7In1MetaSerializer(required=False)
|
sensor = Sensor7In1MetaSerializer(required=False)
|
||||||
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
|
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
|
||||||
@@ -38,4 +82,3 @@ class Sensor7In1SummarySerializer(serializers.Serializer):
|
|||||||
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
||||||
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
||||||
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
||||||
|
|
||||||
|
|||||||
+288
-20
@@ -1,6 +1,8 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm
|
from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from .mock_data import (
|
from .mock_data import (
|
||||||
ANOMALY_DETECTION_CARD,
|
ANOMALY_DETECTION_CARD,
|
||||||
@@ -81,6 +83,59 @@ SENSOR_FIELDS = [
|
|||||||
MIN_REQUIRED_SENSOR_FIELDS = 4
|
MIN_REQUIRED_SENSOR_FIELDS = 4
|
||||||
MAX_HISTORY_ITEMS = 20
|
MAX_HISTORY_ITEMS = 20
|
||||||
MAX_CHART_POINTS = 7
|
MAX_CHART_POINTS = 7
|
||||||
|
COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30}
|
||||||
|
VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)}
|
||||||
|
RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||||||
|
PERSIAN_WEEKDAYS = {
|
||||||
|
0: "دوشنبه",
|
||||||
|
1: "سه شنبه",
|
||||||
|
2: "چهارشنبه",
|
||||||
|
3: "پنج شنبه",
|
||||||
|
4: "جمعه",
|
||||||
|
5: "شنبه",
|
||||||
|
6: "یکشنبه",
|
||||||
|
}
|
||||||
|
COMPARISON_CHART_FIELD_ALIASES = {
|
||||||
|
"soil_moisture": "moisture",
|
||||||
|
"soilMoisture": "moisture",
|
||||||
|
"moisture": "moisture",
|
||||||
|
"soil_temperature": "temperature",
|
||||||
|
"soilTemperature": "temperature",
|
||||||
|
"temperature": "temperature",
|
||||||
|
"humidity": "humidity",
|
||||||
|
"soil_ph": "ph",
|
||||||
|
"soilPh": "ph",
|
||||||
|
"ph": "ph",
|
||||||
|
"electrical_conductivity": "ec",
|
||||||
|
"electricalConductivity": "ec",
|
||||||
|
"ec": "ec",
|
||||||
|
"nitrogen": "nitrogen",
|
||||||
|
"n": "nitrogen",
|
||||||
|
"phosphorus": "phosphorus",
|
||||||
|
"p": "phosphorus",
|
||||||
|
"potassium": "potassium",
|
||||||
|
"k": "potassium",
|
||||||
|
}
|
||||||
|
COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium")
|
||||||
|
VALUES_LIST_FIELDS = [
|
||||||
|
("moisture", "Moisture", "%"),
|
||||||
|
("temperature", "Temperature", "°C"),
|
||||||
|
("humidity", "Humidity", "%"),
|
||||||
|
("ph", "pH", "pH"),
|
||||||
|
("ec", "EC", "dS/m"),
|
||||||
|
("nitrogen", "Nitrogen", "mg/kg"),
|
||||||
|
("phosphorus", "Phosphorus", "mg/kg"),
|
||||||
|
("potassium", "Potassium", "mg/kg"),
|
||||||
|
]
|
||||||
|
RADAR_CHART_FIELDS = [
|
||||||
|
("moisture", "Moisture", 60.0),
|
||||||
|
("temperature", "Temperature", 26.0),
|
||||||
|
("humidity", "Humidity", 55.0),
|
||||||
|
("ph", "PH", 6.5),
|
||||||
|
("ec", "EC", 1.3),
|
||||||
|
("nitrogen", "Nitrogen", 42.0),
|
||||||
|
("potassium", "Potassium", 38.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value):
|
def _to_float(value):
|
||||||
@@ -107,6 +162,16 @@ def _extract_payload(payload):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_numeric_payload(payload):
|
||||||
|
payload = _extract_payload(payload)
|
||||||
|
numeric_payload = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
numeric_value = _to_float(value)
|
||||||
|
if numeric_value is not None:
|
||||||
|
numeric_payload[key] = numeric_value
|
||||||
|
return numeric_payload
|
||||||
|
|
||||||
|
|
||||||
def _extract_readings(payload):
|
def _extract_readings(payload):
|
||||||
payload = _extract_payload(payload)
|
payload = _extract_payload(payload)
|
||||||
readings = {}
|
readings = {}
|
||||||
@@ -151,46 +216,69 @@ def _get_sensor_context(farm=None):
|
|||||||
if farm is None:
|
if farm is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
primary_sensor = get_primary_soil_sensor(farm=farm)
|
||||||
|
if primary_sensor is None:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid)
|
logs_queryset = get_sensor_external_request_logs_for_farm(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
physical_device_uuid=primary_sensor.physical_device_uuid,
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
candidate_log = None
|
|
||||||
candidate_readings = {}
|
|
||||||
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
|
||||||
readings = _extract_readings(log.payload)
|
|
||||||
if len(readings) >= MIN_REQUIRED_SENSOR_FIELDS:
|
|
||||||
candidate_log = log
|
|
||||||
candidate_readings = readings
|
|
||||||
break
|
|
||||||
|
|
||||||
if candidate_log is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
history = []
|
history = []
|
||||||
for log in logs_queryset.filter(physical_device_uuid=candidate_log.physical_device_uuid)[:MAX_HISTORY_ITEMS]:
|
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||||
readings = _extract_readings(log.payload)
|
readings = _extract_readings(log.payload)
|
||||||
if readings:
|
if readings:
|
||||||
history.append((log, readings))
|
history.append((log, readings))
|
||||||
|
|
||||||
if not history:
|
if not history:
|
||||||
history = [(candidate_log, candidate_readings)]
|
return None
|
||||||
|
|
||||||
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[candidate_log])
|
latest_log, latest_readings = history[0]
|
||||||
|
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log])
|
||||||
farm_sensor = farm_sensor_map.get(
|
farm_sensor = farm_sensor_map.get(
|
||||||
(candidate_log.farm_uuid, candidate_log.sensor_catalog_uuid, candidate_log.physical_device_uuid)
|
(latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)
|
||||||
)
|
) or primary_sensor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"farm_sensor": farm_sensor,
|
"farm_sensor": farm_sensor,
|
||||||
"latest_log": history[0][0],
|
"latest_log": latest_log,
|
||||||
"latest_readings": history[0][1],
|
"latest_readings": latest_readings,
|
||||||
"previous_readings": history[1][1] if len(history) > 1 else {},
|
"previous_readings": history[1][1] if len(history) > 1 else {},
|
||||||
"history": history,
|
"history": history,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_primary_soil_sensor(*, farm):
|
||||||
|
soil_sensors = list(
|
||||||
|
farm.sensors.select_related("sensor_catalog")
|
||||||
|
.order_by("created_at", "id")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sensor_priority(sensor):
|
||||||
|
sensor_type = (sensor.sensor_type or "").lower()
|
||||||
|
catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower()
|
||||||
|
catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower()
|
||||||
|
sensor_name = (sensor.name or "").lower()
|
||||||
|
haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name])
|
||||||
|
|
||||||
|
if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type:
|
||||||
|
return 0
|
||||||
|
if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack:
|
||||||
|
return 1
|
||||||
|
if "soil" in haystack:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
prioritized_sensors = sorted(soil_sensors, key=_sensor_priority)
|
||||||
|
if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3:
|
||||||
|
return prioritized_sensors[0]
|
||||||
|
return soil_sensors[0] if soil_sensors else None
|
||||||
|
|
||||||
|
|
||||||
def _build_sensor_meta(context, fallback_sensor):
|
def _build_sensor_meta(context, fallback_sensor):
|
||||||
sensor = deepcopy(fallback_sensor)
|
sensor = deepcopy(fallback_sensor)
|
||||||
if not context:
|
if not context:
|
||||||
@@ -434,3 +522,183 @@ def get_sensor_7_in_1_summary_data(farm=None):
|
|||||||
"anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context),
|
"anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context),
|
||||||
"soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context),
|
"soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_comparison_chart_field(field_name):
|
||||||
|
return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_comparison_category(bucket_date, range_value):
|
||||||
|
if range_value == "7d":
|
||||||
|
return PERSIAN_WEEKDAYS[bucket_date.weekday()]
|
||||||
|
return bucket_date.strftime("%m/%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_percent_change(current_value, baseline_value):
|
||||||
|
if not baseline_value:
|
||||||
|
return "+0.0%"
|
||||||
|
percent_change = ((current_value - baseline_value) / baseline_value) * 100
|
||||||
|
return f"{percent_change:+.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_current_value_subtitle(title, value, unit):
|
||||||
|
rendered_value = _format_value(value, unit)
|
||||||
|
return f"مقدار فعلی: {rendered_value or title}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
days = COMPARISON_CHART_RANGES[range_value]
|
||||||
|
start_date = timezone.localdate() - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
physical_device_uuid=physical_device_uuid,
|
||||||
|
date_from=start_date,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||||
|
|
||||||
|
grouped_logs = {}
|
||||||
|
for log in reversed(list(logs_queryset[: days * 24])):
|
||||||
|
bucket_date = timezone.localtime(log.created_at).date()
|
||||||
|
numeric_payload = _extract_numeric_payload(log.payload)
|
||||||
|
if not numeric_payload:
|
||||||
|
continue
|
||||||
|
grouped_logs[bucket_date] = numeric_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_map = {}
|
||||||
|
for bucket_date in sorted_dates:
|
||||||
|
payload = grouped_logs[bucket_date]
|
||||||
|
normalized_payload = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
normalized_key = _normalize_comparison_chart_field(key)
|
||||||
|
normalized_payload[normalized_key] = value
|
||||||
|
for key, value in normalized_payload.items():
|
||||||
|
series_map.setdefault(key, []).append(round(value, 2))
|
||||||
|
|
||||||
|
ordered_field_names = [
|
||||||
|
field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map
|
||||||
|
] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS)
|
||||||
|
|
||||||
|
series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names]
|
||||||
|
primary_field = ordered_field_names[0]
|
||||||
|
primary_data = series_map[primary_field]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"series": series,
|
||||||
|
"categories": categories,
|
||||||
|
"currentValue": round(primary_data[-1], 2),
|
||||||
|
"vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
|
||||||
|
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
physical_device_uuid=physical_device_uuid,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {"sensors": []}
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
earliest_payload = {}
|
||||||
|
latest_payload = {}
|
||||||
|
for log in logs:
|
||||||
|
numeric_payload = {
|
||||||
|
_normalize_comparison_chart_field(key): value
|
||||||
|
for key, value in _extract_numeric_payload(log.payload).items()
|
||||||
|
}
|
||||||
|
if not numeric_payload:
|
||||||
|
continue
|
||||||
|
if not earliest_payload:
|
||||||
|
earliest_payload = numeric_payload
|
||||||
|
latest_payload = numeric_payload
|
||||||
|
|
||||||
|
if not latest_payload:
|
||||||
|
return {"sensors": []}
|
||||||
|
|
||||||
|
sensors = []
|
||||||
|
for field_name, title, unit in VALUES_LIST_FIELDS:
|
||||||
|
current_value = latest_payload.get(field_name)
|
||||||
|
if current_value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
previous_value = earliest_payload.get(field_name, current_value)
|
||||||
|
delta = round(current_value - previous_value, 2)
|
||||||
|
sensors.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"subtitle": _format_current_value_subtitle(title, current_value, unit),
|
||||||
|
"trendNumber": abs(delta),
|
||||||
|
"trend": "positive" if delta >= 0 else "negative",
|
||||||
|
"unit": unit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"sensors": sensors}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
start_time = timezone.now() - RADAR_CHART_RANGES[range_value]
|
||||||
|
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(
|
||||||
|
farm_uuid=farm.farm_uuid,
|
||||||
|
physical_device_uuid=physical_device_uuid,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
|
||||||
|
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
|
||||||
|
if not logs:
|
||||||
|
latest_log = logs_queryset.order_by("-created_at", "-id").first()
|
||||||
|
if latest_log is None:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
logs = [latest_log]
|
||||||
|
|
||||||
|
latest_payload = {}
|
||||||
|
for log in logs:
|
||||||
|
numeric_payload = {
|
||||||
|
_normalize_comparison_chart_field(key): value
|
||||||
|
for key, value in _extract_numeric_payload(log.payload).items()
|
||||||
|
}
|
||||||
|
if numeric_payload:
|
||||||
|
latest_payload = numeric_payload
|
||||||
|
|
||||||
|
if not latest_payload:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
|
||||||
|
labels = []
|
||||||
|
current_data = []
|
||||||
|
ideal_data = []
|
||||||
|
for field_name, label, ideal_value in RADAR_CHART_FIELDS:
|
||||||
|
current_value = latest_payload.get(field_name)
|
||||||
|
if current_value is None:
|
||||||
|
continue
|
||||||
|
labels.append(label)
|
||||||
|
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},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
+154
-13
@@ -1,5 +1,7 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
|
||||||
from farm_hub.models import FarmHub, FarmSensor, FarmType
|
from farm_hub.models import FarmHub, FarmSensor, FarmType
|
||||||
@@ -8,8 +10,20 @@ from sensor_external_api.models import SensorExternalRequestLog
|
|||||||
|
|
||||||
from dashboard.services import get_farm_dashboard_cards
|
from dashboard.services import get_farm_dashboard_cards
|
||||||
|
|
||||||
from .services import get_sensor_7_in_1_summary_data
|
from .seeds import seed_sensor_7_in_1_demo_data
|
||||||
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
|
from .services import (
|
||||||
|
get_sensor_7_in_1_summary_data,
|
||||||
|
get_sensor_comparison_chart_data,
|
||||||
|
get_primary_soil_sensor,
|
||||||
|
get_sensor_radar_chart_data,
|
||||||
|
get_sensor_values_list_data,
|
||||||
|
)
|
||||||
|
from .views import (
|
||||||
|
Sensor7In1SummaryView,
|
||||||
|
SensorComparisonChartView,
|
||||||
|
SensorRadarChartView,
|
||||||
|
SensorValuesListView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Sensor7In1BaseTestCase(TestCase):
|
class Sensor7In1BaseTestCase(TestCase):
|
||||||
@@ -48,6 +62,13 @@ class Sensor7In1BaseTestCase(TestCase):
|
|||||||
name="Soil Sensor 7-in-1",
|
name="Soil Sensor 7-in-1",
|
||||||
sensor_type="soil_7_in_1",
|
sensor_type="soil_7_in_1",
|
||||||
)
|
)
|
||||||
|
self.chart_sensor = FarmSensor.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
sensor_catalog=self.sensor_catalog,
|
||||||
|
physical_device_uuid="44444444-4444-4444-4444-444444444444",
|
||||||
|
name="Comparison Sensor 7-in-1",
|
||||||
|
sensor_type="soil_7_in_1",
|
||||||
|
)
|
||||||
SensorExternalRequestLog.objects.create(
|
SensorExternalRequestLog.objects.create(
|
||||||
farm_uuid=self.farm.farm_uuid,
|
farm_uuid=self.farm.farm_uuid,
|
||||||
sensor_catalog_uuid=self.sensor_catalog.uuid,
|
sensor_catalog_uuid=self.sensor_catalog.uuid,
|
||||||
@@ -76,9 +97,31 @@ class Sensor7In1BaseTestCase(TestCase):
|
|||||||
"potassium": 24.0,
|
"potassium": 24.0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
now_utc = datetime.now(dt_timezone.utc)
|
||||||
|
base_time = now_utc.replace(hour=12, minute=0, second=0, microsecond=0)
|
||||||
|
for index, moisture in enumerate([56, 58, 55, 60, 62, 61, 59]):
|
||||||
|
log = SensorExternalRequestLog.objects.create(
|
||||||
|
farm_uuid=self.farm.farm_uuid,
|
||||||
|
sensor_catalog_uuid=self.sensor_catalog.uuid,
|
||||||
|
physical_device_uuid=self.chart_sensor.physical_device_uuid,
|
||||||
|
payload={
|
||||||
|
"moisture": moisture,
|
||||||
|
"temperature": round(26.2 + (index * 0.2), 1),
|
||||||
|
"humidity": 50 + index,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SensorExternalRequestLog.objects.filter(id=log.id).update(
|
||||||
|
created_at=base_time - timedelta(days=6 - index)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
|
class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
|
||||||
|
def test_primary_sensor_prefers_7_in_1_sensor_over_generic_soil_probe(self):
|
||||||
|
sensor = get_primary_soil_sensor(farm=self.farm)
|
||||||
|
|
||||||
|
self.assertEqual(sensor.id, self.sensor.id)
|
||||||
|
self.assertEqual(str(sensor.physical_device_uuid), str(self.sensor.physical_device_uuid))
|
||||||
|
|
||||||
def test_summary_returns_latest_specific_sensor_data(self):
|
def test_summary_returns_latest_specific_sensor_data(self):
|
||||||
data = get_sensor_7_in_1_summary_data(self.farm)
|
data = get_sensor_7_in_1_summary_data(self.farm)
|
||||||
|
|
||||||
@@ -98,6 +141,52 @@ class Sensor7In1ServiceTests(Sensor7In1BaseTestCase):
|
|||||||
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
|
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
|
||||||
self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%")
|
self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%")
|
||||||
|
|
||||||
|
def test_comparison_chart_service_returns_raw_chart_data(self):
|
||||||
|
data = get_sensor_comparison_chart_data(
|
||||||
|
farm=self.farm,
|
||||||
|
physical_device_uuid=self.sensor.physical_device_uuid,
|
||||||
|
range_value="7d",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(data["series"][0]["name"], "moisture")
|
||||||
|
self.assertEqual(data["series"][0]["data"], [41.0, 48.5])
|
||||||
|
self.assertEqual(data["currentValue"], 48.5)
|
||||||
|
self.assertEqual(data["vsLastWeek"], "+18.3%")
|
||||||
|
self.assertEqual(len(data["categories"]), 2)
|
||||||
|
|
||||||
|
def test_values_list_service_returns_formatted_sensor_items(self):
|
||||||
|
data = get_sensor_values_list_data(
|
||||||
|
farm=self.farm,
|
||||||
|
physical_device_uuid=self.sensor.physical_device_uuid,
|
||||||
|
range_value="7d",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(data["sensors"][0]["title"], "Moisture")
|
||||||
|
self.assertEqual(data["sensors"][0]["subtitle"], "مقدار فعلی: 48.5%")
|
||||||
|
self.assertEqual(data["sensors"][0]["trendNumber"], 7.5)
|
||||||
|
self.assertEqual(data["sensors"][0]["trend"], "positive")
|
||||||
|
self.assertEqual(data["sensors"][1]["title"], "Temperature")
|
||||||
|
self.assertEqual(data["sensors"][1]["subtitle"], "مقدار فعلی: 23.2°C")
|
||||||
|
self.assertEqual(data["sensors"][1]["trend"], "positive")
|
||||||
|
|
||||||
|
def test_radar_chart_service_returns_aligned_labels_and_series(self):
|
||||||
|
data = get_sensor_radar_chart_data(
|
||||||
|
farm=self.farm,
|
||||||
|
physical_device_uuid=self.sensor.physical_device_uuid,
|
||||||
|
range_value="7d",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
data["labels"],
|
||||||
|
["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"],
|
||||||
|
)
|
||||||
|
self.assertEqual(data["series"][0]["name"], "وضعیت فعلی")
|
||||||
|
self.assertEqual(data["series"][0]["data"], [48.5, 23.2, 6.8, 1.4, 31.0, 24.0])
|
||||||
|
self.assertEqual(data["series"][1]["name"], "بازه ایده آل")
|
||||||
|
self.assertEqual(data["series"][1]["data"], [60.0, 26.0, 6.5, 1.3, 42.0, 38.0])
|
||||||
|
self.assertEqual(len(data["labels"]), len(data["series"][0]["data"]))
|
||||||
|
self.assertEqual(len(data["labels"]), len(data["series"][1]["data"]))
|
||||||
|
|
||||||
|
|
||||||
class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
|
class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
|
||||||
def test_summary_view_returns_sensor_cards(self):
|
def test_summary_view_returns_sensor_cards(self):
|
||||||
@@ -119,22 +208,74 @@ class Sensor7In1ViewTests(Sensor7In1BaseTestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
|
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
|
||||||
|
|
||||||
def test_radar_chart_view_returns_sensor_chart(self):
|
def test_sensor_comparison_chart_view_returns_raw_payload(self):
|
||||||
request = self.factory.get(f"/api/sensor-7-in-1/sensor-radar-chart/?farm_uuid={self.farm.farm_uuid}")
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensors/comparison-chart/"
|
||||||
|
f"?farm_uuid={self.farm.farm_uuid}"
|
||||||
|
)
|
||||||
|
)
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
response = Sensor7In1RadarChartView.as_view()(request)
|
response = SensorComparisonChartView.as_view()(request)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["code"], 200)
|
self.assertIn("series", response.data)
|
||||||
self.assertEqual(response.data["data"]["series"][0]["name"], "اکنون")
|
self.assertNotIn("code", response.data)
|
||||||
|
self.assertEqual(response.data["currentValue"], 48.5)
|
||||||
|
self.assertEqual(response.data["vsLastWeek"], "+18.3%")
|
||||||
|
|
||||||
def test_comparison_chart_view_returns_sensor_chart(self):
|
def test_sensor_values_list_view_returns_raw_payload(self):
|
||||||
request = self.factory.get(f"/api/sensor-7-in-1/sensor-comparison-chart/?farm_uuid={self.farm.farm_uuid}")
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensors/values-list/"
|
||||||
|
f"?farm_uuid={self.farm.farm_uuid}"
|
||||||
|
)
|
||||||
|
)
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
response = Sensor7In1ComparisonChartView.as_view()(request)
|
response = SensorValuesListView.as_view()(request)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["code"], 200)
|
self.assertEqual(response.data["sensors"][0]["title"], "Moisture")
|
||||||
self.assertEqual(response.data["data"]["currentValue"], 48.5)
|
self.assertEqual(response.data["sensors"][0]["trendNumber"], 7.5)
|
||||||
|
self.assertEqual(response.data["sensors"][0]["trend"], "positive")
|
||||||
|
|
||||||
|
def test_sensor_radar_chart_view_returns_raw_payload(self):
|
||||||
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensors/radar-chart/"
|
||||||
|
f"?farm_uuid={self.farm.farm_uuid}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = SensorRadarChartView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["labels"], ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"])
|
||||||
|
self.assertEqual(response.data["series"][0]["name"], "وضعیت فعلی")
|
||||||
|
self.assertEqual(response.data["series"][1]["name"], "بازه ایده آل")
|
||||||
|
self.assertEqual(len(response.data["labels"]), len(response.data["series"][0]["data"]))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
USE_EXTERNAL_API_MOCK=True,
|
||||||
|
CROP_ZONE_CHUNK_AREA_SQM=200000,
|
||||||
|
)
|
||||||
|
class Sensor7In1SeedTests(TestCase):
|
||||||
|
def test_seed_sensor_7_in_1_demo_data_creates_idempotent_sensor_logs(self):
|
||||||
|
first_result = seed_sensor_7_in_1_demo_data()
|
||||||
|
second_result = seed_sensor_7_in_1_demo_data()
|
||||||
|
|
||||||
|
sensor = second_result["sensor"]
|
||||||
|
logs = SensorExternalRequestLog.objects.filter(
|
||||||
|
farm_uuid=second_result["farm"].farm_uuid,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(SensorCatalog.objects.filter(code="sensor-7-in-1").exists())
|
||||||
|
self.assertEqual(first_result["farm"].id, second_result["farm"].id)
|
||||||
|
self.assertEqual(first_result["sensor"].id, second_result["sensor"].id)
|
||||||
|
self.assertEqual(logs.count(), 7)
|
||||||
|
self.assertEqual(logs.first().payload["soil_moisture"], 52.4)
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
|
from .views import (
|
||||||
|
Sensor7In1SummaryView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||||
path("sensor-radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
|
|
||||||
path(
|
|
||||||
"sensor-comparison-chart/",
|
|
||||||
Sensor7In1ComparisonChartView.as_view(),
|
|
||||||
name="sensor-7-in-1-comparison-chart",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
+108
-2
@@ -1,18 +1,30 @@
|
|||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
|
|
||||||
from config.swagger import code_response, farm_uuid_query_param
|
from config.swagger import code_response, farm_uuid_query_param
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
|
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
|
||||||
from .serializers import Sensor7In1SummarySerializer
|
from .serializers import (
|
||||||
|
Sensor7In1SummarySerializer,
|
||||||
|
SensorComparisonChartQuerySerializer,
|
||||||
|
SensorComparisonChartResponseSerializer,
|
||||||
|
SensorRadarChartQuerySerializer,
|
||||||
|
SensorRadarChartResponseSerializer,
|
||||||
|
SensorValuesListQuerySerializer,
|
||||||
|
SensorValuesListResponseSerializer,
|
||||||
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
get_sensor_comparison_chart_data,
|
||||||
get_sensor_7_in_1_comparison_chart_data,
|
get_sensor_7_in_1_comparison_chart_data,
|
||||||
get_sensor_7_in_1_radar_chart_data,
|
get_sensor_7_in_1_radar_chart_data,
|
||||||
get_sensor_7_in_1_summary_data,
|
get_sensor_7_in_1_summary_data,
|
||||||
|
get_primary_soil_sensor,
|
||||||
|
get_sensor_radar_chart_data,
|
||||||
|
get_sensor_values_list_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +42,13 @@ class Sensor7In1SummaryView(APIView):
|
|||||||
except FarmHub.DoesNotExist as exc:
|
except FarmHub.DoesNotExist as exc:
|
||||||
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_primary_sensor(*, farm):
|
||||||
|
sensor = get_primary_soil_sensor(farm=farm)
|
||||||
|
if sensor is None:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]})
|
||||||
|
return sensor
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Sensor 7 in 1"],
|
tags=["Sensor 7 in 1"],
|
||||||
parameters=[
|
parameters=[
|
||||||
@@ -75,3 +94,90 @@ class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
|
|||||||
{"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)},
|
{"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Sensor 7 in 1"],
|
||||||
|
parameters=[
|
||||||
|
farm_uuid_query_param(required=True, description="UUID of the farm."),
|
||||||
|
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):
|
||||||
|
serializer = SensorComparisonChartQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
data = get_sensor_comparison_chart_data(
|
||||||
|
farm=farm,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
range_value=serializer.validated_data["range"],
|
||||||
|
)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorValuesListView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Sensor 7 in 1"],
|
||||||
|
parameters=[
|
||||||
|
farm_uuid_query_param(required=True, description="UUID of the farm."),
|
||||||
|
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):
|
||||||
|
serializer = SensorValuesListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
data = get_sensor_values_list_data(
|
||||||
|
farm=farm,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
range_value=serializer.validated_data["range"],
|
||||||
|
)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorRadarChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Sensor 7 in 1"],
|
||||||
|
parameters=[
|
||||||
|
farm_uuid_query_param(required=True, description="UUID of the farm."),
|
||||||
|
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):
|
||||||
|
serializer = SensorRadarChartQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
data = get_sensor_radar_chart_data(
|
||||||
|
farm=farm,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
range_value=serializer.validated_data["range"],
|
||||||
|
)
|
||||||
|
return Response(data, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# مستند API کاتالوگ سنسورها
|
||||||
|
|
||||||
|
این فایل API ثبتشده در `sensor_catalog/urls.py` را بهصورت کامل توضیح میدهد.
|
||||||
|
|
||||||
|
## فایل route
|
||||||
|
|
||||||
|
فایل route این app:
|
||||||
|
|
||||||
|
`sensor_catalog/urls.py`
|
||||||
|
|
||||||
|
محتوای آن:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import SensorCatalogListView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## آدرس نهایی endpoint
|
||||||
|
|
||||||
|
این route در `config/urls.py` اینطور mount شده است:
|
||||||
|
|
||||||
|
```python
|
||||||
|
path("api/sensor-catalog/", include("sensor_catalog.urls"))
|
||||||
|
```
|
||||||
|
|
||||||
|
پس آدرس نهایی API این است:
|
||||||
|
|
||||||
|
`GET /api/sensor-catalog/`
|
||||||
|
|
||||||
|
## هدف API
|
||||||
|
|
||||||
|
این endpoint برای گرفتن لیست کاتالوگ سنسورها استفاده میشود.
|
||||||
|
|
||||||
|
منظور از کاتالوگ سنسور، تعریف مرجع هر نوع سنسور است؛ مثلا:
|
||||||
|
|
||||||
|
- کد سنسور
|
||||||
|
- نام سنسور
|
||||||
|
- توضیحات
|
||||||
|
- فیلدهای خروجی سنسور
|
||||||
|
- نمونه payload
|
||||||
|
- منبع تغذیههای پشتیبانیشده
|
||||||
|
|
||||||
|
این API بیشتر برای frontend یا تنظیمات سیستم مفید است تا بداند چه نوع سنسورهایی در سیستم تعریف شدهاند و هر سنسور چه ساختاری دارد.
|
||||||
|
|
||||||
|
## View مربوطه
|
||||||
|
|
||||||
|
این endpoint در فایل `sensor_catalog/views.py` پیادهسازی شده است:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SensorCatalogListView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
```
|
||||||
|
|
||||||
|
این یعنی:
|
||||||
|
|
||||||
|
- فقط متد `GET` پشتیبانی میشود
|
||||||
|
- کاربر باید authenticated باشد
|
||||||
|
|
||||||
|
## احراز هویت و دسترسی
|
||||||
|
|
||||||
|
این View از:
|
||||||
|
|
||||||
|
```python
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
```
|
||||||
|
|
||||||
|
استفاده میکند.
|
||||||
|
|
||||||
|
در این پروژه بهصورت پیشفرض authentication از طریق JWT انجام میشود، چون در `config/settings.py` مقدار زیر تعریف شده:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
پس اگر کاربر توکن معتبر نداشته باشد، این API پاسخ `401 Unauthorized` برمیگرداند.
|
||||||
|
|
||||||
|
## رفتار endpoint
|
||||||
|
|
||||||
|
در متد `get` این View:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get(self, request):
|
||||||
|
sensors = SensorCatalog.objects.order_by("code")
|
||||||
|
data = SensorCatalogSerializer(sensors, many=True).data
|
||||||
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
```
|
||||||
|
|
||||||
|
این منطق اجرا میشود:
|
||||||
|
|
||||||
|
1. همه رکوردهای `SensorCatalog` از دیتابیس خوانده میشوند
|
||||||
|
2. خروجی بر اساس `code` مرتب میشود
|
||||||
|
3. دادهها با serializer به JSON تبدیل میشوند
|
||||||
|
4. پاسخ استاندارد با `code` و `msg` و `data` برگردانده میشود
|
||||||
|
|
||||||
|
## مدل دیتابیس
|
||||||
|
|
||||||
|
مدل این API در `sensor_catalog/models.py` قرار دارد:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SensorCatalog(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid.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="")
|
||||||
|
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)
|
||||||
|
sample_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### معنی فیلدها
|
||||||
|
|
||||||
|
- `uuid`: شناسه یکتا برای هر کاتالوگ
|
||||||
|
- `code`: کد یکتا و فنی سنسور
|
||||||
|
- `name`: نام قابل نمایش سنسور
|
||||||
|
- `description`: توضیح سنسور
|
||||||
|
- `customizable_fields`: فیلدهایی که موقع ساخت/پیکربندی سنسور ممکن است قابل تنظیم باشند
|
||||||
|
- `supported_power_sources`: نوع منبع تغذیههای پشتیبانیشده
|
||||||
|
- `returned_data_fields`: فیلدهایی که این سنسور در payload خود برمیگرداند
|
||||||
|
- `sample_payload`: یک نمونه payload برای درک ساختار داده
|
||||||
|
- `is_active`: فعال یا غیرفعال بودن این کاتالوگ
|
||||||
|
- `created_at` و `updated_at`: زمان ایجاد و آخرین بروزرسانی
|
||||||
|
|
||||||
|
## Serializer خروجی
|
||||||
|
|
||||||
|
serializer این endpoint در `sensor_catalog/serializers.py` تعریف شده است:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SensorCatalogSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SensorCatalog
|
||||||
|
fields = [
|
||||||
|
"uuid",
|
||||||
|
"code",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"customizable_fields",
|
||||||
|
"supported_power_sources",
|
||||||
|
"returned_data_fields",
|
||||||
|
"sample_payload",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکته مهم
|
||||||
|
|
||||||
|
این serializer فقط این فیلدها را در خروجی برمیگرداند:
|
||||||
|
|
||||||
|
- `uuid`
|
||||||
|
- `code`
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `customizable_fields`
|
||||||
|
- `supported_power_sources`
|
||||||
|
- `returned_data_fields`
|
||||||
|
- `sample_payload`
|
||||||
|
- `is_active`
|
||||||
|
|
||||||
|
پس فیلدهای `created_at` و `updated_at` در پاسخ این API نیستند.
|
||||||
|
|
||||||
|
## ورودی API
|
||||||
|
|
||||||
|
این endpoint ورودی body یا query param خاصی ندارد.
|
||||||
|
|
||||||
|
فقط کافی است کاربر authenticated باشد.
|
||||||
|
|
||||||
|
### نمونه درخواست
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sensor-catalog/
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## خروجی موفق
|
||||||
|
|
||||||
|
نمونه پاسخ موفق:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"code": "sensor_7_soil_moisture_sensor_v1_2",
|
||||||
|
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
|
||||||
|
"description": "Measures only soil moisture using electrical resistance between two metal probes.",
|
||||||
|
"customizable_fields": [],
|
||||||
|
"supported_power_sources": ["solar", "direct_power"],
|
||||||
|
"returned_data_fields": ["soil_moisture", "analog_output", "digital_output"],
|
||||||
|
"sample_payload": {
|
||||||
|
"soil_moisture": 42,
|
||||||
|
"analog_output": 610,
|
||||||
|
"digital_output": 1
|
||||||
|
},
|
||||||
|
"is_active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"code": "legacy_sensor",
|
||||||
|
"name": "Legacy Sensor",
|
||||||
|
"description": "",
|
||||||
|
"customizable_fields": [],
|
||||||
|
"supported_power_sources": ["direct_power"],
|
||||||
|
"returned_data_fields": ["status"],
|
||||||
|
"sample_payload": {
|
||||||
|
"status": "offline"
|
||||||
|
},
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ترتیب خروجی
|
||||||
|
|
||||||
|
خروجی با این دستور مرتب میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SensorCatalog.objects.order_by("code")
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی لیست همیشه بر اساس `code` به صورت صعودی برگردانده میشود.
|
||||||
|
|
||||||
|
## سناریوهای کاربردی
|
||||||
|
|
||||||
|
این API معمولا برای این موارد استفاده میشود:
|
||||||
|
|
||||||
|
- ساخت dropdown برای انتخاب نوع سنسور
|
||||||
|
- نمایش ساختار داده قابل انتظار از یک سنسور
|
||||||
|
- فهمیدن اینکه هر سنسور چه فیلدهایی برمیگرداند
|
||||||
|
- ساخت فرمهای داینامیک برای پیکربندی سنسور
|
||||||
|
- نمایش `sample_payload` در Swagger یا UI مدیریتی
|
||||||
|
|
||||||
|
## وضعیتهای خطا
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
|
||||||
|
اگر کاربر login نباشد یا توکن معتبر نداشته باشد.
|
||||||
|
|
||||||
|
### 200 با لیست خالی
|
||||||
|
|
||||||
|
اگر هیچ رکوردی در جدول `sensor_catalogs` وجود نداشته باشد، پاسخ موفق است اما `data` خالی خواهد بود:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## تست موجود
|
||||||
|
|
||||||
|
برای این endpoint تست در فایل `sensor_catalog/tests.py` وجود دارد.
|
||||||
|
|
||||||
|
تست اصلی بررسی میکند که:
|
||||||
|
|
||||||
|
- کاربر authenticated بتواند endpoint را صدا بزند
|
||||||
|
- پاسخ `200` باشد
|
||||||
|
- همه سنسورهای موجود برگردانده شوند
|
||||||
|
|
||||||
|
نمونه assertion:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["code"], 200)
|
||||||
|
self.assertEqual(len(response.data["data"]), 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## خلاصه
|
||||||
|
|
||||||
|
API موجود در `sensor_catalog/urls.py` فقط یک endpoint دارد:
|
||||||
|
|
||||||
|
- `GET /api/sensor-catalog/`
|
||||||
|
|
||||||
|
این endpoint:
|
||||||
|
|
||||||
|
- نیاز به احراز هویت دارد
|
||||||
|
- همه کاتالوگهای سنسور را از دیتابیس میخواند
|
||||||
|
- آنها را بر اساس `code` مرتب میکند
|
||||||
|
- اطلاعات ساختاری سنسورها را برای frontend یا پنل مدیریتی برمیگرداند
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
- `sensor_catalog/urls.py`
|
||||||
|
- `sensor_catalog/views.py`
|
||||||
|
- `sensor_catalog/serializers.py`
|
||||||
|
- `sensor_catalog/models.py`
|
||||||
|
- `sensor_catalog/tests.py`
|
||||||
|
- `config/urls.py`
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# مستند API دریافت داده سنسور خارجی
|
||||||
|
|
||||||
|
این فایل رفتار endpoint زیر را توضیح میدهد:
|
||||||
|
|
||||||
|
`POST /api/sensor-external-api/`
|
||||||
|
|
||||||
|
این API برای دریافت payload از یک سنسور فیزیکی، ثبت آن داخل دیتابیس، ساخت نوتیفیکیشن برای مزرعه، و سپس ارسال همان داده به سرویس AI/Farm Data استفاده میشود.
|
||||||
|
|
||||||
|
## هدف API
|
||||||
|
|
||||||
|
این endpoint وقتی صدا زده میشود که یک سنسور خارجی داده جدیدی تولید کرده باشد. بکاند در این مسیر چند کار پشت سر هم انجام میدهد:
|
||||||
|
|
||||||
|
1. اعتبارسنجی API key
|
||||||
|
2. اعتبارسنجی `uuid` و `payload`
|
||||||
|
3. پیدا کردن سنسور بر اساس `physical_device_uuid`
|
||||||
|
4. ذخیره لاگ درخواست در جدول `sensor_external_request_logs`
|
||||||
|
5. ساخت notification برای مزرعه
|
||||||
|
6. ارسال داده به سرویس AI در endpoint مربوط به farm data
|
||||||
|
|
||||||
|
## مسیر و View
|
||||||
|
|
||||||
|
این endpoint در فایل `sensor_external_api/urls.py` ثبت شده است:
|
||||||
|
|
||||||
|
```python
|
||||||
|
path("", SensorExternalAPIView.as_view(), name="sensor-external-api")
|
||||||
|
```
|
||||||
|
|
||||||
|
پیادهسازی view در فایل `sensor_external_api/views.py` قرار دارد:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SensorExternalAPIView(APIView):
|
||||||
|
authentication_classes = [SensorExternalAPIKeyAuthentication]
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
```
|
||||||
|
|
||||||
|
## احراز هویت
|
||||||
|
|
||||||
|
این API از هدر `X-API-Key` استفاده میکند.
|
||||||
|
|
||||||
|
کلاس احراز هویت:
|
||||||
|
|
||||||
|
`sensor_external_api/authentication.py`
|
||||||
|
|
||||||
|
رفتار آن:
|
||||||
|
|
||||||
|
- اگر `X-API-Key` یا `Authorization` ارسال نشود، پاسخ `401` میدهد.
|
||||||
|
- اگر مقدار کلید اشتباه باشد، پاسخ `401` میدهد.
|
||||||
|
- مقدار مورد انتظار از `SENSOR_EXTERNAL_API_KEY` خوانده میشود.
|
||||||
|
|
||||||
|
## ورودی درخواست
|
||||||
|
|
||||||
|
serializer ورودی در فایل `sensor_external_api/serializers.py` تعریف شده است:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SensorExternalRequestSerializer(serializers.Serializer):
|
||||||
|
uuid = serializers.UUIDField()
|
||||||
|
payload = serializers.JSONField(required=False, default=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
### بدنه نمونه درخواست
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"payload": {
|
||||||
|
"moisture_percent": 32.5,
|
||||||
|
"temperature_c": 21.3,
|
||||||
|
"ph": 6.7,
|
||||||
|
"ec_ds_m": 1.1,
|
||||||
|
"nitrogen_mg_kg": 42,
|
||||||
|
"phosphorus_mg_kg": 18,
|
||||||
|
"potassium_mg_kg": 210
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
|
||||||
|
- `uuid` در این API همان `physical_device_uuid` سنسور است.
|
||||||
|
- `payload` به همان شکلی که از سنسور میآید ذخیره و forward میشود.
|
||||||
|
|
||||||
|
## روند اجرای API
|
||||||
|
|
||||||
|
### 1) اعتبارسنجی request
|
||||||
|
|
||||||
|
در متد `post` ابتدا داده ورودی validate میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
serializer = SensorExternalRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر `uuid` معتبر نباشد یا ساختار body خراب باشد، DRF خطای `400` برمیگرداند.
|
||||||
|
|
||||||
|
### 2) ثبت لاگ و ساخت نوتیفیکیشن
|
||||||
|
|
||||||
|
سپس این سرویس صدا زده میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
notification = create_sensor_external_notification(
|
||||||
|
physical_device_uuid=serializer.validated_data["uuid"],
|
||||||
|
payload=serializer.validated_data.get("payload"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
این تابع در فایل `sensor_external_api/services.py` قرار دارد.
|
||||||
|
|
||||||
|
کارهایی که انجام میدهد:
|
||||||
|
|
||||||
|
- سنسور را از جدول `FarmSensor` با `physical_device_uuid` پیدا میکند.
|
||||||
|
- اگر سنسور پیدا نشود، `ValueError("Physical device not found.")` میدهد.
|
||||||
|
- یک رکورد در جدول `sensor_external_request_logs` میسازد.
|
||||||
|
- یک notification برای مزرعه میسازد.
|
||||||
|
|
||||||
|
### رکوردی که در دیتابیس ذخیره میشود
|
||||||
|
|
||||||
|
مدل ذخیرهسازی:
|
||||||
|
|
||||||
|
`sensor_external_api/models.py`
|
||||||
|
|
||||||
|
```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)
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی payload خام سنسور برای گزارشگیری و استفادههای بعدی نگه داشته میشود.
|
||||||
|
|
||||||
|
### 3) ارسال داده به سرویس AI / Farm Data
|
||||||
|
|
||||||
|
بعد از ثبت لاگ، این سرویس صدا زده میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
forward_sensor_payload_to_farm_data(
|
||||||
|
physical_device_uuid=serializer.validated_data["uuid"],
|
||||||
|
payload=serializer.validated_data.get("payload"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
این قسمت مهمترین call خارجی endpoint است.
|
||||||
|
|
||||||
|
## این API چه آدرسی از AI را صدا میزند؟
|
||||||
|
|
||||||
|
سرویس خارجی از طریق `external_api_adapter.request` صدا زده میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
response = external_api_request(
|
||||||
|
"ai",
|
||||||
|
_get_farm_data_path(),
|
||||||
|
method="POST",
|
||||||
|
payload=request_payload,
|
||||||
|
headers={...},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### service name
|
||||||
|
|
||||||
|
مقدار service برابر است با:
|
||||||
|
|
||||||
|
`"ai"`
|
||||||
|
|
||||||
|
یعنی این درخواست به سرویسی میرود که در تنظیمات به عنوان AI service تعریف شده است.
|
||||||
|
|
||||||
|
### base URL سرویس AI
|
||||||
|
|
||||||
|
در `config/settings.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"ai": {
|
||||||
|
"base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
|
||||||
|
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
|
||||||
|
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
پس base URL بهصورت پیشفرض این است:
|
||||||
|
|
||||||
|
`http://ai-web:8000`
|
||||||
|
|
||||||
|
### path مقصد
|
||||||
|
|
||||||
|
path از این تنظیم خوانده میشود:
|
||||||
|
|
||||||
|
```python
|
||||||
|
FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/")
|
||||||
|
```
|
||||||
|
|
||||||
|
پس path پیشفرض این است:
|
||||||
|
|
||||||
|
`/api/farm-data/`
|
||||||
|
|
||||||
|
### آدرس نهایی که صدا زده میشود
|
||||||
|
|
||||||
|
در حالت پیشفرض، آدرس نهایی به این صورت است:
|
||||||
|
|
||||||
|
`POST http://ai-web:8000/api/farm-data/`
|
||||||
|
|
||||||
|
اگر متغیرهای environment تغییر کرده باشند، این آدرس هم تغییر میکند.
|
||||||
|
|
||||||
|
## چرا این آدرس صدا زده میشود؟
|
||||||
|
|
||||||
|
هدف از این call این است که داده سنسور خام فقط در بکاند ذخیره نشود، بلکه برای پردازش downstream هم به سرویس AI/Farm Data فرستاده شود.
|
||||||
|
|
||||||
|
این سرویس AI احتمالا برای کارهای زیر استفاده میشود:
|
||||||
|
|
||||||
|
- تحلیل داده سنسورها در سطح مزرعه
|
||||||
|
- ساخت داده تجمیعی farm data
|
||||||
|
- تغذیه dashboardها و مدلهای AI
|
||||||
|
- محاسبه شاخصها یا توصیههای بعدی
|
||||||
|
|
||||||
|
خود این endpoint در این پروژه فقط داده را forward میکند و پردازش AI داخل همین اپ انجام نمیشود.
|
||||||
|
|
||||||
|
## چه payloadی به AI ارسال میشود؟
|
||||||
|
|
||||||
|
قبل از ارسال، بکاند این ساختار را میسازد:
|
||||||
|
|
||||||
|
```python
|
||||||
|
request_payload = {
|
||||||
|
"farm_uuid": str(sensor.farm.farm_uuid),
|
||||||
|
"farm_boundary": farm_boundary,
|
||||||
|
"sensor_payload": {
|
||||||
|
sensor.name or str(sensor.physical_device_uuid): payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی payload ارسالشده به AI دقیقا body اولیه کاربر نیست، بلکه این wrapper را دارد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"farm_boundary": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[51.39, 35.7], [51.41, 35.7], [51.41, 35.72], [51.39, 35.72], [51.39, 35.7]]]
|
||||||
|
},
|
||||||
|
"sensor_payload": {
|
||||||
|
"Soil Sensor 7-in-1": {
|
||||||
|
"moisture_percent": 32.5,
|
||||||
|
"temperature_c": 21.3,
|
||||||
|
"ph": 6.7,
|
||||||
|
"ec_ds_m": 1.1,
|
||||||
|
"nitrogen_mg_kg": 42,
|
||||||
|
"phosphorus_mg_kg": 18,
|
||||||
|
"potassium_mg_kg": 210
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## farm_boundary از کجا میآید؟
|
||||||
|
|
||||||
|
سرویس `_get_farm_boundary` این منطق را دارد:
|
||||||
|
|
||||||
|
- اگر `farm.current_crop_area` وجود داشته باشد، از آن استفاده میکند.
|
||||||
|
- اگر وجود نداشته باشد، آخرین crop area مزرعه را برمیدارد.
|
||||||
|
- اگر هیچ boundary وجود نداشته باشد، خطا میدهد.
|
||||||
|
- اگر geometry از نوع `Polygon` نباشد، خطا میدهد.
|
||||||
|
|
||||||
|
پس سرویس AI فقط وقتی صدا زده میشود که مرز مزرعه معتبر وجود داشته باشد.
|
||||||
|
|
||||||
|
## هدرهایی که به AI ارسال میشوند
|
||||||
|
|
||||||
|
در زمان forward کردن، این هدرها ارسال میشوند:
|
||||||
|
|
||||||
|
```python
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": api_key,
|
||||||
|
"Authorization": f"Api-Key {api_key}",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_key` از این setting میآید:
|
||||||
|
|
||||||
|
`FARM_DATA_API_KEY`
|
||||||
|
|
||||||
|
اگر این مقدار ست نشده باشد، پاسخ `503` برمیگردد.
|
||||||
|
|
||||||
|
## پاسخ موفق
|
||||||
|
|
||||||
|
اگر همه چیز درست باشد:
|
||||||
|
|
||||||
|
- لاگ ذخیره میشود
|
||||||
|
- notification ساخته میشود
|
||||||
|
- داده به AI forward میشود
|
||||||
|
- پاسخ `201` برمیگردد
|
||||||
|
|
||||||
|
نمونه ساختار پاسخ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 201,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"...": "serialized notification object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
نکته:
|
||||||
|
|
||||||
|
data خروجی این endpoint نتیجه AI نیست. خروجی، notification ساختهشده در سیستم خود بکاند است.
|
||||||
|
|
||||||
|
## خطاهای ممکن
|
||||||
|
|
||||||
|
### 401 Unauthorized
|
||||||
|
|
||||||
|
اگر API key ارسال نشود یا اشتباه باشد.
|
||||||
|
|
||||||
|
### 404 Not Found
|
||||||
|
|
||||||
|
اگر `physical_device_uuid` در جدول `FarmSensor` پیدا نشود.
|
||||||
|
|
||||||
|
پاسخ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Physical device not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 503 Service Unavailable
|
||||||
|
|
||||||
|
در چند حالت:
|
||||||
|
|
||||||
|
- migration جدولها انجام نشده باشد
|
||||||
|
- `FARM_DATA_API_KEY` تنظیم نشده باشد
|
||||||
|
- مرز مزرعه موجود نباشد
|
||||||
|
- geometry مزرعه `Polygon` نباشد
|
||||||
|
- سرویس AI در دسترس نباشد
|
||||||
|
- سرویس AI پاسخ خطای 4xx/5xx بدهد
|
||||||
|
|
||||||
|
نمونه خطا:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 503,
|
||||||
|
"msg": "Farm data API request failed: connection error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## خلاصه رفتاری endpoint
|
||||||
|
|
||||||
|
`POST /api/sensor-external-api/` این کارها را انجام میدهد:
|
||||||
|
|
||||||
|
1. داده سنسور را از بیرون میگیرد.
|
||||||
|
2. سنسور را با `physical_device_uuid` پیدا میکند.
|
||||||
|
3. payload را در جدول لاگ ذخیره میکند.
|
||||||
|
4. برای مزرعه notification میسازد.
|
||||||
|
5. داده را به سرویس AI در آدرس پیشفرض `POST http://ai-web:8000/api/farm-data/` میفرستد.
|
||||||
|
6. در نهایت نتیجه موفقیت را با notification برمیگرداند.
|
||||||
@@ -13,8 +13,19 @@ class SensorExternalRequestSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
|
class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField()
|
farm_uuid = serializers.UUIDField()
|
||||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
page = serializers.IntegerField(min_value=1)
|
||||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20)
|
page_size = serializers.IntegerField(min_value=1, max_value=100)
|
||||||
|
physical_device_uuid = serializers.UUIDField(required=False)
|
||||||
|
sensor_type = serializers.CharField(required=False, allow_blank=False)
|
||||||
|
date_from = serializers.DateField(required=False)
|
||||||
|
date_to = serializers.DateField(required=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
date_from = attrs.get("date_from")
|
||||||
|
date_to = attrs.get("date_to")
|
||||||
|
if date_from and date_to and date_from > date_to:
|
||||||
|
raise serializers.ValidationError({"date_to": "date_to must be greater than or equal to date_from."})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
|
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
|
||||||
@@ -36,14 +47,18 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def get_farm_sensor(self, obj):
|
def get_farm_sensor(self, obj):
|
||||||
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
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))
|
farm_sensor = farm_sensor_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:
|
if farm_sensor is None:
|
||||||
return None
|
return None
|
||||||
return FarmSensorLogSerializer(farm_sensor).data
|
return FarmSensorLogSerializer(farm_sensor).data
|
||||||
|
|
||||||
def get_sensor_catalog(self, obj):
|
def get_sensor_catalog(self, obj):
|
||||||
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
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))
|
farm_sensor = farm_sensor_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:
|
if farm_sensor is None or farm_sensor.sensor_catalog is None:
|
||||||
return None
|
return None
|
||||||
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
|
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import OperationalError, ProgrammingError, transaction
|
from django.db import OperationalError, ProgrammingError, transaction
|
||||||
|
|
||||||
@@ -9,13 +11,41 @@ from notifications.services import create_notification_for_farm_uuid
|
|||||||
from .models import SensorExternalRequestLog
|
from .models import SensorExternalRequestLog
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FarmDataForwardError(Exception):
|
class FarmDataForwardError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_sensor_external_request_logs_for_farm(*, farm_uuid):
|
def get_sensor_external_request_logs_for_farm(
|
||||||
|
*,
|
||||||
|
farm_uuid,
|
||||||
|
physical_device_uuid=None,
|
||||||
|
sensor_type=None,
|
||||||
|
date_from=None,
|
||||||
|
date_to=None,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
|
queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_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)
|
||||||
|
queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(created_at__date__gte=date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(created_at__date__lte=date_to)
|
||||||
|
|
||||||
|
return queryset.order_by("-created_at", "-id")
|
||||||
except (ProgrammingError, OperationalError) as exc:
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
@@ -37,12 +67,18 @@ def get_farm_sensor_map_for_logs(*, logs):
|
|||||||
|
|
||||||
farm_sensor_map = {}
|
farm_sensor_map = {}
|
||||||
for farm_sensor in farm_sensor_queryset:
|
for farm_sensor in farm_sensor_queryset:
|
||||||
key = (
|
exact_key = (
|
||||||
farm_sensor.farm.farm_uuid,
|
farm_sensor.farm.farm_uuid,
|
||||||
farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None,
|
farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None,
|
||||||
farm_sensor.physical_device_uuid,
|
farm_sensor.physical_device_uuid,
|
||||||
)
|
)
|
||||||
farm_sensor_map.setdefault(key, farm_sensor)
|
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
|
return farm_sensor_map
|
||||||
except (ProgrammingError, OperationalError) as exc:
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
@@ -66,12 +102,22 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph
|
|||||||
|
|
||||||
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
|
logger.warning(
|
||||||
|
"Sensor external notification start: physical_device_uuid=%s payload_type=%s payload_keys=%s",
|
||||||
|
physical_device_uuid,
|
||||||
|
type(payload).__name__,
|
||||||
|
sorted(payload.keys()) if isinstance(payload, dict) else None,
|
||||||
|
)
|
||||||
sensor = (
|
sensor = (
|
||||||
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
|
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
|
||||||
.filter(physical_device_uuid=physical_device_uuid)
|
.filter(physical_device_uuid=physical_device_uuid)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if sensor is None:
|
if sensor is None:
|
||||||
|
logger.error(
|
||||||
|
"Sensor external notification failed: physical device not found for uuid=%s",
|
||||||
|
physical_device_uuid,
|
||||||
|
)
|
||||||
raise ValueError("Physical device not found.")
|
raise ValueError("Physical device not found.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +128,7 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
|||||||
physical_device_uuid=sensor.physical_device_uuid,
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
return create_notification_for_farm_uuid(
|
notification = create_notification_for_farm_uuid(
|
||||||
farm_uuid=sensor.farm.farm_uuid,
|
farm_uuid=sensor.farm.farm_uuid,
|
||||||
title="Sensor external API request",
|
title="Sensor external API request",
|
||||||
message=f"Payload received from device {sensor.physical_device_uuid}.",
|
message=f"Payload received from device {sensor.physical_device_uuid}.",
|
||||||
@@ -94,32 +140,62 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
|||||||
"payload": payload,
|
"payload": payload,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Sensor external notification created: farm_uuid=%s sensor_catalog_uuid=%s physical_device_uuid=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
|
||||||
|
sensor.physical_device_uuid,
|
||||||
|
)
|
||||||
|
return notification
|
||||||
except (ProgrammingError, OperationalError) as exc:
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Sensor external notification failed due to database readiness: physical_device_uuid=%s",
|
||||||
|
physical_device_uuid,
|
||||||
|
)
|
||||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
|
|
||||||
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
|
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
sensor = (
|
sensor = (
|
||||||
FarmSensor.objects.select_related("farm", "farm__current_crop_area")
|
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
|
||||||
.filter(physical_device_uuid=physical_device_uuid)
|
.filter(physical_device_uuid=physical_device_uuid)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if sensor is None:
|
if sensor is None:
|
||||||
|
logger.error(
|
||||||
|
"Farm data forward failed: physical device not found for uuid=%s",
|
||||||
|
physical_device_uuid,
|
||||||
|
)
|
||||||
raise ValueError("Physical device not found.")
|
raise ValueError("Physical device not found.")
|
||||||
|
|
||||||
farm_boundary = _get_farm_boundary(sensor=sensor)
|
farm_boundary = _get_farm_boundary(sensor=sensor)
|
||||||
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
logger.error(
|
||||||
|
"Farm data forward failed: FARM_DATA_API_KEY missing for farm_uuid=%s physical_device_uuid=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
physical_device_uuid,
|
||||||
|
)
|
||||||
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
|
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
|
||||||
|
|
||||||
|
sensor_key = _get_sensor_key(sensor=sensor)
|
||||||
|
normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload)
|
||||||
request_payload = {
|
request_payload = {
|
||||||
"farm_uuid": str(sensor.farm.farm_uuid),
|
"farm_uuid": str(sensor.farm.farm_uuid),
|
||||||
"farm_boundary": farm_boundary,
|
"farm_boundary": farm_boundary,
|
||||||
"sensor_payload": {
|
"sensor_key": sensor_key,
|
||||||
sensor.name or str(sensor.physical_device_uuid): payload,
|
"sensor_payload": normalized_sensor_payload,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
logger.warning(
|
||||||
|
"Farm data forward start: farm_uuid=%s physical_device_uuid=%s sensor_key=%s payload_keys=%s boundary_type=%s boundary_points=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
physical_device_uuid,
|
||||||
|
sensor_key,
|
||||||
|
sorted(normalized_sensor_payload.keys()) if isinstance(normalized_sensor_payload, dict) else None,
|
||||||
|
farm_boundary.get("type") if isinstance(farm_boundary, dict) else None,
|
||||||
|
len(farm_boundary.get("coordinates", [[]])[0]) if isinstance(farm_boundary, dict) and farm_boundary.get("coordinates") else None,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = external_api_request(
|
response = external_api_request(
|
||||||
@@ -135,20 +211,46 @@ def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except ExternalAPIRequestError as exc:
|
except ExternalAPIRequestError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Farm data forward request exception: farm_uuid=%s physical_device_uuid=%s sensor_key=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
physical_device_uuid,
|
||||||
|
sensor_key,
|
||||||
|
)
|
||||||
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
|
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
response_body = response.data
|
response_body = response.data
|
||||||
|
logger.error(
|
||||||
|
"Farm data forward rejected: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s response=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
physical_device_uuid,
|
||||||
|
sensor_key,
|
||||||
|
response.status_code,
|
||||||
|
response_body,
|
||||||
|
)
|
||||||
raise FarmDataForwardError(
|
raise FarmDataForwardError(
|
||||||
f"Farm data API returned status {response.status_code}: {response_body}"
|
f"Farm data API returned status {response.status_code}: {response_body}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Farm data forward success: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
physical_device_uuid,
|
||||||
|
sensor_key,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
return request_payload
|
return request_payload
|
||||||
|
|
||||||
|
|
||||||
def _get_farm_boundary(*, sensor):
|
def _get_farm_boundary(*, sensor):
|
||||||
crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first()
|
crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first()
|
||||||
if crop_area is None:
|
if crop_area is None:
|
||||||
|
logger.error(
|
||||||
|
"Farm data forward failed: no farm boundary configured for farm_uuid=%s physical_device_uuid=%s",
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
sensor.physical_device_uuid,
|
||||||
|
)
|
||||||
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
|
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
|
||||||
|
|
||||||
geometry = crop_area.geometry or {}
|
geometry = crop_area.geometry or {}
|
||||||
@@ -156,10 +258,33 @@ def _get_farm_boundary(*, sensor):
|
|||||||
geometry = geometry.get("geometry") or {}
|
geometry = geometry.get("geometry") or {}
|
||||||
|
|
||||||
if geometry.get("type") != "Polygon":
|
if geometry.get("type") != "Polygon":
|
||||||
|
logger.error(
|
||||||
|
"Farm data forward failed: invalid boundary geometry type=%s for farm_uuid=%s physical_device_uuid=%s",
|
||||||
|
geometry.get("type"),
|
||||||
|
sensor.farm.farm_uuid,
|
||||||
|
sensor.physical_device_uuid,
|
||||||
|
)
|
||||||
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
|
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
|
||||||
|
|
||||||
return geometry
|
return geometry
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sensor_payload(*, sensor_key, sensor_payload):
|
||||||
|
if not sensor_payload:
|
||||||
|
return {}
|
||||||
|
if not isinstance(sensor_payload, dict):
|
||||||
|
raise FarmDataForwardError("`payload` must be a JSON object.")
|
||||||
|
|
||||||
|
if all(isinstance(value, dict) for value in sensor_payload.values()):
|
||||||
|
return sensor_payload
|
||||||
|
return {sensor_key: sensor_payload}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sensor_key(*, sensor):
|
||||||
|
if sensor.sensor_catalog and sensor.sensor_catalog.code:
|
||||||
|
return sensor.sensor_catalog.code
|
||||||
|
return "sensor-7-1"
|
||||||
|
|
||||||
|
|
||||||
def _get_farm_data_path():
|
def _get_farm_data_path():
|
||||||
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
|
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIRequestFactory
|
from rest_framework.test import APIRequestFactory
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
@@ -118,8 +121,9 @@ class SensorExternalAPIViewTests(TestCase):
|
|||||||
payload={
|
payload={
|
||||||
"farm_uuid": str(self.farm.farm_uuid),
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
"farm_boundary": self.crop_area.geometry,
|
"farm_boundary": self.crop_area.geometry,
|
||||||
|
"sensor_key": self.sensor_catalog.code,
|
||||||
"sensor_payload": {
|
"sensor_payload": {
|
||||||
"sensor-7-1": {"temp": 12},
|
self.sensor_catalog.code: {"temp": 12},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
headers={
|
headers={
|
||||||
@@ -202,6 +206,7 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
|
|||||||
email="sensor-external-log@example.com",
|
email="sensor-external-log@example.com",
|
||||||
phone_number="09120000016",
|
phone_number="09120000016",
|
||||||
)
|
)
|
||||||
|
self.access_token = str(AccessToken.for_user(self.user))
|
||||||
self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی")
|
self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی")
|
||||||
self.farm = FarmHub.objects.create(
|
self.farm = FarmHub.objects.create(
|
||||||
owner=self.user,
|
owner=self.user,
|
||||||
@@ -261,17 +266,31 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
|
|||||||
payload={"temp": 24},
|
payload={"temp": 24},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_requires_api_key(self):
|
def test_requires_bearer_token(self):
|
||||||
request = self.factory.get(f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}")
|
request = self.factory.get(
|
||||||
|
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=20"
|
||||||
|
)
|
||||||
|
|
||||||
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_requires_page_and_page_size(self):
|
||||||
|
request = self.factory.get(
|
||||||
|
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("page", response.data)
|
||||||
|
self.assertIn("page_size", response.data)
|
||||||
|
|
||||||
def test_returns_paginated_logs_for_farm_uuid(self):
|
def test_returns_paginated_logs_for_farm_uuid(self):
|
||||||
request = self.factory.get(
|
request = self.factory.get(
|
||||||
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1",
|
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1",
|
||||||
HTTP_X_API_KEY="12345",
|
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
@@ -301,3 +320,61 @@ class SensorExternalRequestLogListAPIViewTests(TestCase):
|
|||||||
response.data["data"][0]["farm_sensor"]["physical_device_uuid"],
|
response.data["data"][0]["farm_sensor"]["physical_device_uuid"],
|
||||||
str(self.second_sensor.physical_device_uuid),
|
str(self.second_sensor.physical_device_uuid),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(response.data["data"][0]["payload"]["temp"], 18)
|
||||||
|
self.assertIsInstance(response.data["data"][0]["payload"]["temp"], int)
|
||||||
|
|
||||||
|
def test_filters_logs_by_physical_device_uuid(self):
|
||||||
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensor-external-api/logs/"
|
||||||
|
f"?farm_uuid={self.farm_uuid}"
|
||||||
|
f"&physical_device_uuid={self.first_sensor.physical_device_uuid}"
|
||||||
|
"&page=1&page_size=20"
|
||||||
|
),
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["count"], 1)
|
||||||
|
self.assertEqual(response.data["data"][0]["id"], self.first_log.id)
|
||||||
|
|
||||||
|
def test_filters_logs_by_sensor_type(self):
|
||||||
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensor-external-api/logs/"
|
||||||
|
f"?farm_uuid={self.farm_uuid}"
|
||||||
|
"&sensor_type=soil_sensor"
|
||||||
|
"&page=1&page_size=20"
|
||||||
|
),
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["count"], 1)
|
||||||
|
self.assertEqual(response.data["data"][0]["id"], self.second_log.id)
|
||||||
|
|
||||||
|
def test_filters_logs_by_date_range(self):
|
||||||
|
older_timestamp = datetime(2025, 5, 1, 10, 0, tzinfo=dt_timezone.utc)
|
||||||
|
newer_timestamp = datetime(2025, 5, 2, 11, 0, tzinfo=dt_timezone.utc)
|
||||||
|
SensorExternalRequestLog.objects.filter(id=self.first_log.id).update(created_at=older_timestamp)
|
||||||
|
SensorExternalRequestLog.objects.filter(id=self.second_log.id).update(created_at=newer_timestamp)
|
||||||
|
|
||||||
|
request = self.factory.get(
|
||||||
|
(
|
||||||
|
"/api/sensor-external-api/logs/"
|
||||||
|
f"?farm_uuid={self.farm_uuid}"
|
||||||
|
"&date_from=2025-05-02&date_to=2025-05-02"
|
||||||
|
"&page=1&page_size=20"
|
||||||
|
),
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = SensorExternalRequestLogListAPIView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["count"], 1)
|
||||||
|
self.assertEqual(response.data["data"][0]["id"], self.second_log.id)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
|
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
|
|
||||||
from config.swagger import code_response
|
from config.swagger import code_response
|
||||||
from notifications.serializers import FarmNotificationSerializer
|
from notifications.serializers import FarmNotificationSerializer
|
||||||
@@ -24,6 +26,9 @@ from .services import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SensorExternalRequestLogPagination(PageNumberPagination):
|
class SensorExternalRequestLogPagination(PageNumberPagination):
|
||||||
page_size = 20
|
page_size = 20
|
||||||
page_size_query_param = "page_size"
|
page_size_query_param = "page_size"
|
||||||
@@ -37,6 +42,24 @@ class SensorExternalAPIView(APIView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Sensor External API"],
|
tags=["Sensor External API"],
|
||||||
request=SensorExternalRequestSerializer,
|
request=SensorExternalRequestSerializer,
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Sensor External API Request",
|
||||||
|
value={
|
||||||
|
"uuid": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"payload": {
|
||||||
|
"moisture_percent": 32.5,
|
||||||
|
"temperature_c": 21.3,
|
||||||
|
"ph": 6.7,
|
||||||
|
"ec_ds_m": 1.1,
|
||||||
|
"nitrogen_mg_kg": 42,
|
||||||
|
"phosphorus_mg_kg": 18,
|
||||||
|
"potassium_mg_kg": 210,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request_only=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name="X-API-Key",
|
name="X-API-Key",
|
||||||
@@ -57,6 +80,13 @@ class SensorExternalAPIView(APIView):
|
|||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = SensorExternalRequestSerializer(data=request.data)
|
serializer = SensorExternalRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
logger.warning(
|
||||||
|
"Sensor external API POST received: uuid=%s payload_keys=%s",
|
||||||
|
serializer.validated_data["uuid"],
|
||||||
|
sorted(serializer.validated_data.get("payload", {}).keys())
|
||||||
|
if isinstance(serializer.validated_data.get("payload"), dict)
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
notification = create_sensor_external_notification(
|
notification = create_sensor_external_notification(
|
||||||
@@ -69,18 +99,31 @@ class SensorExternalAPIView(APIView):
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
if "not migrated" in str(exc):
|
if "not migrated" in str(exc):
|
||||||
|
logger.exception(
|
||||||
|
"Sensor external API POST failed due to missing migrations: uuid=%s",
|
||||||
|
serializer.validated_data["uuid"],
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 503, "msg": "Required tables are not ready. Run migrations."},
|
{"code": 503, "msg": "Required tables are not ready. Run migrations."},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
logger.exception(
|
||||||
|
"Sensor external API POST failed due to missing physical device: uuid=%s",
|
||||||
|
serializer.validated_data["uuid"],
|
||||||
|
)
|
||||||
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
except FarmDataForwardError as exc:
|
except FarmDataForwardError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Sensor external API POST failed while forwarding to farm data: uuid=%s",
|
||||||
|
serializer.validated_data["uuid"],
|
||||||
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 503, "msg": str(exc)},
|
{"code": 503, "msg": str(exc)},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = FarmNotificationSerializer(notification).data
|
data = FarmNotificationSerializer(notification).data
|
||||||
|
logger.warning("Sensor external API POST succeeded: uuid=%s", serializer.validated_data["uuid"])
|
||||||
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,9 +136,12 @@ class SensorExternalRequestLogListAPIView(APIView):
|
|||||||
tags=["Sensor External API"],
|
tags=["Sensor External API"],
|
||||||
parameters=[
|
parameters=[
|
||||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"),
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"),
|
||||||
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False),
|
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True),
|
||||||
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False),
|
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True),
|
||||||
|
OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False),
|
||||||
|
OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False),
|
||||||
],
|
],
|
||||||
responses={
|
responses={
|
||||||
200: code_response(
|
200: code_response(
|
||||||
@@ -118,6 +164,10 @@ class SensorExternalRequestLogListAPIView(APIView):
|
|||||||
try:
|
try:
|
||||||
queryset = get_sensor_external_request_logs_for_farm(
|
queryset = get_sensor_external_request_logs_for_farm(
|
||||||
farm_uuid=serializer.validated_data["farm_uuid"],
|
farm_uuid=serializer.validated_data["farm_uuid"],
|
||||||
|
physical_device_uuid=serializer.validated_data.get("physical_device_uuid"),
|
||||||
|
sensor_type=serializer.validated_data.get("sensor_type"),
|
||||||
|
date_from=serializer.validated_data.get("date_from"),
|
||||||
|
date_to=serializer.validated_data.get("date_to"),
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
Reference in New Issue
Block a user