This commit is contained in:
2026-05-05 00:56:05 +03:30
parent 21b734f6a7
commit cfe60f6729
85 changed files with 1786 additions and 3840 deletions
+44
View File
@@ -0,0 +1,44 @@
ARG BASE_IMAGE=mirror-docker.runflare.com/library/python:3.10-slim-bookworm
FROM ${BASE_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
ARG APT_MIRROR=mirror-linux.runflare.com/debian
ARG APT_SECURITY_MIRROR=mirror-linux.runflare.com/debian-security
ARG PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple
ARG PIP_TRUSTED_HOST=mirror-pypi.runflare.com
WORKDIR /app
# Route Debian packages through the requested mirror.
RUN printf '%s\n' \
"deb https://${APT_MIRROR} bookworm main contrib non-free non-free-firmware" \
"deb https://${APT_MIRROR} bookworm-updates main contrib non-free non-free-firmware" \
"deb https://${APT_SECURITY_MIRROR} bookworm-security main contrib non-free non-free-firmware" \
> /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
# Route Python packages through the requested mirror.
RUN pip config set global.index-url "${PIP_INDEX_URL}" \
&& pip config set global.trusted-host "${PIP_TRUSTED_HOST}" \
&& pip install -r /app/requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . /app
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+2 -2
View File
@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
("farm_hub", "0006_seed_expanded_product_catalog"),
("sensor_catalog", "0003_sensorcatalog_code"),
("device_hub", "0001_initial"),
]
operations = [
@@ -61,7 +61,7 @@ class Migration(migrations.Migration):
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")),
("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")),
("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog")),
("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="device_hub.sensorcatalog")),
("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")),
],
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
+1 -1
View File
@@ -70,7 +70,7 @@ class AccessRule(models.Model):
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True)
products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("device_hub.SensorCatalog", related_name="access_rules", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Binary file not shown.
+1 -3
View File
@@ -40,14 +40,13 @@ INSTALLED_APPS = [
"auth.apps.AuthConfig",
"account.apps.AccountConfig",
"farm_hub.apps.FarmHubConfig",
"device_hub.apps.DeviceHubConfig",
"access_control.apps.AccessControlConfig",
"sensor_catalog.apps.SensorCatalogConfig",
"dashboard",
"crop_health.apps.CropHealthConfig",
"soil.apps.SoilConfig",
"crop_zoning",
"pest_detection",
"sensor_7_in_1.apps.Sensor7In1Config",
"water.apps.WaterConfig",
"irrigation",
"yield_harvest.apps.YieldHarvestConfig",
@@ -60,7 +59,6 @@ INSTALLED_APPS = [
"farmer_calendar.apps.FarmerCalendarConfig",
"farmer_todos.apps.FarmerTodosConfig",
"external_api_adapter.apps.ExternalApiAdapterConfig",
"sensor_external_api.apps.SensorExternalApiConfig",
"rest_framework",
"drf_spectacular",
"drf_spectacular_sidecar",
+4 -4
View File
@@ -11,7 +11,7 @@ urlpatterns = [
path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.urls")),
path("api/access-control/", include("access_control.urls")),
path("api/sensor-catalog/", include("sensor_catalog.urls")),
path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-health/", include("crop_health.urls")),
@@ -23,8 +23,8 @@ urlpatterns = [
path("api/pest-detection/", include("pest_detection.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/sensors/", include("sensor_7_in_1.comparison_urls")),
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
path("api/sensors/", include("device_hub.comparison_urls")),
path("api/irrigation/", include("irrigation.urls")),
path("api/weather/", include("water.weather_urls")),
@@ -39,5 +39,5 @@ urlpatterns = [
path("api/events/", include("farmer_calendar.urls")),
path("api/farmer-todos/", include("farmer_todos.urls")),
path("api/sensor-external-api/", include("sensor_external_api.urls")),
path("api/sensor-external-api/", include("device_hub.sensor_external_api_urls")),
]
+1 -1
View File
@@ -15,7 +15,7 @@ from farm_alerts.services import (
from fertilization.services import get_fertilization_dashboard_recommendation
from irrigation.services import get_irrigation_dashboard_recommendation
from pest_detection.services import get_risk_summary_data
from sensor_7_in_1.services import (
from device_hub.services import (
get_sensor_7_in_1_summary_data,
)
from yield_harvest.services import get_yield_harvest_summary_data
@@ -1,6 +1,8 @@
from django.apps import AppConfig
class SensorExternalApiConfig(AppConfig):
class DeviceHubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_external_api"
name = "device_hub"
verbose_name = "Device Hub"
@@ -9,14 +9,10 @@ class SensorExternalAPIKeyAuthentication(BaseAuthentication):
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)
@@ -1,4 +1,4 @@
from sensor_catalog.models import SensorCatalog
from .models import SensorCatalog
SENSOR_CATALOG_ITEMS = [
@@ -48,3 +48,4 @@ def seed_sensor_catalog():
updated_count += 1
return results, created_count, updated_count
@@ -2,9 +2,9 @@ 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"),
]
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand, CommandError
from sensor_7_in_1.seeds import seed_sensor_7_in_1_demo_data
from device_hub.seeds import seed_sensor_7_in_1_demo_data
class Command(BaseCommand):
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from sensor_catalog.management import seed_sensor_catalog
from device_hub.catalog_seed import seed_sensor_catalog
class Command(BaseCommand):
@@ -19,3 +19,4 @@ class Command(BaseCommand):
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
)
)
+68
View File
@@ -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 = []
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -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}]}],
}
+67
View File
@@ -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()}"
+59
View File
@@ -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)}
+9
View File
@@ -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"),
]
@@ -5,3 +5,4 @@ from .views import SensorCatalogListView
urlpatterns = [
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
]
@@ -6,3 +6,4 @@ urlpatterns = [
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
]
@@ -1,12 +1,6 @@
from rest_framework import serializers
from soil.serializers import (
SoilAnomalyDetectionSerializer,
SoilComparisonChartSerializer,
SoilKpiSerializer,
SoilMoistureHeatmapSerializer,
SoilRadarChartSerializer,
)
from soil.serializers import SoilAnomalyDetectionSerializer, SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, SoilRadarChartSerializer
class Sensor7In1MetaSerializer(serializers.Serializer):
@@ -82,3 +76,4 @@ class Sensor7In1SummarySerializer(serializers.Serializer):
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
@@ -1,9 +1,23 @@
from rest_framework import serializers
from farm_hub.models import FarmSensor
from sensor_catalog.models import SensorCatalog
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
from .models import 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):
@@ -28,42 +42,6 @@ class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
return attrs
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
class FarmSensorLogSerializer(serializers.ModelSerializer):
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
@@ -99,3 +77,39 @@ class SensorCatalogLogSerializer(serializers.ModelSerializer):
"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
+559
View File
@@ -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}]}
+11
View File
@@ -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"),
]
+142
View File
@@ -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)
+30 -6
View File
@@ -1,6 +1,6 @@
services:
db:
image: docker.iranserver.com/mysql:8
image: mirror-docker.runflare.com/library/mysql:8
container_name: croplogic-db
environment:
MYSQL_DATABASE: ${DB_NAME}
@@ -21,7 +21,7 @@ services:
- crop_network
redis:
image: docker.iranserver.com/redis
image: mirror-docker.runflare.com/library/redis:7-alpine
container_name: backend-redis
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
restart: unless-stopped
@@ -36,7 +36,7 @@ services:
- crop_network
accsess:
image: openpolicyagent/opa:0.67.1-static
image: mirror-docker.runflare.com/openpolicyagent/opa:0.67.1-static
container_name: backend-accsess
command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"]
volumes:
@@ -48,7 +48,15 @@ services:
web:
container_name: backend-web
build: .
build:
context: .
dockerfile: Dockerfile.Dev
args:
BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm
APT_MIRROR: mirror-linux.runflare.com/debian
APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security
PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple
PIP_TRUSTED_HOST: mirror-pypi.runflare.com
env_file:
- .env
environment:
@@ -76,7 +84,15 @@ services:
celery:
container_name: backend-celery
build: .
build:
context: .
dockerfile: Dockerfile.Dev
args:
BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm
APT_MIRROR: mirror-linux.runflare.com/debian
APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security
PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple
PIP_TRUSTED_HOST: mirror-pypi.runflare.com
command: ["celery", "-A", "config", "worker", "-l", "info"]
env_file:
- .env
@@ -104,7 +120,15 @@ services:
celery-beat:
container_name: backend-celery-beat
build: .
build:
context: .
dockerfile: Dockerfile.Dev
args:
BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm
APT_MIRROR: mirror-linux.runflare.com/debian
APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security
PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple
PIP_TRUSTED_HOST: mirror-pypi.runflare.com
command: ["celery", "-A", "config", "beat", "-l", "info"]
env_file:
- .env
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
("device_hub", "0001_initial"),
("farm_hub", "0002_seed_default_catalog"),
]
@@ -25,7 +25,7 @@ class Migration(migrations.Migration):
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="farm_sensors",
to="sensor_catalog.sensorcatalog",
to="device_hub.sensorcatalog",
),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0001_initial"),
("farm_hub", "0009_farmhub_irrigation_method_fields"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.DeleteModel(name="FarmSensor"),
],
),
]
-31
View File
@@ -2,7 +2,6 @@ import uuid as uuid_lib
from django.conf import settings
from django.db import models
from sensor_catalog.models import SensorCatalog
class FarmType(models.Model):
@@ -123,33 +122,3 @@ class FarmHub(models.Model):
def __str__(self):
return f"{self.name} ({self.farm_uuid})"
class FarmSensor(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
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})"
+2 -2
View File
@@ -3,8 +3,8 @@ import uuid
from django.db import transaction
from account.seeds import seed_admin_user
from sensor_catalog.management import seed_sensor_catalog
from sensor_catalog.models import SensorCatalog
from device_hub.catalog_seed import seed_sensor_catalog
from device_hub.models import SensorCatalog
from .catalog import CATALOG_SEED_DATA
from .models import FarmHub, FarmType, Product
+2 -2
View File
@@ -3,10 +3,10 @@ from access_control.models import SubscriptionPlan
from access_control.serializers import SubscriptionPlanSerializer
from access_control.catalog import GOLD_PLAN_CODE
from access_control.services import get_effective_subscription_plan
from device_hub.models import FarmSensor, SensorCatalog
from .models import FarmHub, FarmSensor, FarmType, Product
from .models import FarmHub, FarmType, Product
from .services import normalize_farm_boundary_input
from sensor_catalog.models import SensorCatalog
class FarmTypeSerializer(serializers.ModelSerializer):
+2
View File
@@ -11,6 +11,7 @@ from crop_zoning.services import (
)
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from plants.services import push_plants_to_ai
logger = logging.getLogger(__name__)
@@ -65,6 +66,7 @@ def sync_farm_data(
plant_ids=None,
irrigation_method_id=None,
):
push_plants_to_ai()
request_payload = {
"farm_uuid": str(farm.farm_uuid),
"farm_boundary": _extract_boundary_geometry(area_feature, farm=farm),
+1 -1
View File
@@ -7,12 +7,12 @@ from access_control.models import AccessFeature, AccessRule, FarmAccessProfile,
from access_control.services import build_farm_access_profile
from access_control.views import FarmAccessProfileView
from crop_zoning.models import CropArea
from device_hub.models import SensorCatalog
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType, Product
from farm_hub.serializers import FarmHubSerializer
from farm_hub.seeds import seed_admin_farm
from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView
from sensor_catalog.models import SensorCatalog
AREA_GEOJSON = {
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from django.apps import apps as django_apps
from django.utils import timezone
from django.utils.dateparse import parse_date
AUTO_PLAN_SOURCE = "auto_plan_sync"
PLAN_TYPE_IRRIGATION = "irrigation"
PLAN_TYPE_FERTILIZATION = "fertilization"
def create_event_for_farm(
*,
farm,
title,
description="",
start=None,
end=None,
scheduled_date=None,
event_time=None,
priority=None,
tags=None,
zone_value="برنامه خودکار",
extended_props=None,
):
FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent")
FarmerCalendarZone = django_apps.get_model("farmer_calendar", "FarmerCalendarZone")
from .enums import FarmerPriority
if priority is None:
priority = FarmerPriority.MEDIUM
zone, _ = FarmerCalendarZone.objects.get_or_create(
farm=farm,
value=zone_value,
defaults={"label": zone_value},
)
if zone.label != zone_value:
zone.label = zone_value
zone.save(update_fields=["label", "updated_at"])
payload = dict(extended_props or {})
payload["tags"] = list(tags or [])
return FarmerCalendarEvent.objects.create(
farm=farm,
zone=zone,
title=title,
description=description,
deadline=int(end.timestamp()) if end else None,
scheduled_date=scheduled_date,
time=event_time,
start=start,
end=end,
priority=priority,
status=FarmerCalendarEvent.STATUS_OPEN,
extended_props=payload,
)
def delete_plan_events(*, farm, plan_type, plan_uuid):
FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent")
for event in FarmerCalendarEvent.objects.filter(farm=farm):
props = event.extended_props or {}
if (
props.get("source") == AUTO_PLAN_SOURCE
and props.get("plan_type") == plan_type
and str(props.get("plan_uuid")) == str(plan_uuid)
):
event.delete()
def sync_plan_events(plan, plan_type):
from .enums import FarmerPriority
delete_plan_events(farm=plan.farm, plan_type=plan_type, plan_uuid=plan.uuid)
if getattr(plan, "is_deleted", False) or not getattr(plan, "is_active", False):
return []
if plan_type == PLAN_TYPE_IRRIGATION:
items = _build_irrigation_events(plan)
elif plan_type == PLAN_TYPE_FERTILIZATION:
items = _build_fertilization_events(plan)
else:
items = []
created = []
for index, item in enumerate(items, start=1):
created.append(
create_event_for_farm(
farm=plan.farm,
title=item["title"],
description=item.get("description", ""),
start=item.get("start"),
end=item.get("end"),
scheduled_date=item.get("scheduled_date"),
event_time=item.get("time"),
priority=item.get("priority", FarmerPriority.MEDIUM),
tags=item.get("tags", []),
zone_value=item.get("zone_value", "برنامه خودکار"),
extended_props={
"source": AUTO_PLAN_SOURCE,
"plan_type": plan_type,
"plan_uuid": str(plan.uuid),
"plan_title": plan.title,
"entry_index": index,
**item.get("extended_props", {}),
},
)
)
return created
def _build_irrigation_events(plan):
from .enums import FarmerPriority, FarmerTag
payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {}
plan_data = payload.get("plan") if isinstance(payload.get("plan"), dict) else {}
water_balance = payload.get("water_balance") if isinstance(payload.get("water_balance"), dict) else {}
daily_entries = water_balance.get("daily") if isinstance(water_balance.get("daily"), list) else []
created = []
for entry in daily_entries:
if not isinstance(entry, dict):
continue
scheduled = _parse_date(entry.get("forecast_date"))
if not scheduled:
continue
start_time, end_time = _parse_time_range(entry.get("irrigation_timing") or plan_data.get("bestTimeOfDay"))
start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes"))
gross_amount = entry.get("gross_irrigation_mm")
title = f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}"
description_parts = []
if gross_amount not in (None, ""):
description_parts.append(f"مقدار آبیاری: {gross_amount} mm")
if plan_data.get("durationMinutes"):
description_parts.append(f"مدت زمان: {plan_data.get('durationMinutes')} دقیقه")
if entry.get("irrigation_timing"):
description_parts.append(f"بازه اجرا: {entry.get('irrigation_timing')}")
created.append(
{
"title": title,
"description": " | ".join(description_parts),
"scheduled_date": scheduled,
"time": start_time,
"start": start,
"end": end,
"priority": FarmerPriority.HIGH,
"tags": [FarmerTag.IRRIGATION.value],
"zone_value": "آبیاری",
"extended_props": {
"kind": "irrigation",
"gross_irrigation_mm": gross_amount,
"irrigation_timing": entry.get("irrigation_timing"),
},
}
)
if created:
return created
scheduled = timezone.localdate()
start_time, end_time = _parse_time_range(plan_data.get("bestTimeOfDay"))
start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes"))
return [
{
"title": f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}",
"description": f"برنامه فعال آبیاری: {plan.title}".strip(),
"scheduled_date": scheduled,
"time": start_time,
"start": start,
"end": end,
"priority": FarmerPriority.HIGH,
"tags": [FarmerTag.IRRIGATION.value],
"zone_value": "آبیاری",
"extended_props": {"kind": "irrigation_fallback"},
}
]
def _build_fertilization_events(plan):
from .enums import FarmerPriority, FarmerTag
payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {}
primary = payload.get("primary_recommendation") if isinstance(payload.get("primary_recommendation"), dict) else {}
guide = payload.get("application_guide") if isinstance(payload.get("application_guide"), dict) else {}
steps = guide.get("steps") if isinstance(guide.get("steps"), list) else []
interval = primary.get("application_interval") if isinstance(primary.get("application_interval"), dict) else {}
interval_days = _safe_int(interval.get("value"))
base_date = timezone.localdate()
fertilizer_name = primary.get("display_title") or primary.get("fertilizer_name") or plan.title or "برنامه کودی"
created = []
for index, step in enumerate(steps):
if not isinstance(step, dict):
continue
scheduled = _extract_step_date(step) or (base_date + timedelta(days=(index * interval_days if interval_days else index)))
start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0)))
end = start + timedelta(minutes=30)
description = str(step.get("description") or guide.get("safety_warning") or "").strip()
created.append(
{
"title": f"کوددهی - {fertilizer_name}",
"description": description,
"scheduled_date": scheduled,
"time": start.time(),
"start": start,
"end": end,
"priority": FarmerPriority.MEDIUM,
"tags": [FarmerTag.FERTILIZATION.value],
"zone_value": "کوددهی",
"extended_props": {
"kind": "fertilization",
"step_number": step.get("step_number"),
"fertilizer_code": primary.get("fertilizer_code"),
},
}
)
if created:
return created
scheduled = base_date
start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0)))
end = start + timedelta(minutes=30)
interval_label = interval.get("label") or ""
description = " | ".join(part for part in [str(primary.get("summary") or "").strip(), str(interval_label).strip()] if part)
return [
{
"title": f"کوددهی - {fertilizer_name}",
"description": description,
"scheduled_date": scheduled,
"time": start.time(),
"start": start,
"end": end,
"priority": FarmerPriority.MEDIUM,
"tags": [FarmerTag.FERTILIZATION.value],
"zone_value": "کوددهی",
"extended_props": {
"kind": "fertilization_fallback",
"fertilizer_code": primary.get("fertilizer_code"),
},
}
]
def _parse_date(value):
if isinstance(value, date):
return value
if not value:
return None
return parse_date(str(value))
def _parse_time_range(value):
if not value:
return None, None
raw = str(value).replace("تا", "-").replace("", "-")
parts = [part.strip() for part in raw.split("-") if part.strip()]
if not parts:
return None, None
start_time = _parse_time(parts[0])
end_time = _parse_time(parts[1]) if len(parts) > 1 else None
return start_time, end_time
def _parse_time(value):
if isinstance(value, time):
return value
if not value:
return None
cleaned = str(value).strip()
for fmt in ("%H:%M", "%H:%M:%S"):
try:
return datetime.strptime(cleaned, fmt).time()
except ValueError:
continue
return None
def _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=None):
if scheduled is None:
return None, None
if start_time is None:
start_time = time(hour=6, minute=0)
start = timezone.make_aware(datetime.combine(scheduled, start_time))
if end_time is not None:
end = timezone.make_aware(datetime.combine(scheduled, end_time))
else:
end = start + timedelta(minutes=_safe_int(default_duration_minutes) or 30)
return start, end
def _extract_step_date(step):
for key in ("date", "scheduled_date", "application_date", "target_date", "forecast_date"):
parsed = _parse_date(step.get(key))
if parsed:
return parsed
return None
def _safe_int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
+6 -3
View File
@@ -42,7 +42,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z"
}
],
@@ -63,6 +63,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag
- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند.
- ترتیب لیست از جدید به قدیم است.
- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد.
---
@@ -95,7 +96,7 @@ GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z",
"updated_at": "2025-02-24T10:20:30Z",
"plan_payload": {
@@ -198,7 +199,7 @@ Content-Type: application/json
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_active": false
"is_active": true
}
}
```
@@ -226,6 +227,8 @@ Content-Type: application/json
## Summary
- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند.
- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد.
- `GET /api/fertilization/plans/` لیست برنامه‌ها
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fertilization_recommendation", "0003_fertilizationplan"),
]
operations = [
migrations.AlterField(
model_name="fertilizationplan",
name="is_active",
field=models.BooleanField(db_index=True, default=False),
),
]
+1 -1
View File
@@ -72,7 +72,7 @@ class FertilizationPlan(models.Model):
plan_payload = models.JSONField(default=dict, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
is_active = models.BooleanField(default=False, db_index=True)
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
+72 -4
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import FertilizationPlan, FertilizationRecommendationRequest
from .views import (
FertilizationPlanDetailView,
@@ -159,7 +160,7 @@ class FertilizationRecommendViewTests(TestCase):
self.assertEqual(saved_request.growth_stage, "vegetative")
self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION)
self.assertEqual(saved_plan.recommendation_id, saved_request.id)
self.assertTrue(saved_plan.is_active)
self.assertFalse(saved_plan.is_active)
self.assertFalse(saved_plan.is_deleted)
self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020")
self.assertEqual(
@@ -280,7 +281,7 @@ class FertilizationRecommendViewTests(TestCase):
self.assertEqual(plan.title, "برنامه کوددهی گندم")
self.assertEqual(plan.crop_id, "گندم")
self.assertEqual(plan.growth_stage, "flowering")
self.assertTrue(plan.is_active)
self.assertFalse(plan.is_active)
self.assertFalse(plan.is_deleted)
def test_recommendation_list_returns_paginated_summary_items(self):
@@ -473,7 +474,7 @@ class FertilizationPlanApiTests(TestCase):
def test_plan_status_patch_updates_is_active(self):
request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": False},
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
@@ -482,4 +483,71 @@ class FertilizationPlanApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertFalse(self.plan.is_active)
self.assertTrue(self.plan.is_active)
def test_activating_one_plan_deactivates_other_active_plan(self):
other_plan = FertilizationPlan.objects.create(
farm=self.farm,
source=FertilizationPlan.SOURCE_FREE_TEXT,
title="برنامه دوم",
is_active=True,
)
request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
other_plan.refresh_from_db()
self.assertTrue(self.plan.is_active)
self.assertFalse(other_plan.is_active)
def test_plan_status_patch_syncs_calendar_events(self):
self.plan.plan_payload = {
"primary_recommendation": {
"fertilizer_code": "npk-202020",
"fertilizer_name": "NPK 20-20-20",
"application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"},
},
"application_guide": {
"steps": [
{"step_number": 1, "title": "مرحله اول", "description": "در آب حل شود", "date": "2025-02-14"}
]
},
}
self.plan.is_active = False
self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"])
activate_request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(activate_request, user=self.user)
activate_response = FertilizationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid)
self.assertEqual(activate_response.status_code, 200)
events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid))
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().extended_props["plan_type"], "fertilization")
deactivate_request = self.factory.patch(
f"/api/fertilization/plans/{self.plan.uuid}/status/",
{"is_active": False},
format="json",
)
force_authenticate(deactivate_request, user=self.user)
deactivate_response = FertilizationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid)
self.assertEqual(deactivate_response.status_code, 200)
self.assertFalse(
FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists()
)
+19 -3
View File
@@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema
from config.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events
from .models import FertilizationPlan, FertilizationRecommendationRequest
from .services import build_active_plan_context
from .mock_data import CONFIG_RESPONSE_DATA
@@ -371,7 +372,7 @@ class RecommendView(FarmAccessMixin, APIView):
def _create_plan_from_recommendation(self, recommendation, public_data):
primary_recommendation = public_data.get("primary_recommendation", {})
FertilizationPlan.objects.create(
plan = FertilizationPlan.objects.create(
farm=recommendation.farm,
source=FertilizationPlan.SOURCE_RECOMMENDATION,
recommendation=recommendation,
@@ -382,6 +383,7 @@ class RecommendView(FarmAccessMixin, APIView):
request_payload=recommendation.request_payload,
response_payload=recommendation.response_payload,
)
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
@staticmethod
def _enrich_ai_payload(payload, farm):
@@ -581,7 +583,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
final_plan = self._extract_final_plan(response_data)
if final_plan and farm_uuid:
FertilizationPlan.objects.create(
plan = FertilizationPlan.objects.create(
farm=farm,
source=FertilizationPlan.SOURCE_FREE_TEXT,
title=self._build_free_text_plan_title(final_plan),
@@ -596,6 +598,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
request_payload=payload,
response_payload=response_data,
)
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
@@ -663,6 +666,7 @@ class FertilizationPlanDetailView(APIView):
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.soft_delete()
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid)
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
@@ -684,8 +688,20 @@ class FertilizationPlanStatusView(APIView):
if plan is None:
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.is_active = serializer.validated_data["is_active"]
new_is_active = serializer.validated_data["is_active"]
if new_is_active:
FertilizationPlan.objects.filter(
farm=plan.farm,
is_deleted=False,
is_active=True,
).exclude(pk=plan.pk).update(is_active=False)
plan.is_active = new_is_active
plan.save(update_fields=["is_active", "updated_at"])
if plan.is_active:
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
else:
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid)
return Response(
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
status=status.HTTP_200_OK,
+6 -3
View File
@@ -42,7 +42,7 @@ GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z"
}
],
@@ -63,6 +63,7 @@ GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1
- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند.
- ترتیب لیست از جدید به قدیم است.
- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد.
---
@@ -95,7 +96,7 @@ GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": true,
"is_active": false,
"created_at": "2025-02-24T10:20:30Z",
"updated_at": "2025-02-24T10:20:30Z",
"plan_payload": {
@@ -195,7 +196,7 @@ Content-Type: application/json
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_active": false
"is_active": true
}
}
```
@@ -223,6 +224,8 @@ Content-Type: application/json
## Summary
- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند.
- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد.
- `GET /api/irrigation/plans/` لیست برنامه‌ها
- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه
- `DELETE /api/irrigation/plans/{plan_uuid}/` حذف نرم برنامه
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("irrigation_recommendation", "0003_irrigationplan"),
]
operations = [
migrations.AlterField(
model_name="irrigationplan",
name="is_active",
field=models.BooleanField(db_index=True, default=False),
),
]
+1 -1
View File
@@ -74,7 +74,7 @@ class IrrigationPlan(models.Model):
plan_payload = models.JSONField(default=dict, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
is_active = models.BooleanField(default=False, db_index=True)
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
+71 -3
View File
@@ -6,6 +6,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .views import (
@@ -312,7 +313,7 @@ class RecommendViewTests(TestCase):
self.assertEqual(IrrigationPlan.objects.count(), 1)
plan = IrrigationPlan.objects.get()
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
self.assertTrue(plan.is_active)
self.assertFalse(plan.is_active)
self.assertFalse(plan.is_deleted)
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
@@ -570,7 +571,7 @@ class IrrigationPlanApiTests(TestCase):
def test_plan_status_patch_updates_is_active(self):
request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": False},
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
@@ -579,4 +580,71 @@ class IrrigationPlanApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertFalse(self.plan.is_active)
self.assertTrue(self.plan.is_active)
def test_activating_one_plan_deactivates_other_active_plan(self):
other_plan = IrrigationPlan.objects.create(
farm=self.farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title="برنامه دوم",
is_active=True,
)
request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
other_plan.refresh_from_db()
self.assertTrue(self.plan.is_active)
self.assertFalse(other_plan.is_active)
def test_plan_status_patch_syncs_calendar_events(self):
self.plan.plan_payload = {
"plan": {"durationMinutes": 25, "bestTimeOfDay": "05:30 - 06:00"},
"water_balance": {
"daily": [
{
"forecast_date": "2025-02-12",
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 06:00",
}
]
},
}
self.plan.is_active = False
self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"])
activate_request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(activate_request, user=self.user)
activate_response = IrrigationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid)
self.assertEqual(activate_response.status_code, 200)
events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid))
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().extended_props["plan_type"], "irrigation")
deactivate_request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": False},
format="json",
)
force_authenticate(deactivate_request, user=self.user)
deactivate_response = IrrigationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid)
self.assertEqual(deactivate_response.status_code, 200)
self.assertFalse(
FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists()
)
+19 -3
View File
@@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema
from config.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .mock_data import CONFIG_RESPONSE_DATA
@@ -171,7 +172,7 @@ class RecommendView(FarmAccessMixin, APIView):
return " - ".join(parts) if parts else "برنامه آبیاری"
def _create_plan_from_recommendation(self, recommendation, recommendation_data):
IrrigationPlan.objects.create(
plan = IrrigationPlan.objects.create(
farm=recommendation.farm,
source=IrrigationPlan.SOURCE_RECOMMENDATION,
recommendation=recommendation,
@@ -182,6 +183,7 @@ class RecommendView(FarmAccessMixin, APIView):
request_payload=recommendation.request_payload,
response_payload=recommendation.response_payload,
)
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
@staticmethod
def _enrich_ai_payload(payload, farm):
@@ -451,7 +453,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
final_plan = self._extract_final_plan(response_data)
if final_plan and farm_uuid:
IrrigationPlan.objects.create(
plan = IrrigationPlan.objects.create(
farm=farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title=self._build_free_text_plan_title(final_plan),
@@ -466,6 +468,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
request_payload=payload,
response_payload=response_data,
)
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
@@ -533,6 +536,7 @@ class IrrigationPlanDetailView(APIView):
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.soft_delete()
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid)
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
@@ -554,8 +558,20 @@ class IrrigationPlanStatusView(APIView):
if plan is None:
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.is_active = serializer.validated_data["is_active"]
new_is_active = serializer.validated_data["is_active"]
if new_is_active:
IrrigationPlan.objects.filter(
farm=plan.farm,
is_deleted=False,
is_active=True,
).exclude(pk=plan.pk).update(is_active=False)
plan.is_active = new_is_active
plan.save(update_fields=["is_active", "updated_at"])
if plan.is_active:
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
else:
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid)
return Response(
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
status=status.HTTP_200_OK,
+5
View File
@@ -11,6 +11,8 @@ class PlantSerializer(serializers.ModelSerializer):
fields = [
"id",
"name",
"description",
"metadata",
"light",
"watering",
"soil",
@@ -22,6 +24,9 @@ class PlantSerializer(serializers.ModelSerializer):
"harvest_time",
"spacing",
"fertilizer",
"health_profile",
"irrigation_profile",
"growth_profile",
"created_at",
"updated_at",
]
+55 -72
View File
@@ -1,4 +1,5 @@
from django.db import transaction
from django.conf import settings
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
@@ -14,33 +15,13 @@ DEFAULT_GROWTH_STAGES = [
"fruiting",
"maturity",
]
AI_PLANTS_PATH = "/api/plants/"
AI_FARM_DATA_PLANT_SYNC_PATH = "/api/farm-data/plants/sync/"
class PlantSyncError(Exception):
pass
def _extract_plant_items(adapter_data):
if isinstance(adapter_data, list):
return adapter_data
if not isinstance(adapter_data, dict):
return []
data = adapter_data.get("data")
if isinstance(data, list):
return data
if isinstance(data, dict):
result = data.get("result")
if isinstance(result, list):
return result
result = adapter_data.get("result")
if isinstance(result, list):
return result
return []
def _clean_stage_name(value):
stage = str(value or "").strip()
return stage
@@ -110,60 +91,62 @@ def ensure_plant_defaults(queryset=None):
return products
@transaction.atomic
def sync_plants_from_ai():
def serialize_products_for_ai(products=None):
products = list(products if products is not None else Product.objects.select_related("farm_type").all().order_by("name"))
ensure_plant_defaults(products)
payload = []
for product in products:
payload.append(
{
"id": product.id,
"name": product.name,
"slug": "",
"icon": product.icon,
"description": product.description,
"metadata": product.metadata if isinstance(product.metadata, dict) else {},
"light": product.light,
"watering": product.watering,
"soil": product.soil,
"temperature": product.temperature,
"growth_stage": product.growth_stage,
"growth_stages": product.growth_stages or [],
"planting_season": product.planting_season,
"harvest_time": product.harvest_time,
"spacing": product.spacing,
"fertilizer": product.fertilizer,
"health_profile": product.health_profile if isinstance(product.health_profile, dict) else {},
"irrigation_profile": product.irrigation_profile if isinstance(product.irrigation_profile, dict) else {},
"growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {},
"is_active": True,
"updated_at": product.updated_at.isoformat() if product.updated_at else None,
"farm_type": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME,
}
)
return payload
def push_plants_to_ai(products=None):
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key:
raise PlantSyncError("FARM_DATA_API_KEY is not configured.")
payload = serialize_products_for_ai(products)
try:
adapter_response = external_api_request("ai", AI_PLANTS_PATH, method="GET")
adapter_response = external_api_request(
"ai",
AI_FARM_DATA_PLANT_SYNC_PATH,
method="POST",
payload=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 PlantSyncError(str(exc)) from exc
if adapter_response.status_code >= 400:
raise PlantSyncError(f"AI service returned status {adapter_response.status_code}.")
products = []
for item in _extract_plant_items(adapter_response.data):
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
farm_type_name = str(item.get("farm_type") or DEFAULT_FARM_TYPE_NAME).strip() or DEFAULT_FARM_TYPE_NAME
farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name)
growth_profile = item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {}
growth_stages = item.get("growth_stages") if isinstance(item.get("growth_stages"), list) else []
normalized_growth_stages = []
for stage in growth_stages:
normalized = _clean_stage_name(stage)
if normalized:
normalized_growth_stages.append(normalized)
defaults = {
"description": str(item.get("description") or "").strip(),
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
"light": str(item.get("light") or "").strip(),
"watering": str(item.get("watering") or "").strip(),
"soil": str(item.get("soil") or "").strip(),
"temperature": str(item.get("temperature") or "").strip(),
"growth_stage": str(item.get("growth_stage") or "").strip(),
"growth_stages": normalized_growth_stages,
"planting_season": str(item.get("planting_season") or "").strip(),
"harvest_time": str(item.get("harvest_time") or "").strip(),
"spacing": str(item.get("spacing") or "").strip(),
"fertilizer": str(item.get("fertilizer") or "").strip(),
"icon": str(item.get("icon") or "").strip(),
"health_profile": item.get("health_profile") if isinstance(item.get("health_profile"), dict) else {},
"irrigation_profile": item.get("irrigation_profile") if isinstance(item.get("irrigation_profile"), dict) else {},
"growth_profile": growth_profile,
}
product, _ = Product.objects.update_or_create(
farm_type=farm_type,
name=name,
defaults=defaults,
)
products.append(product)
ensure_plant_defaults(products)
return products
return payload
+6 -6
View File
@@ -56,9 +56,9 @@ class PlantApiTests(TestCase):
self.assertIn("flowering", response.data["data"][0]["growth_stages"])
mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET")
@patch("plants.views.sync_plants_from_ai")
def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_sync_plants_from_ai):
mock_sync_plants_from_ai.return_value = []
@patch("plants.views.push_plants_to_ai")
def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai):
mock_push_plants_to_ai.return_value = []
product = Product.objects.create(
farm_type=self.farm_type,
name="Pepper",
@@ -79,9 +79,9 @@ class PlantApiTests(TestCase):
self.assertEqual(product.icon, "leaf")
self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"])
@patch("plants.views.sync_plants_from_ai")
def test_selected_endpoint_returns_farmer_products(self, mock_sync_plants_from_ai):
mock_sync_plants_from_ai.return_value = []
@patch("plants.views.push_plants_to_ai")
def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai):
mock_push_plants_to_ai.return_value = []
tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"])
pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"])
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="farm-a")
+7 -8
View File
@@ -8,7 +8,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import code_response, farm_uuid_query_param
from farm_hub.models import FarmHub, Product
from .serializers import PlantNameSerializer, PlantSerializer
from .services import PlantSyncError, ensure_plant_defaults, sync_plants_from_ai
from .services import PlantSyncError, ensure_plant_defaults, push_plants_to_ai
class PlantBaseView(APIView):
@@ -17,7 +17,7 @@ class PlantBaseView(APIView):
@staticmethod
def _sync_plants_if_possible():
try:
sync_plants_from_ai()
push_plants_to_ai()
except PlantSyncError:
return False
return True
@@ -38,13 +38,12 @@ class PlantListView(PlantBaseView):
responses={200: code_response("PlantListResponse", data=PlantSerializer(many=True))},
)
def get(self, request):
try:
sync_plants_from_ai()
except PlantSyncError as exc:
if not Product.objects.exists():
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
products = ensure_plant_defaults(Product.objects.order_by("name"))
try:
push_plants_to_ai(products)
except PlantSyncError as exc:
if not products:
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
data = PlantSerializer(products, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class Sensor7In1Config(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_7_in_1"
verbose_name = "Sensor 7 in 1"
-133
View File
@@ -1,133 +0,0 @@
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},
],
}
],
}
-4
View File
@@ -1,4 +0,0 @@
"""
This app is service-based and does not define local database models.
"""
-174
View File
@@ -1,174 +0,0 @@
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),
}
-704
View File
@@ -1,704 +0,0 @@
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 django.utils import timezone
from .mock_data import (
ANOMALY_DETECTION_CARD,
AVG_SOIL_MOISTURE,
SENSOR_COMPARISON_CHART,
SENSOR_RADAR_CHART,
SENSOR_VALUES_LIST,
SOIL_MOISTURE_HEATMAP,
)
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": "پتاسیم",
},
]
MIN_REQUIRED_SENSOR_FIELDS = 4
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 _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)
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):
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"])
unit = field["unit"]
if unit in {"", "pH"}:
return f"{lower}-{upper}"
return f"{lower}-{upper} {unit}"
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 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):
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 = 0.0
if baseline_value:
percent_change = ((current_value - baseline_value) / baseline_value) * 100
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)
if anomalies:
data["anomalies"] = anomalies
else:
data["anomalies"] = [
{
"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):
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},
],
}
-281
View File
@@ -1,281 +0,0 @@
from datetime import datetime, timedelta, timezone as dt_timezone
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate
from farm_hub.models import FarmHub, FarmSensor, FarmType
from sensor_catalog.models import SensorCatalog
from sensor_external_api.models import SensorExternalRequestLog
from dashboard.services import get_farm_dashboard_cards
from .seeds import seed_sensor_7_in_1_demo_data
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):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-7-in-1-user",
password="secret123",
email="sensor7@example.com",
phone_number="09120000017",
)
self.farm_type = FarmType.objects.create(name="مزرعه سنسور 7 در 1")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm Sensor 7 in 1",
farm_uuid="11111111-1111-1111-1111-111111111111",
)
self.sensor_catalog = SensorCatalog.objects.create(
code="sensor-7-in-1",
name="7 in 1 Soil Sensor",
returned_data_fields=[
"soil_moisture",
"soil_temperature",
"soil_ph",
"electrical_conductivity",
"nitrogen",
"phosphorus",
"potassium",
],
)
self.sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.sensor_catalog,
physical_device_uuid="33333333-3333-3333-3333-333333333333",
name="Soil Sensor 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(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.sensor.physical_device_uuid,
payload={
"soil_moisture": 41.0,
"soil_temperature": 21.0,
"soil_ph": 6.5,
"electrical_conductivity": 1.0,
"nitrogen": 28.0,
"phosphorus": 14.0,
"potassium": 19.0,
},
)
self.latest_log = SensorExternalRequestLog.objects.create(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.sensor.physical_device_uuid,
payload={
"soil_moisture": 48.5,
"soil_temperature": 23.2,
"soil_ph": 6.8,
"electrical_conductivity": 1.4,
"nitrogen": 31.0,
"phosphorus": 16.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):
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):
data = get_sensor_7_in_1_summary_data(self.farm)
self.assertEqual(data["sensor"]["name"], "Soil Sensor 7-in-1")
self.assertEqual(data["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
self.assertEqual(data["sensorValuesList"]["sensors"][0]["id"], "soil_moisture")
self.assertEqual(data["avgSoilMoisture"]["stats"], "48.5%")
self.assertEqual(data["sensorComparisonChart"]["currentValue"], 48.5)
self.assertEqual(data["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
def test_dashboard_cards_use_sensor_service_outputs(self):
cards = get_farm_dashboard_cards(self.farm)
self.assertEqual(cards["sensorValuesList"]["sensor"]["physicalDeviceUuid"], str(self.sensor.physical_device_uuid))
self.assertEqual(cards["sensorValuesList"]["sensors"][0]["title"], "48.5%")
self.assertEqual(cards["sensorRadarChart"]["series"][0]["name"], "اکنون")
self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1")
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):
def test_summary_view_returns_sensor_cards(self):
request = self.factory.get(f"/api/sensor-7-in-1/summary/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = Sensor7In1SummaryView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["sensor"]["sensorCatalogCode"], "sensor-7-in-1")
def test_summary_view_requires_farm_uuid(self):
request = self.factory.get("/api/sensor-7-in-1/summary/")
force_authenticate(request, user=self.user)
response = Sensor7In1SummaryView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.")
def test_sensor_comparison_chart_view_returns_raw_payload(self):
request = self.factory.get(
(
"/api/sensors/comparison-chart/"
f"?farm_uuid={self.farm.farm_uuid}"
)
)
force_authenticate(request, user=self.user)
response = SensorComparisonChartView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertIn("series", response.data)
self.assertNotIn("code", response.data)
self.assertEqual(response.data["currentValue"], 48.5)
self.assertEqual(response.data["vsLastWeek"], "+18.3%")
def test_sensor_values_list_view_returns_raw_payload(self):
request = self.factory.get(
(
"/api/sensors/values-list/"
f"?farm_uuid={self.farm.farm_uuid}"
)
)
force_authenticate(request, user=self.user)
response = SensorValuesListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["sensors"][0]["title"], "Moisture")
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)
-10
View File
@@ -1,10 +0,0 @@
from django.urls import path
from .views import (
Sensor7In1SummaryView,
)
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
]
-183
View File
@@ -1,183 +0,0 @@
from rest_framework import serializers, status
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from config.swagger import code_response, farm_uuid_query_param
from farm_hub.models import FarmHub
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
from .serializers import (
Sensor7In1SummarySerializer,
SensorComparisonChartQuerySerializer,
SensorComparisonChartResponseSerializer,
SensorRadarChartQuerySerializer,
SensorRadarChartResponseSerializer,
SensorValuesListQuerySerializer,
SensorValuesListResponseSerializer,
)
from .services import (
get_sensor_comparison_chart_data,
get_sensor_7_in_1_comparison_chart_data,
get_sensor_7_in_1_radar_chart_data,
get_sensor_7_in_1_summary_data,
get_primary_soil_sensor,
get_sensor_radar_chart_data,
get_sensor_values_list_data,
)
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)
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)
-302
View File
@@ -1,302 +0,0 @@
# مستند 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`
View File
-7
View File
@@ -1,7 +0,0 @@
from django.apps import AppConfig
class SensorCatalogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_catalog"
verbose_name = "Sensor Catalog"
-32
View File
@@ -1,32 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-20 00:00
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
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)),
("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)),
("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": ["name"],
},
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-20 01:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sensor_catalog", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="sensorcatalog",
name="supported_power_sources",
field=models.JSONField(blank=True, default=list),
),
]
@@ -1,44 +0,0 @@
import re
from django.db import migrations, models
def _to_snake_case(value):
normalized = re.sub(r"[^a-zA-Z0-9]+", "_", (value or "").strip()).strip("_").lower()
return normalized or "sensor"
def populate_sensor_codes(apps, schema_editor):
SensorCatalog = apps.get_model("sensor_catalog", "SensorCatalog")
used_codes = set()
for sensor in SensorCatalog.objects.all().order_by("id"):
base_code = _to_snake_case(sensor.name)
code = base_code
suffix = 2
while code in used_codes:
code = f"{base_code}_{suffix}"
suffix += 1
sensor.code = code
sensor.save(update_fields=["code"])
used_codes.add(code)
class Migration(migrations.Migration):
dependencies = [
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
]
operations = [
migrations.AddField(
model_name="sensorcatalog",
name="code",
field=models.CharField(blank=True, db_index=True, default="", max_length=255),
),
migrations.RunPython(populate_sensor_codes, migrations.RunPython.noop),
migrations.AlterField(
model_name="sensorcatalog",
name="code",
field=models.CharField(db_index=True, max_length=255, unique=True),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sensor_catalog', '0003_sensorcatalog_code'),
]
operations = [
migrations.AlterModelOptions(
name='sensorcatalog',
options={'ordering': ['code']},
),
]
-24
View File
@@ -1,24 +0,0 @@
import uuid
from django.db import models
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)
class Meta:
db_table = "sensor_catalogs"
ordering = ["code"]
def __str__(self):
return self.name
-20
View File
@@ -1,20 +0,0 @@
from rest_framework import serializers
from .models import SensorCatalog
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
-57
View File
@@ -1,57 +0,0 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from sensor_catalog.models import SensorCatalog
from sensor_catalog.views import SensorCatalogListView
class SensorCatalogListViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-user",
password="secret123",
email="sensor@example.com",
phone_number="09120000002",
)
SensorCatalog.objects.update_or_create(
code="sensor_7_soil_moisture_sensor_v1_2",
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={
"description": (
"Measures only soil moisture using electrical resistance between two metal probes. "
"Provides analog and digital outputs."
),
"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,
},
)
SensorCatalog.objects.update_or_create(
code="legacy_sensor",
name="Legacy Sensor",
defaults={
"customizable_fields": [],
"supported_power_sources": ["direct_power"],
"returned_data_fields": ["status"],
"sample_payload": {"status": "offline"},
"is_active": False,
},
)
def test_list_returns_all_existing_sensors(self):
request = self.factory.get("/api/sensor-catalog/")
force_authenticate(request, user=self.user)
response = SensorCatalogListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 2)
self.assertEqual(
{item["code"] for item in response.data["data"]},
{"sensor_7_soil_moisture_sensor_v1_2", "legacy_sensor"},
)
-22
View File
@@ -1,22 +0,0 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from .models import SensorCatalog
from .serializers import SensorCatalogSerializer
class SensorCatalogListView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Sensor Catalog"],
responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))},
)
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,356 +0,0 @@
# مستند 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 برمی‌گرداند.
@@ -1,317 +0,0 @@
# مستند API لاگ درخواست های سنسور خارجی
این فایل نحوه کار endpoint زیر را توضیح می دهد:
`GET /sensor_external_api/logs/`
مسیر مربوطه در فایل `sensor_external_api/urls.py` به این صورت ثبت شده است:
```python
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list")
```
## هدف API
این API برای مشاهده لیست لاگ درخواست هایی استفاده می شود که از سنسورهای خارجی برای یک مزرعه مشخص ثبت شده اند.
هر لاگ شامل اطلاعات زیر است:
- شناسه لاگ
- `farm_uuid`
- `sensor_catalog_uuid`
- `physical_device_uuid`
- `payload` دریافتی از سنسور
- زمان ثبت لاگ
- اطلاعات سنسور مزرعه (`farm_sensor`)
- اطلاعات کاتالوگ سنسور (`sensor_catalog`)
## کلاس View
این endpoint در کلاس `SensorExternalRequestLogListAPIView` داخل فایل `sensor_external_api/views.py` پیاده سازی شده است.
ویژگی های مهم این View:
- فقط متد `GET` را پشتیبانی می کند.
- نیاز به احراز هویت دارد.
- از صفحه بندی استفاده می کند.
- لاگ ها را بر اساس `farm_uuid` فیلتر می کند.
## احراز هویت و دسترسی
در این View مقدار زیر تعریف شده است:
```python
permission_classes = [IsAuthenticated]
```
یعنی کاربر باید authenticated باشد تا بتواند این API را صدا بزند.
نکته مهم:
در تست ها، اگر درخواست بدون اعتبارنامه ارسال شود، پاسخ `401 Unauthorized` برمی گردد.
## پارامترهای ورودی
این API پارامترهای query string زیر را دریافت می کند:
- `farm_uuid` اجباری
- `page` اختیاری، پیش فرض `1`
- `page_size` اختیاری، پیش فرض `20` و حداکثر `100`
اعتبارسنجی پارامترها توسط `SensorExternalRequestLogQuerySerializer` در فایل `sensor_external_api/serializers.py` انجام می شود:
```python
class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
page = serializers.IntegerField(required=False, min_value=1, default=1)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20)
```
### نمونه درخواست
```http
GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20
```
## روند اجرای API
### 1) اعتبارسنجی query params
ابتدا `request.query_params` توسط serializer اعتبارسنجی می شود:
```python
serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
```
اگر `farm_uuid` معتبر نباشد یا `page_size` خارج از بازه باشد، پاسخ خطای validation از DRF برمی گردد.
### 2) گرفتن لاگ های مربوط به مزرعه
سپس سرویس `get_sensor_external_request_logs_for_farm` فراخوانی می شود:
```python
queryset = get_sensor_external_request_logs_for_farm(
farm_uuid=serializer.validated_data["farm_uuid"],
)
```
این سرویس در فایل `sensor_external_api/services.py` تعریف شده و لاگ ها را از جدول `sensor_external_request_logs` می خواند:
```python
SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id")
```
پس ترتیب خروجی به این صورت است:
- جدیدترین لاگ ها اول نمایش داده می شوند.
- اگر `created_at` برابر باشد، لاگ با `id` بزرگ تر زودتر می آید.
### 3) مدیریت خطای migration
اگر جدول های لازم هنوز migrate نشده باشند، سرویس خطا را به `ValueError` تبدیل می کند و View این پاسخ را برمی گرداند:
```json
{
"code": 503,
"msg": "Required tables are not ready. Run migrations."
}
```
با status code برابر با `503 Service Unavailable`.
### 4) صفحه بندی نتایج
این View از paginator زیر استفاده می کند:
```python
class SensorExternalRequestLogPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
```
و در View نیز `page_size` از داده معتبرشده serializer روی paginator اعمال می شود:
```python
paginator = self.pagination_class()
paginator.page_size = serializer.validated_data["page_size"]
page = paginator.paginate_queryset(queryset, request, view=self)
```
### 5) ساخت map از سنسورهای مزرعه
برای اینکه هر لاگ همراه با اطلاعات سنسور مزرعه و کاتالوگ سنسور برگردد، این سرویس صدا زده می شود:
```python
farm_sensor_map = get_farm_sensor_map_for_logs(logs=page)
```
این سرویس:
- لاگ های همان page را می گیرد.
- `FarmSensor` های متناظر را از دیتابیس پیدا می کند.
- یک map با کلید زیر می سازد:
```python
(farm_uuid, sensor_catalog_uuid, physical_device_uuid)
```
به کمک این map، serializer می تواند برای هر لاگ اطلاعات تکمیلی را پر کند.
## ساختار serializer خروجی
خروجی هر آیتم با `SensorExternalRequestLogSerializer` ساخته می شود.
فیلدهای اصلی:
```python
fields = [
"id",
"farm_uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"farm_sensor",
"sensor_catalog",
"payload",
"created_at",
]
```
### فیلد `farm_sensor`
این فیلد از نوع `SerializerMethodField` است.
اگر سنسور متناظر پیدا شود، اطلاعاتی مثل موارد زیر را برمی گرداند:
- `uuid`
- `sensor_catalog_uuid`
- `physical_device_uuid`
- `name`
- `sensor_type`
- `is_active`
- `specifications`
- `power_source`
- `created_at`
- `updated_at`
اگر سنسور پیدا نشود، مقدار آن `null` خواهد بود.
### فیلد `sensor_catalog`
این فیلد هم از نوع `SerializerMethodField` است.
اگر `farm_sensor` و `sensor_catalog` موجود باشند، اطلاعات کاتالوگ سنسور برمی گردد، مثل:
- `uuid`
- `code`
- `name`
- `description`
- `customizable_fields`
- `supported_power_sources`
- `returned_data_fields`
- `sample_payload`
- `is_active`
- `created_at`
- `updated_at`
اگر داده متناظر وجود نداشته باشد، مقدار آن `null` خواهد بود.
## مدل لاگ
لاگ ها در مدل `SensorExternalRequestLog` ذخیره می شوند:
```python
class SensorExternalRequestLog(models.Model):
farm_uuid = models.UUIDField(db_index=True)
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
physical_device_uuid = models.UUIDField(db_index=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
```
ویژگی مهم مدل:
- جدول دیتابیس: `sensor_external_request_logs`
- ترتیب پیش فرض: `ordering = ["-created_at", "-id"]`
## نمونه پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"count": 2,
"next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1",
"previous": null,
"data": [
{
"id": 12,
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
"farm_sensor": {
"uuid": "99999999-9999-9999-9999-999999999999",
"sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222",
"physical_device_uuid": "55555555-5555-5555-5555-555555555555",
"name": "External device 2",
"sensor_type": "soil_sensor",
"is_active": true,
"specifications": {
"model": "FH-2"
},
"power_source": {
"type": "solar"
},
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"sensor_catalog": {
"uuid": "22222222-2222-2222-2222-222222222222",
"code": "ext-sensor-log-2",
"name": "External Sensor Log 2",
"description": "Sensor catalog for second log",
"customizable_fields": [],
"supported_power_sources": [],
"returned_data_fields": ["humidity"],
"sample_payload": {},
"is_active": true,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
},
"payload": {
"temp": 18
},
"created_at": "2024-01-02T10:00:00Z"
}
]
}
```
## خطاهای احتمالی
### 401 Unauthorized
اگر کاربر احراز هویت نشده باشد:
```json
{
"detail": "Authentication credentials were not provided."
}
```
### 400 Bad Request
اگر پارامترها نامعتبر باشند، مثلا `farm_uuid` فرمت درست نداشته باشد یا `page_size` بیشتر از `100` باشد، خطای validation برگردانده می شود.
### 503 Service Unavailable
اگر migration های مربوط به جدول ها اجرا نشده باشند:
```json
{
"code": 503,
"msg": "Required tables are not ready. Run migrations."
}
```
## نکات مهم پیاده سازی
- این API فقط لاگ های مربوط به یک `farm_uuid` را برمی گرداند.
- پاسخ به صورت paginated است.
- داده های `farm_sensor` و `sensor_catalog` به صورت enrich شده به هر لاگ اضافه می شوند.
- اگر برای یک لاگ، سنسور متناظر در `FarmSensor` پیدا نشود، فیلدهای `farm_sensor` و `sensor_catalog` ممکن است `null` باشند.
- ترتیب نمایش لاگ ها نزولی و از جدیدترین به قدیمی ترین است.
## فایل های مرتبط
- `sensor_external_api/urls.py`
- `sensor_external_api/views.py`
- `sensor_external_api/serializers.py`
- `sensor_external_api/services.py`
- `sensor_external_api/models.py`
- `sensor_external_api/tests.py`
## جمع بندی
API مربوط به `logs/` برای گزارش گیری و مشاهده تاریخچه درخواست های ورودی از سنسورهای خارجی یک مزرعه استفاده می شود. این endpoint با دریافت `farm_uuid`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند.
View File
@@ -1,27 +0,0 @@
# Generated by Django 5.1.15 on 2026-04-04 21:00
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="SensorExternalRequestLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("farm_uuid", models.UUIDField(db_index=True)),
("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)),
("physical_device_uuid", models.UUIDField(db_index=True)),
("payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"db_table": "sensor_external_request_logs",
"ordering": ["-created_at", "-id"],
},
),
]
-16
View File
@@ -1,16 +0,0 @@
from django.db import models
class SensorExternalRequestLog(models.Model):
farm_uuid = models.UUIDField(db_index=True)
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
physical_device_uuid = models.UUIDField(db_index=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "sensor_external_request_logs"
ordering = ["-created_at", "-id"]
def __str__(self):
return f"{self.physical_device_uuid}:{self.created_at.isoformat()}"
-290
View File
@@ -1,290 +0,0 @@
import logging
from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction
from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmSensor
from notifications.services import create_notification_for_farm_uuid
from .models import SensorExternalRequestLog
logger = logging.getLogger(__name__)
class FarmDataForwardError(Exception):
pass
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 {}
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 = (
FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog")
.filter(physical_device_uuid=physical_device_uuid)
.first()
)
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.")
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,
)
notification = 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,
},
)
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:
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
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:
logger.error(
"Farm data forward failed: physical device not found for uuid=%s",
physical_device_uuid,
)
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:
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.")
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,
}
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:
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:
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
if response.status_code >= 400:
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(
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
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:
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.")
geometry = crop_area.geometry or {}
if geometry.get("type") == "Feature":
geometry = geometry.get("geometry") or {}
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.")
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/")
-380
View File
@@ -1,380 +0,0 @@
from datetime import datetime, timezone as dt_timezone
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from rest_framework_simplejwt.tokens import AccessToken
from unittest.mock import patch
from external_api_adapter.adapter import AdapterResponse
from external_api_adapter.exceptions import ExternalAPIRequestError
from crop_zoning.models import CropArea
from farm_hub.models import FarmHub, FarmSensor, FarmType
from notifications.models import FarmNotification
from sensor_catalog.models import SensorCatalog
from .models import SensorExternalRequestLog
from .services import get_latest_sensor_external_request_log
from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView
@override_settings(
SENSOR_EXTERNAL_API_KEY="12345",
FARM_DATA_API_KEY="farm-data-key",
)
class SensorExternalAPIViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-external-user",
password="secret123",
email="sensor-external@example.com",
phone_number="09120000015",
)
self.farm_type = FarmType.objects.create(name="سنسور خارجی")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm External",
)
self.sensor_catalog = SensorCatalog.objects.create(
code="ext-sensor-v1",
name="External Sensor",
)
self.crop_area = CropArea.objects.create(
farm=self.farm,
geometry={
"type": "Polygon",
"coordinates": [
[
[51.39, 35.7],
[51.41, 35.7],
[51.41, 35.72],
[51.39, 35.72],
[51.39, 35.7],
]
],
},
points=[
[51.39, 35.7],
[51.41, 35.7],
[51.41, 35.72],
[51.39, 35.72],
],
center={"lat": 35.71, "lng": 51.4},
area_sqm=1000,
area_hectares=0.1,
chunk_area_sqm=1000,
)
self.farm.current_crop_area = self.crop_area
self.farm.save(update_fields=["current_crop_area"])
self.sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.sensor_catalog,
physical_device_uuid="11111111-1111-1111-1111-111111111111",
name="sensor-7-1",
sensor_type="weather_station",
)
def test_requires_api_key(self):
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
format="json",
)
response = SensorExternalAPIView.as_view()(request)
self.assertEqual(response.status_code, 401)
@patch("sensor_external_api.services.external_api_request")
def test_creates_notification_and_request_log_for_device_uuid(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(status_code=201, data={})
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
format="json",
HTTP_X_API_KEY="12345",
)
response = SensorExternalAPIView.as_view()(request)
self.assertEqual(response.status_code, 201)
self.assertTrue(
FarmNotification.objects.filter(
farm=self.farm,
title="Sensor external API request",
).exists()
)
self.assertTrue(
SensorExternalRequestLog.objects.filter(
farm_uuid=self.farm.farm_uuid,
sensor_catalog_uuid=self.sensor_catalog.uuid,
physical_device_uuid=self.sensor.physical_device_uuid,
payload={"temp": 12},
).exists()
)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/farm-data/",
method="POST",
payload={
"farm_uuid": str(self.farm.farm_uuid),
"farm_boundary": self.crop_area.geometry,
"sensor_key": self.sensor_catalog.code,
"sensor_payload": {
self.sensor_catalog.code: {"temp": 12},
},
},
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"X-API-Key": "farm-data-key",
"Authorization": "Api-Key farm-data-key",
},
)
def test_returns_404_for_unknown_device_uuid(self):
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"temp": 12}},
format="json",
HTTP_X_API_KEY="12345",
)
response = SensorExternalAPIView.as_view()(request)
self.assertEqual(response.status_code, 404)
@patch("sensor_external_api.services.external_api_request")
def test_returns_503_when_farm_data_api_is_unavailable(self, mock_external_api_request):
mock_external_api_request.side_effect = ExternalAPIRequestError("connection error")
request = self.factory.post(
"/api/sensor-external-api/",
{"uuid": str(self.sensor.physical_device_uuid), "payload": {"temp": 12}},
format="json",
HTTP_X_API_KEY="12345",
)
response = SensorExternalAPIView.as_view()(request)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.data["code"], 503)
self.assertIn("Farm data API request failed", response.data["msg"])
class SensorExternalServiceTests(TestCase):
def test_get_latest_sensor_external_request_log_returns_latest_matching_record(self):
first_log = SensorExternalRequestLog.objects.create(
farm_uuid="11111111-1111-1111-1111-111111111111",
sensor_catalog_uuid="22222222-2222-2222-2222-222222222222",
physical_device_uuid="33333333-3333-3333-3333-333333333333",
payload={"temp": 12},
)
latest_log = SensorExternalRequestLog.objects.create(
farm_uuid=first_log.farm_uuid,
sensor_catalog_uuid=first_log.sensor_catalog_uuid,
physical_device_uuid=first_log.physical_device_uuid,
payload={"temp": 18},
)
SensorExternalRequestLog.objects.create(
farm_uuid=first_log.farm_uuid,
sensor_catalog_uuid=first_log.sensor_catalog_uuid,
physical_device_uuid="44444444-4444-4444-4444-444444444444",
payload={"temp": 25},
)
log = get_latest_sensor_external_request_log(
farm_uuid=first_log.farm_uuid,
sensor_catalog_uuid=first_log.sensor_catalog_uuid,
physical_device_uuid=first_log.physical_device_uuid,
)
self.assertIsNotNone(log)
self.assertEqual(log.id, latest_log.id)
self.assertEqual(log.payload, {"temp": 18})
@override_settings(SENSOR_EXTERNAL_API_KEY="12345")
class SensorExternalRequestLogListAPIViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-external-log-user",
password="secret123",
email="sensor-external-log@example.com",
phone_number="09120000016",
)
self.access_token = str(AccessToken.for_user(self.user))
self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Farm Log External",
farm_uuid="11111111-1111-1111-1111-111111111111",
)
self.farm_uuid = self.farm.farm_uuid
self.other_farm_uuid = "aaaaaaaa-1111-1111-1111-111111111111"
self.first_catalog = SensorCatalog.objects.create(
code="ext-sensor-log-1",
name="External Sensor Log 1",
description="Sensor catalog for first log",
returned_data_fields=["temp"],
)
self.second_catalog = SensorCatalog.objects.create(
code="ext-sensor-log-2",
name="External Sensor Log 2",
description="Sensor catalog for second log",
returned_data_fields=["humidity"],
)
self.first_sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.first_catalog,
physical_device_uuid="33333333-3333-3333-3333-333333333333",
name="External device 1",
sensor_type="weather_station",
specifications={"model": "FH-1"},
power_source={"type": "battery"},
)
self.second_sensor = FarmSensor.objects.create(
farm=self.farm,
sensor_catalog=self.second_catalog,
physical_device_uuid="55555555-5555-5555-5555-555555555555",
name="External device 2",
sensor_type="soil_sensor",
specifications={"model": "FH-2"},
power_source={"type": "solar"},
)
self.first_log = SensorExternalRequestLog.objects.create(
farm_uuid=self.farm_uuid,
sensor_catalog_uuid=self.first_catalog.uuid,
physical_device_uuid=self.first_sensor.physical_device_uuid,
payload={"temp": 12},
)
self.second_log = SensorExternalRequestLog.objects.create(
farm_uuid=self.farm_uuid,
sensor_catalog_uuid=self.second_catalog.uuid,
physical_device_uuid=self.second_sensor.physical_device_uuid,
payload={"temp": 18},
)
SensorExternalRequestLog.objects.create(
farm_uuid=self.other_farm_uuid,
sensor_catalog_uuid="66666666-6666-6666-6666-666666666666",
physical_device_uuid="77777777-7777-7777-7777-777777777777",
payload={"temp": 24},
)
def test_requires_bearer_token(self):
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)
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):
request = self.factory.get(
f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1",
HTTP_AUTHORIZATION=f"Bearer {self.access_token}",
)
response = SensorExternalRequestLogListAPIView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["data"][0]["id"], self.second_log.id)
self.assertEqual(
response.data["data"][0]["physical_device_uuid"],
str(self.second_log.physical_device_uuid),
)
self.assertEqual(
response.data["data"][0]["sensor_catalog"]["uuid"],
str(self.second_catalog.uuid),
)
self.assertEqual(
response.data["data"][0]["sensor_catalog"]["name"],
self.second_catalog.name,
)
self.assertEqual(
response.data["data"][0]["farm_sensor"]["uuid"],
str(self.second_sensor.uuid),
)
self.assertEqual(
response.data["data"][0]["farm_sensor"]["physical_device_uuid"],
str(self.second_sensor.physical_device_uuid),
)
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)
-197
View File
@@ -1,197 +0,0 @@
import logging
from rest_framework import serializers, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny
from rest_framework.permissions import 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
from notifications.serializers import FarmNotificationSerializer
from .authentication import SensorExternalAPIKeyAuthentication
from .serializers import (
SensorExternalRequestLogQuerySerializer,
SensorExternalRequestLogSerializer,
SensorExternalRequestSerializer,
)
from .services import (
FarmDataForwardError,
create_sensor_external_notification,
forward_sensor_payload_to_farm_data,
get_farm_sensor_map_for_logs,
get_sensor_external_request_logs_for_farm,
)
logger = logging.getLogger(__name__)
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)
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:
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):
logger.exception(
"Sensor external API POST failed due to missing migrations: uuid=%s",
serializer.validated_data["uuid"],
)
return Response(
{"code": 503, "msg": "Required tables are not ready. Run migrations."},
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)
except FarmDataForwardError as exc:
logger.exception(
"Sensor external API POST failed while forwarding to farm data: uuid=%s",
serializer.validated_data["uuid"],
)
return Response(
{"code": 503, "msg": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
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)
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,
)