UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DeviceHubConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "device_hub"
|
||||
verbose_name = "Device Hub"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.conf import settings
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class SensorExternalAPIKeyAuthentication(BaseAuthentication):
|
||||
keyword = "Api-Key"
|
||||
|
||||
def authenticate(self, request):
|
||||
provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization")
|
||||
expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345")
|
||||
if not provided_key:
|
||||
raise AuthenticationFailed("API key is required.")
|
||||
if provided_key.startswith(f"{self.keyword} "):
|
||||
provided_key = provided_key[len(self.keyword) + 1 :]
|
||||
if provided_key != expected_key:
|
||||
raise AuthenticationFailed("Invalid API key.")
|
||||
return (None, None)
|
||||
@@ -0,0 +1,51 @@
|
||||
from .models import SensorCatalog
|
||||
|
||||
|
||||
SENSOR_CATALOG_ITEMS = [
|
||||
{
|
||||
"code": "sensor_7_soil_moisture_sensor_v1_2",
|
||||
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
|
||||
"description": (
|
||||
"This sensor is typically the YL-69 or FC-28 soil moisture sensor. "
|
||||
"It measures only soil moisture and provides analog and digital outputs. "
|
||||
"It does not report soil temperature, pH, or nutrients."
|
||||
),
|
||||
"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,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def seed_sensor_catalog():
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
results = []
|
||||
|
||||
for item in SENSOR_CATALOG_ITEMS:
|
||||
sensor, created = SensorCatalog.objects.update_or_create(
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"description": item["description"],
|
||||
"customizable_fields": item["customizable_fields"],
|
||||
"supported_power_sources": item["supported_power_sources"],
|
||||
"returned_data_fields": item["returned_data_fields"],
|
||||
"sample_payload": item["sample_payload"],
|
||||
"is_active": item["is_active"],
|
||||
},
|
||||
)
|
||||
results.append((sensor, created))
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
return results, created_count, updated_count
|
||||
|
||||
@@ -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 device_hub.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,22 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from device_hub.catalog_seed import seed_sensor_catalog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed sensor catalog data."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
results, created_count, updated_count = seed_sensor_catalog()
|
||||
for sensor, created in results:
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("farm_hub", "0009_farmhub_irrigation_method_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="SensorCatalog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("code", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
("name", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
("customizable_fields", models.JSONField(blank=True, default=list)),
|
||||
("supported_power_sources", models.JSONField(blank=True, default=list)),
|
||||
("returned_data_fields", models.JSONField(blank=True, default=list)),
|
||||
("sample_payload", models.JSONField(blank=True, default=dict)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FarmSensor",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("physical_device_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("specifications", models.JSONField(blank=True, default=dict)),
|
||||
("power_source", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")),
|
||||
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_sensors", to="device_hub.sensorcatalog")),
|
||||
],
|
||||
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SensorExternalRequestLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("farm_uuid", models.UUIDField(db_index=True)),
|
||||
("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)),
|
||||
("physical_device_uuid", models.UUIDField(db_index=True)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={"db_table": "sensor_external_request_logs", "ordering": ["-created_at", "-id"]},
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("device_hub", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("device_hub", "0002_absorb_sensor_7_in_1"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("device_hub", "0003_absorb_sensor_external_api"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
AVG_SOIL_MOISTURE = {
|
||||
"id": "avg_soil_moisture",
|
||||
"title": "میانگین رطوبت خاک",
|
||||
"subtitle": "سنسور 7 در 1 خاک",
|
||||
"stats": "45%",
|
||||
"avatarColor": "primary",
|
||||
"avatarIcon": "tabler-droplet",
|
||||
"chipText": "متوسط",
|
||||
"chipColor": "warning",
|
||||
}
|
||||
|
||||
|
||||
SENSOR_VALUES_LIST = {
|
||||
"sensor": {
|
||||
"name": "سنسور 7 در 1 خاک",
|
||||
"physicalDeviceUuid": None,
|
||||
"sensorCatalogCode": "sensor-7-in-1",
|
||||
"updatedAt": None,
|
||||
},
|
||||
"sensors": [
|
||||
{"id": "soil_moisture", "title": "45%", "subtitle": "رطوبت خاک", "trendNumber": 1.5, "trend": "positive", "unit": "%"},
|
||||
{"id": "soil_temperature", "title": "22.5°C", "subtitle": "دمای خاک", "trendNumber": 0.8, "trend": "positive", "unit": "°C"},
|
||||
{"id": "soil_ph", "title": "6.8", "subtitle": "pH خاک", "trendNumber": 0.1, "trend": "positive", "unit": "pH"},
|
||||
{"id": "electrical_conductivity", "title": "1.2 dS/m", "subtitle": "هدایت الکتریکی", "trendNumber": -0.1, "trend": "negative", "unit": "dS/m"},
|
||||
{"id": "nitrogen", "title": "30 mg/kg", "subtitle": "نیتروژن", "trendNumber": 2.0, "trend": "positive", "unit": "mg/kg"},
|
||||
{"id": "phosphorus", "title": "15 mg/kg", "subtitle": "فسفر", "trendNumber": 1.0, "trend": "positive", "unit": "mg/kg"},
|
||||
{"id": "potassium", "title": "20 mg/kg", "subtitle": "پتاسیم", "trendNumber": -1.0, "trend": "negative", "unit": "mg/kg"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
SENSOR_RADAR_CHART = {
|
||||
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
|
||||
"series": [{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]}, {"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]}],
|
||||
}
|
||||
|
||||
|
||||
SENSOR_COMPARISON_CHART = {
|
||||
"currentValue": 45,
|
||||
"vsLastWeek": "+4.7%",
|
||||
"vsLastWeekValue": 4.7,
|
||||
"categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||
"series": [{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]}, {"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]}],
|
||||
}
|
||||
|
||||
|
||||
ANOMALY_DETECTION_CARD = {
|
||||
"anomalies": [{"sensor": "هدایت الکتریکی", "value": "1.2 dS/m", "expected": "0.8-1.1 dS/m", "deviation": "+0.1 dS/m", "severity": "warning"}]
|
||||
}
|
||||
|
||||
|
||||
SOIL_MOISTURE_HEATMAP = {
|
||||
"zones": ["سنسور 7 در 1 خاک"],
|
||||
"hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||
"series": [{"name": "سنسور 7 در 1 خاک", "data": [{"x": "08:00", "y": 42}, {"x": "10:00", "y": 44}, {"x": "12:00", "y": 45}, {"x": "14:00", "y": 47}, {"x": "16:00", "y": 46}, {"x": "18:00", "y": 45}, {"x": "20:00", "y": 45}]}],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import uuid as uuid_lib
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SensorCatalog(models.Model):
|
||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||
code = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
description = models.TextField(blank=True, default="")
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
db_table = "sensor_catalogs"
|
||||
ordering = ["code"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class FarmSensor(models.Model):
|
||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="sensors")
|
||||
sensor_catalog = models.ForeignKey(
|
||||
SensorCatalog,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="farm_sensors",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
|
||||
name = models.CharField(max_length=255)
|
||||
sensor_type = models.CharField(max_length=255, blank=True, default="")
|
||||
is_active = models.BooleanField(default=True)
|
||||
specifications = models.JSONField(default=dict, blank=True)
|
||||
power_source = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_sensors"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.uuid})"
|
||||
|
||||
|
||||
class SensorExternalRequestLog(models.Model):
|
||||
farm_uuid = models.UUIDField(db_index=True)
|
||||
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
|
||||
physical_device_uuid = models.UUIDField(db_index=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "sensor_external_request_logs"
|
||||
ordering = ["-created_at", "-id"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.physical_device_uuid}:{self.created_at.isoformat()}"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from farm_hub.seeds import seed_admin_farm
|
||||
|
||||
from .models import FarmSensor, SensorCatalog, 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)}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
|
||||
|
||||
urlpatterns = [
|
||||
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||
path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
|
||||
path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"),
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import SensorCatalogListView
|
||||
|
||||
urlpatterns = [
|
||||
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
|
||||
|
||||
urlpatterns = [
|
||||
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
||||
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from soil.serializers import SoilAnomalyDetectionSerializer, SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, SoilRadarChartSerializer
|
||||
|
||||
|
||||
class Sensor7In1MetaSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(required=False, allow_blank=True)
|
||||
physicalDeviceUuid = serializers.CharField(required=False, allow_null=True)
|
||||
sensorCatalogCode = serializers.CharField(required=False, allow_blank=True)
|
||||
updatedAt = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class Sensor7In1ValueSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(required=False, allow_blank=True)
|
||||
title = serializers.CharField(required=False, allow_blank=True)
|
||||
subtitle = serializers.CharField(required=False, allow_blank=True)
|
||||
trendNumber = serializers.FloatField(required=False)
|
||||
trend = serializers.CharField(required=False, allow_blank=True)
|
||||
unit = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class Sensor7In1ValuesListSerializer(serializers.Serializer):
|
||||
sensor = Sensor7In1MetaSerializer(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):
|
||||
sensor = Sensor7In1MetaSerializer(required=False)
|
||||
sensorValuesList = Sensor7In1ValuesListSerializer(required=False)
|
||||
avgSoilMoisture = SoilKpiSerializer(required=False)
|
||||
sensorRadarChart = SoilRadarChartSerializer(required=False)
|
||||
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
||||
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
||||
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SensorExternalRequestSerializer(serializers.Serializer):
|
||||
uuid = serializers.UUIDField()
|
||||
payload = serializers.JSONField(required=False, default=dict)
|
||||
|
||||
|
||||
class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField()
|
||||
page = serializers.IntegerField(min_value=1)
|
||||
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 FarmSensorLogSerializer(serializers.ModelSerializer):
|
||||
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmSensor
|
||||
fields = [
|
||||
"uuid",
|
||||
"sensor_catalog_uuid",
|
||||
"physical_device_uuid",
|
||||
"name",
|
||||
"sensor_type",
|
||||
"is_active",
|
||||
"specifications",
|
||||
"power_source",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class SensorCatalogLogSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SensorCatalog
|
||||
fields = [
|
||||
"uuid",
|
||||
"code",
|
||||
"name",
|
||||
"description",
|
||||
"customizable_fields",
|
||||
"supported_power_sources",
|
||||
"returned_data_fields",
|
||||
"sample_payload",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
|
||||
farm_sensor = serializers.SerializerMethodField()
|
||||
sensor_catalog = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SensorExternalRequestLog
|
||||
fields = [
|
||||
"id",
|
||||
"farm_uuid",
|
||||
"sensor_catalog_uuid",
|
||||
"physical_device_uuid",
|
||||
"farm_sensor",
|
||||
"sensor_catalog",
|
||||
"payload",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
def get_farm_sensor(self, obj):
|
||||
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
||||
farm_sensor = farm_sensor_map.get(
|
||||
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
|
||||
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
|
||||
if farm_sensor is None:
|
||||
return None
|
||||
return FarmSensorLogSerializer(farm_sensor).data
|
||||
|
||||
def get_sensor_catalog(self, obj):
|
||||
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
||||
farm_sensor = farm_sensor_map.get(
|
||||
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
|
||||
) 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:
|
||||
return None
|
||||
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
|
||||
@@ -0,0 +1,559 @@
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from external_api_adapter import request as external_api_request
|
||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||
from notifications.services import create_notification_for_farm_uuid
|
||||
|
||||
from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP
|
||||
from .models import FarmSensor, SensorExternalRequestLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FarmDataForwardError(Exception):
|
||||
pass
|
||||
|
||||
SENSOR_FIELDS = [
|
||||
{"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"},
|
||||
{"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"},
|
||||
{"id": "soil_ph", "label": "pH خاک", "unit": "pH", "payload_keys": ("soil_ph", "soilPh", "ph"), "ideal_min": 6.0, "ideal_max": 7.5, "radar_label": "pH"},
|
||||
{"id": "electrical_conductivity", "label": "هدایت الکتریکی", "unit": "dS/m", "payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"), "ideal_min": 0.8, "ideal_max": 1.8, "radar_label": "EC"},
|
||||
{"id": "nitrogen", "label": "نیتروژن", "unit": "mg/kg", "payload_keys": ("nitrogen", "n"), "ideal_min": 20.0, "ideal_max": 40.0, "radar_label": "نیتروژن"},
|
||||
{"id": "phosphorus", "label": "فسفر", "unit": "mg/kg", "payload_keys": ("phosphorus", "p"), "ideal_min": 10.0, "ideal_max": 25.0, "radar_label": "فسفر"},
|
||||
{"id": "potassium", "label": "پتاسیم", "unit": "mg/kg", "payload_keys": ("potassium", "k"), "ideal_min": 15.0, "ideal_max": 35.0, "radar_label": "پتاسیم"},
|
||||
]
|
||||
MAX_HISTORY_ITEMS = 20
|
||||
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 get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid=None, sensor_type=None, date_from=None, date_to=None):
|
||||
try:
|
||||
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:
|
||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||
|
||||
|
||||
def get_farm_sensor_map_for_logs(*, logs):
|
||||
try:
|
||||
logs = list(logs)
|
||||
if not logs:
|
||||
return {}
|
||||
farm_sensor_queryset = FarmSensor.objects.select_related("farm", "sensor_catalog").filter(
|
||||
farm__farm_uuid__in={log.farm_uuid for log in logs},
|
||||
physical_device_uuid__in={log.physical_device_uuid for log in logs},
|
||||
).order_by("-created_at", "-id")
|
||||
farm_sensor_map = {}
|
||||
for farm_sensor in farm_sensor_queryset:
|
||||
exact_key = (farm_sensor.farm.farm_uuid, farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, farm_sensor.physical_device_uuid)
|
||||
fallback_key = (farm_sensor.farm.farm_uuid, None, farm_sensor.physical_device_uuid)
|
||||
farm_sensor_map.setdefault(exact_key, farm_sensor)
|
||||
farm_sensor_map.setdefault(fallback_key, farm_sensor)
|
||||
return farm_sensor_map
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||
|
||||
|
||||
def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, physical_device_uuid):
|
||||
try:
|
||||
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid, sensor_catalog_uuid=sensor_catalog_uuid, physical_device_uuid=physical_device_uuid).order_by("-created_at", "-id").first()
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||
|
||||
|
||||
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
||||
payload = payload or {}
|
||||
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
|
||||
if sensor is None:
|
||||
raise ValueError("Physical device not found.")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
SensorExternalRequestLog.objects.create(
|
||||
farm_uuid=sensor.farm.farm_uuid,
|
||||
sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
|
||||
physical_device_uuid=sensor.physical_device_uuid,
|
||||
payload=payload,
|
||||
)
|
||||
return create_notification_for_farm_uuid(
|
||||
farm_uuid=sensor.farm.farm_uuid,
|
||||
title="Sensor external API request",
|
||||
message=f"Payload received from device {sensor.physical_device_uuid}.",
|
||||
level="info",
|
||||
metadata={"farm_uuid": str(sensor.farm.farm_uuid), "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, "physical_device_uuid": str(sensor.physical_device_uuid), "payload": payload},
|
||||
)
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||
|
||||
|
||||
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
|
||||
payload = payload or {}
|
||||
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
|
||||
if sensor is None:
|
||||
raise ValueError("Physical device not found.")
|
||||
farm_boundary = _get_farm_boundary(sensor=sensor)
|
||||
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
||||
if not api_key:
|
||||
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 = {"farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, "sensor_key": sensor_key, "sensor_payload": normalized_sensor_payload}
|
||||
try:
|
||||
response = external_api_request(
|
||||
"ai",
|
||||
_get_farm_data_path(),
|
||||
method="POST",
|
||||
payload=request_payload,
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json", "X-API-Key": api_key, "Authorization": f"Api-Key {api_key}"},
|
||||
)
|
||||
except ExternalAPIRequestError as exc:
|
||||
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise FarmDataForwardError(f"Farm data API returned status {response.status_code}: {response.data}")
|
||||
return request_payload
|
||||
|
||||
|
||||
def _get_farm_boundary(*, sensor):
|
||||
crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first()
|
||||
if crop_area is None:
|
||||
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
|
||||
geometry = crop_area.geometry or {}
|
||||
if geometry.get("type") == "Feature":
|
||||
geometry = geometry.get("geometry") or {}
|
||||
if geometry.get("type") != "Polygon":
|
||||
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
|
||||
return geometry
|
||||
|
||||
|
||||
def _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():
|
||||
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
|
||||
|
||||
|
||||
def _to_float(value):
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
if isinstance(payload.get("payload"), dict):
|
||||
payload = payload["payload"]
|
||||
if isinstance(payload.get("data"), dict):
|
||||
nested = payload["data"]
|
||||
if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS):
|
||||
payload = nested
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_numeric_payload(payload):
|
||||
payload = _extract_payload(payload)
|
||||
return {key: numeric_value for key, value in payload.items() if (numeric_value := _to_float(value)) is not None}
|
||||
|
||||
|
||||
def _extract_readings(payload):
|
||||
payload = _extract_payload(payload)
|
||||
readings = {}
|
||||
for field in SENSOR_FIELDS:
|
||||
for key in field["payload_keys"]:
|
||||
value = _to_float(payload.get(key))
|
||||
if value is not None:
|
||||
readings[field["id"]] = value
|
||||
break
|
||||
return readings
|
||||
|
||||
|
||||
def _format_number(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.1f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_value(value, unit):
|
||||
number = _format_number(value)
|
||||
if not number:
|
||||
return number
|
||||
if unit in {"", "pH"}:
|
||||
return number
|
||||
if unit in {"%", "°C"}:
|
||||
return f"{number}{unit}"
|
||||
return f"{number} {unit}"
|
||||
|
||||
|
||||
def _format_range(field):
|
||||
lower = _format_number(field["ideal_min"])
|
||||
upper = _format_number(field["ideal_max"])
|
||||
if field["unit"] in {"", "pH"}:
|
||||
return f"{lower}-{upper}"
|
||||
return f"{lower}-{upper} {field['unit']}"
|
||||
|
||||
|
||||
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 _get_sensor_context(farm=None):
|
||||
if farm is None:
|
||||
return None
|
||||
primary_sensor = get_primary_soil_sensor(farm=farm)
|
||||
if primary_sensor is None:
|
||||
return None
|
||||
try:
|
||||
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid)
|
||||
except ValueError:
|
||||
return None
|
||||
history = []
|
||||
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||
readings = _extract_readings(log.payload)
|
||||
if readings:
|
||||
history.append((log, readings))
|
||||
if not history:
|
||||
return None
|
||||
latest_log, latest_readings = history[0]
|
||||
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log])
|
||||
farm_sensor = farm_sensor_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
|
||||
return {"farm_sensor": farm_sensor, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history}
|
||||
|
||||
|
||||
def _build_sensor_meta(context, fallback_sensor):
|
||||
sensor = deepcopy(fallback_sensor)
|
||||
if not context:
|
||||
return sensor
|
||||
farm_sensor = context.get("farm_sensor")
|
||||
latest_log = context["latest_log"]
|
||||
sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid)
|
||||
sensor["updatedAt"] = latest_log.created_at.isoformat()
|
||||
if farm_sensor is not None:
|
||||
sensor["name"] = farm_sensor.name or sensor["name"]
|
||||
if farm_sensor.sensor_catalog is not None:
|
||||
sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code
|
||||
return sensor
|
||||
|
||||
|
||||
def _calculate_status_chip(value):
|
||||
if value is None:
|
||||
return ("نامشخص", "secondary", "secondary")
|
||||
if value >= 60:
|
||||
return ("بهینه", "success", "primary")
|
||||
if value >= 45:
|
||||
return ("متوسط", "warning", "warning")
|
||||
return ("کم", "error", "error")
|
||||
|
||||
|
||||
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_VALUES_LIST)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
data["sensor"] = _build_sensor_meta(context, data["sensor"])
|
||||
if not context:
|
||||
return data
|
||||
latest_readings = context["latest_readings"]
|
||||
previous_readings = context["previous_readings"]
|
||||
sensors = []
|
||||
for field in SENSOR_FIELDS:
|
||||
value = latest_readings.get(field["id"])
|
||||
if value is None:
|
||||
continue
|
||||
previous = previous_readings.get(field["id"])
|
||||
change = 0.0 if previous is None else round(value - previous, 2)
|
||||
sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
|
||||
if sensors:
|
||||
data["sensors"] = sensors
|
||||
return data
|
||||
|
||||
|
||||
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
|
||||
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
moisture = context["latest_readings"].get("soil_moisture")
|
||||
if moisture is None:
|
||||
return data
|
||||
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
|
||||
data["stats"] = _format_value(moisture, "%")
|
||||
data["chipText"] = chip_text
|
||||
data["chipColor"] = chip_color
|
||||
data["avatarColor"] = avatar_color
|
||||
return data
|
||||
|
||||
|
||||
def _score_field(value, field):
|
||||
min_value = field["ideal_min"]
|
||||
max_value = field["ideal_max"]
|
||||
midpoint = (min_value + max_value) / 2
|
||||
half_span = max((max_value - min_value) / 2, 0.1)
|
||||
distance = abs(value - midpoint)
|
||||
if min_value <= value <= max_value:
|
||||
return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1)
|
||||
overflow = max(0.0, distance - half_span)
|
||||
return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1)
|
||||
|
||||
|
||||
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_RADAR_CHART)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
latest_readings = context["latest_readings"]
|
||||
scores, labels = [], []
|
||||
for field in SENSOR_FIELDS:
|
||||
value = latest_readings.get(field["id"])
|
||||
if value is None:
|
||||
continue
|
||||
labels.append(field["radar_label"])
|
||||
scores.append(_score_field(value, field))
|
||||
if labels:
|
||||
data["labels"] = labels
|
||||
data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}]
|
||||
return data
|
||||
|
||||
|
||||
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
|
||||
data = deepcopy(SENSOR_COMPARISON_CHART)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||
moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None]
|
||||
if not moisture_points:
|
||||
return data
|
||||
categories = [item[0] for item in moisture_points]
|
||||
values = [round(item[1], 2) for item in moisture_points]
|
||||
current_value = values[-1]
|
||||
baseline_value = values[0] if len(values) > 1 else 55.0
|
||||
percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0
|
||||
data["currentValue"] = round(current_value, 2)
|
||||
data["vsLastWeekValue"] = round(percent_change, 2)
|
||||
data["vsLastWeek"] = f"{percent_change:+.1f}%"
|
||||
data["categories"] = categories
|
||||
data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}]
|
||||
return data
|
||||
|
||||
|
||||
def _build_anomaly_item(field, value):
|
||||
lower = field["ideal_min"]
|
||||
upper = field["ideal_max"]
|
||||
if lower <= value <= upper:
|
||||
return None
|
||||
deviation = value - upper if value > upper else value - lower
|
||||
severity = "warning"
|
||||
span = max(upper - lower, 0.1)
|
||||
if abs(deviation) >= span * 0.5:
|
||||
severity = "error"
|
||||
sign = "+" if deviation > 0 else ""
|
||||
return {"sensor": field["label"], "value": _format_value(value, field["unit"]), "expected": _format_range(field), "deviation": f"{sign}{_format_value(deviation, field['unit'])}", "severity": severity}
|
||||
|
||||
|
||||
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
|
||||
data = deepcopy(ANOMALY_DETECTION_CARD)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
anomalies = []
|
||||
for field in SENSOR_FIELDS:
|
||||
value = context["latest_readings"].get(field["id"])
|
||||
if value is None:
|
||||
continue
|
||||
anomaly = _build_anomaly_item(field, value)
|
||||
if anomaly is not None:
|
||||
anomalies.append(anomaly)
|
||||
data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخصها در بازه مجاز هستند", "deviation": "0", "severity": "success"}]
|
||||
return data
|
||||
|
||||
|
||||
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
|
||||
data = deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||
context = _get_sensor_context(farm) if context is None else context
|
||||
if not context:
|
||||
return data
|
||||
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||
chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None]
|
||||
if not chart_points:
|
||||
return data
|
||||
sensor_name = data["zones"][0]
|
||||
farm_sensor = context.get("farm_sensor")
|
||||
if farm_sensor is not None and farm_sensor.name:
|
||||
sensor_name = farm_sensor.name
|
||||
data["zones"] = [sensor_name]
|
||||
data["hours"] = [point["x"] for point in chart_points]
|
||||
data["series"] = [{"name": sensor_name, "data": chart_points}]
|
||||
return data
|
||||
|
||||
|
||||
def get_sensor_7_in_1_summary_data(farm=None):
|
||||
context = _get_sensor_context(farm)
|
||||
values_list = get_sensor_7_in_1_values_list_data(farm, context=context)
|
||||
return {"sensor": values_list["sensor"], "sensorValuesList": values_list, "avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context), "sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context), "sensorComparisonChart": get_sensor_7_in_1_comparison_chart_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)}
|
||||
|
||||
|
||||
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):
|
||||
return PERSIAN_WEEKDAYS[bucket_date.weekday()] if range_value == "7d" else bucket_date.strftime("%m/%d")
|
||||
|
||||
|
||||
def _format_percent_change(current_value, baseline_value):
|
||||
if not baseline_value:
|
||||
return "+0.0%"
|
||||
return f"{((current_value - baseline_value) / baseline_value) * 100:+.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 numeric_payload:
|
||||
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:
|
||||
normalized_payload = {_normalize_comparison_chart_field(key): value for key, value in grouped_logs[bucket_date].items()}
|
||||
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_data = series_map[ordered_field_names[0]]
|
||||
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}]}
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import Sensor7In1SummaryView, SensorCatalogListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
|
||||
|
||||
urlpatterns = [
|
||||
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
||||
path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema
|
||||
|
||||
from config.swagger import code_response, farm_uuid_query_param
|
||||
from farm_hub.models import FarmHub
|
||||
from notifications.serializers import FarmNotificationSerializer
|
||||
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
|
||||
|
||||
from .authentication import SensorExternalAPIKeyAuthentication
|
||||
from .sensor_serializers import Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
|
||||
from .serializers import SensorCatalogSerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
|
||||
from .services import FarmDataForwardError, create_sensor_external_notification, forward_sensor_payload_to_farm_data, get_farm_sensor_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data
|
||||
|
||||
|
||||
class SensorCatalogListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(tags=["Sensor Catalog"], responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))})
|
||||
def get(self, request):
|
||||
from .models import SensorCatalog
|
||||
return Response({"code": 200, "msg": "success", "data": SensorCatalogSerializer(SensorCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class Sensor7In1SummaryView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
required_feature_code = "sensor-7-in-1"
|
||||
|
||||
@staticmethod
|
||||
def _get_farm(request):
|
||||
farm_uuid = request.query_params.get("farm_uuid")
|
||||
if not farm_uuid:
|
||||
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||
try:
|
||||
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||
except FarmHub.DoesNotExist as 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(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class Sensor7In1RadarChartView(Sensor7In1SummaryView):
|
||||
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
|
||||
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())})
|
||||
def get(self, request):
|
||||
farm = self._get_farm(request)
|
||||
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, 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)
|
||||
return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), 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)
|
||||
return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), 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)
|
||||
return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SensorExternalRequestLogPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class SensorExternalAPIView(APIView):
|
||||
authentication_classes = [SensorExternalAPIKeyAuthentication]
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(tags=["Sensor External API"], 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=[OpenApiParameter(name="X-API-Key", type=OpenApiTypes.STR, location=OpenApiParameter.HEADER, required=True, default="12345", description="API key for sensor external API.")], responses={201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), 503: code_response("SensorExternalAPIUnavailableResponse")})
|
||||
def post(self, request):
|
||||
serializer = SensorExternalRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
|
||||
forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
|
||||
except ValueError as exc:
|
||||
if "not migrated" in str(exc):
|
||||
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
except FarmDataForwardError as exc:
|
||||
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return Response({"code": 201, "msg": "success", "data": FarmNotificationSerializer(notification).data}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class SensorExternalRequestLogListAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = SensorExternalRequestLogPagination
|
||||
|
||||
@extend_schema(tags=["Sensor External API"], parameters=[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=True), 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={200: code_response("SensorExternalRequestLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)}), 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), 503: code_response("SensorExternalRequestLogListUnavailableResponse")})
|
||||
def get(self, request):
|
||||
serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
queryset = get_sensor_external_request_logs_for_farm(farm_uuid=serializer.validated_data["farm_uuid"], 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:
|
||||
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
paginator = self.pagination_class()
|
||||
paginator.page_size = serializer.validated_data["page_size"]
|
||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||
farm_sensor_map = get_farm_sensor_map_for_logs(logs=page)
|
||||
data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_sensor_map": farm_sensor_map}).data
|
||||
return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK)
|
||||
Reference in New Issue
Block a user