UPDATE
This commit is contained in:
@@ -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"]
|
||||||
@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("farm_hub", "0006_seed_expanded_product_catalog"),
|
("farm_hub", "0006_seed_expanded_product_catalog"),
|
||||||
("sensor_catalog", "0003_sensorcatalog_code"),
|
("device_hub", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -61,7 +61,7 @@ class Migration(migrations.Migration):
|
|||||||
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
|
("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")),
|
("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")),
|
("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")),
|
("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")),
|
||||||
],
|
],
|
||||||
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
|
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class AccessRule(models.Model):
|
|||||||
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
|
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
|
||||||
farm_types = models.ManyToManyField("farm_hub.FarmType", 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)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+1
-3
@@ -40,14 +40,13 @@ INSTALLED_APPS = [
|
|||||||
"auth.apps.AuthConfig",
|
"auth.apps.AuthConfig",
|
||||||
"account.apps.AccountConfig",
|
"account.apps.AccountConfig",
|
||||||
"farm_hub.apps.FarmHubConfig",
|
"farm_hub.apps.FarmHubConfig",
|
||||||
|
"device_hub.apps.DeviceHubConfig",
|
||||||
"access_control.apps.AccessControlConfig",
|
"access_control.apps.AccessControlConfig",
|
||||||
"sensor_catalog.apps.SensorCatalogConfig",
|
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"crop_health.apps.CropHealthConfig",
|
"crop_health.apps.CropHealthConfig",
|
||||||
"soil.apps.SoilConfig",
|
"soil.apps.SoilConfig",
|
||||||
"crop_zoning",
|
"crop_zoning",
|
||||||
"pest_detection",
|
"pest_detection",
|
||||||
"sensor_7_in_1.apps.Sensor7In1Config",
|
|
||||||
"water.apps.WaterConfig",
|
"water.apps.WaterConfig",
|
||||||
"irrigation",
|
"irrigation",
|
||||||
"yield_harvest.apps.YieldHarvestConfig",
|
"yield_harvest.apps.YieldHarvestConfig",
|
||||||
@@ -60,7 +59,6 @@ INSTALLED_APPS = [
|
|||||||
"farmer_calendar.apps.FarmerCalendarConfig",
|
"farmer_calendar.apps.FarmerCalendarConfig",
|
||||||
"farmer_todos.apps.FarmerTodosConfig",
|
"farmer_todos.apps.FarmerTodosConfig",
|
||||||
"external_api_adapter.apps.ExternalApiAdapterConfig",
|
"external_api_adapter.apps.ExternalApiAdapterConfig",
|
||||||
"sensor_external_api.apps.SensorExternalApiConfig",
|
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"drf_spectacular_sidecar",
|
"drf_spectacular_sidecar",
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,7 @@ urlpatterns = [
|
|||||||
path("api/account/", include("account.urls")),
|
path("api/account/", include("account.urls")),
|
||||||
path("api/farm-hub/", include("farm_hub.urls")),
|
path("api/farm-hub/", include("farm_hub.urls")),
|
||||||
path("api/access-control/", include("access_control.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-config/", include("dashboard.urls_config")),
|
||||||
path("api/farm-dashboard/", include("dashboard.urls")),
|
path("api/farm-dashboard/", include("dashboard.urls")),
|
||||||
path("api/crop-health/", include("crop_health.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-detection/", include("pest_detection.urls")),
|
||||||
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
|
||||||
path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")),
|
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
|
||||||
path("api/sensors/", include("sensor_7_in_1.comparison_urls")),
|
path("api/sensors/", include("device_hub.comparison_urls")),
|
||||||
path("api/irrigation/", include("irrigation.urls")),
|
path("api/irrigation/", include("irrigation.urls")),
|
||||||
|
|
||||||
path("api/weather/", include("water.weather_urls")),
|
path("api/weather/", include("water.weather_urls")),
|
||||||
@@ -39,5 +39,5 @@ urlpatterns = [
|
|||||||
path("api/events/", include("farmer_calendar.urls")),
|
path("api/events/", include("farmer_calendar.urls")),
|
||||||
path("api/farmer-todos/", include("farmer_todos.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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from farm_alerts.services import (
|
|||||||
from fertilization.services import get_fertilization_dashboard_recommendation
|
from fertilization.services import get_fertilization_dashboard_recommendation
|
||||||
from irrigation.services import get_irrigation_dashboard_recommendation
|
from irrigation.services import get_irrigation_dashboard_recommendation
|
||||||
from pest_detection.services import get_risk_summary_data
|
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,
|
get_sensor_7_in_1_summary_data,
|
||||||
)
|
)
|
||||||
from yield_harvest.services import get_yield_harvest_summary_data
|
from yield_harvest.services import get_yield_harvest_summary_data
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SensorExternalApiConfig(AppConfig):
|
class DeviceHubConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
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):
|
def authenticate(self, request):
|
||||||
provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization")
|
provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization")
|
||||||
expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345")
|
expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345")
|
||||||
|
|
||||||
if not provided_key:
|
if not provided_key:
|
||||||
raise AuthenticationFailed("API key is required.")
|
raise AuthenticationFailed("API key is required.")
|
||||||
|
|
||||||
if provided_key.startswith(f"{self.keyword} "):
|
if provided_key.startswith(f"{self.keyword} "):
|
||||||
provided_key = provided_key[len(self.keyword) + 1 :]
|
provided_key = provided_key[len(self.keyword) + 1 :]
|
||||||
|
|
||||||
if provided_key != expected_key:
|
if provided_key != expected_key:
|
||||||
raise AuthenticationFailed("Invalid API key.")
|
raise AuthenticationFailed("Invalid API key.")
|
||||||
|
|
||||||
return (None, None)
|
return (None, None)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sensor_catalog.models import SensorCatalog
|
from .models import SensorCatalog
|
||||||
|
|
||||||
|
|
||||||
SENSOR_CATALOG_ITEMS = [
|
SENSOR_CATALOG_ITEMS = [
|
||||||
@@ -48,3 +48,4 @@ def seed_sensor_catalog():
|
|||||||
updated_count += 1
|
updated_count += 1
|
||||||
|
|
||||||
return results, created_count, updated_count
|
return results, created_count, updated_count
|
||||||
|
|
||||||
@@ -2,9 +2,9 @@ from django.urls import path
|
|||||||
|
|
||||||
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
|
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
|
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
|
||||||
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
|
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
|
||||||
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
|
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
|
||||||
]
|
]
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
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):
|
class Command(BaseCommand):
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
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):
|
class Command(BaseCommand):
|
||||||
@@ -19,3 +19,4 @@ class Command(BaseCommand):
|
|||||||
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
|
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("farm_hub", "0009_farmhub_irrigation_method_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[],
|
||||||
|
state_operations=[
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SensorCatalog",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("code", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||||
|
("name", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||||
|
("description", models.TextField(blank=True, default="")),
|
||||||
|
("customizable_fields", models.JSONField(blank=True, default=list)),
|
||||||
|
("supported_power_sources", models.JSONField(blank=True, default=list)),
|
||||||
|
("returned_data_fields", models.JSONField(blank=True, default=list)),
|
||||||
|
("sample_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={"db_table": "sensor_catalogs", "ordering": ["code"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FarmSensor",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("physical_device_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("specifications", models.JSONField(blank=True, default=dict)),
|
||||||
|
("power_source", models.JSONField(blank=True, default=dict)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")),
|
||||||
|
("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_sensors", to="device_hub.sensorcatalog")),
|
||||||
|
],
|
||||||
|
options={"db_table": "farm_sensors", "ordering": ["-created_at"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SensorExternalRequestLog",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("farm_uuid", models.UUIDField(db_index=True)),
|
||||||
|
("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)),
|
||||||
|
("physical_device_uuid", models.UUIDField(db_index=True)),
|
||||||
|
("payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={"db_table": "sensor_external_request_logs", "ordering": ["-created_at", "-id"]},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("device_hub", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("device_hub", "0002_absorb_sensor_7_in_1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("device_hub", "0003_absorb_sensor_external_api"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
AVG_SOIL_MOISTURE = {
|
||||||
|
"id": "avg_soil_moisture",
|
||||||
|
"title": "میانگین رطوبت خاک",
|
||||||
|
"subtitle": "سنسور 7 در 1 خاک",
|
||||||
|
"stats": "45%",
|
||||||
|
"avatarColor": "primary",
|
||||||
|
"avatarIcon": "tabler-droplet",
|
||||||
|
"chipText": "متوسط",
|
||||||
|
"chipColor": "warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_VALUES_LIST = {
|
||||||
|
"sensor": {
|
||||||
|
"name": "سنسور 7 در 1 خاک",
|
||||||
|
"physicalDeviceUuid": None,
|
||||||
|
"sensorCatalogCode": "sensor-7-in-1",
|
||||||
|
"updatedAt": None,
|
||||||
|
},
|
||||||
|
"sensors": [
|
||||||
|
{"id": "soil_moisture", "title": "45%", "subtitle": "رطوبت خاک", "trendNumber": 1.5, "trend": "positive", "unit": "%"},
|
||||||
|
{"id": "soil_temperature", "title": "22.5°C", "subtitle": "دمای خاک", "trendNumber": 0.8, "trend": "positive", "unit": "°C"},
|
||||||
|
{"id": "soil_ph", "title": "6.8", "subtitle": "pH خاک", "trendNumber": 0.1, "trend": "positive", "unit": "pH"},
|
||||||
|
{"id": "electrical_conductivity", "title": "1.2 dS/m", "subtitle": "هدایت الکتریکی", "trendNumber": -0.1, "trend": "negative", "unit": "dS/m"},
|
||||||
|
{"id": "nitrogen", "title": "30 mg/kg", "subtitle": "نیتروژن", "trendNumber": 2.0, "trend": "positive", "unit": "mg/kg"},
|
||||||
|
{"id": "phosphorus", "title": "15 mg/kg", "subtitle": "فسفر", "trendNumber": 1.0, "trend": "positive", "unit": "mg/kg"},
|
||||||
|
{"id": "potassium", "title": "20 mg/kg", "subtitle": "پتاسیم", "trendNumber": -1.0, "trend": "negative", "unit": "mg/kg"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_RADAR_CHART = {
|
||||||
|
"labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"],
|
||||||
|
"series": [{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]}, {"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_COMPARISON_CHART = {
|
||||||
|
"currentValue": 45,
|
||||||
|
"vsLastWeek": "+4.7%",
|
||||||
|
"vsLastWeekValue": 4.7,
|
||||||
|
"categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||||
|
"series": [{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]}, {"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ANOMALY_DETECTION_CARD = {
|
||||||
|
"anomalies": [{"sensor": "هدایت الکتریکی", "value": "1.2 dS/m", "expected": "0.8-1.1 dS/m", "deviation": "+0.1 dS/m", "severity": "warning"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SOIL_MOISTURE_HEATMAP = {
|
||||||
|
"zones": ["سنسور 7 در 1 خاک"],
|
||||||
|
"hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"],
|
||||||
|
"series": [{"name": "سنسور 7 در 1 خاک", "data": [{"x": "08:00", "y": 42}, {"x": "10:00", "y": 44}, {"x": "12:00", "y": 45}, {"x": "14:00", "y": 47}, {"x": "16:00", "y": 46}, {"x": "18:00", "y": 45}, {"x": "20:00", "y": 45}]}],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import uuid as uuid_lib
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SensorCatalog(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||||
|
code = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
customizable_fields = models.JSONField(default=list, blank=True)
|
||||||
|
supported_power_sources = models.JSONField(default=list, blank=True)
|
||||||
|
returned_data_fields = models.JSONField(default=list, blank=True)
|
||||||
|
sample_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "sensor_catalogs"
|
||||||
|
ordering = ["code"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class FarmSensor(models.Model):
|
||||||
|
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||||
|
farm = models.ForeignKey("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="sensors")
|
||||||
|
sensor_catalog = models.ForeignKey(
|
||||||
|
SensorCatalog,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="farm_sensors",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
sensor_type = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
specifications = models.JSONField(default=dict, blank=True)
|
||||||
|
power_source = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "farm_sensors"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.uuid})"
|
||||||
|
|
||||||
|
|
||||||
|
class SensorExternalRequestLog(models.Model):
|
||||||
|
farm_uuid = models.UUIDField(db_index=True)
|
||||||
|
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
|
||||||
|
physical_device_uuid = models.UUIDField(db_index=True)
|
||||||
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "sensor_external_request_logs"
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.physical_device_uuid}:{self.created_at.isoformat()}"
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from farm_hub.seeds import seed_admin_farm
|
||||||
|
|
||||||
|
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1"
|
||||||
|
SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777")
|
||||||
|
SENSOR_7_IN_1_LOG_SERIES = [
|
||||||
|
{"days_ago": 6, "payload": {"soil_moisture": 44.0, "soil_temperature": 20.6, "soil_ph": 6.3, "electrical_conductivity": 1.0, "nitrogen": 25.0, "phosphorus": 13.0, "potassium": 21.0}},
|
||||||
|
{"days_ago": 5, "payload": {"soil_moisture": 45.5, "soil_temperature": 21.1, "soil_ph": 6.4, "electrical_conductivity": 1.1, "nitrogen": 26.0, "phosphorus": 13.8, "potassium": 21.8}},
|
||||||
|
{"days_ago": 4, "payload": {"soil_moisture": 46.8, "soil_temperature": 21.7, "soil_ph": 6.5, "electrical_conductivity": 1.1, "nitrogen": 27.4, "phosphorus": 14.2, "potassium": 22.5}},
|
||||||
|
{"days_ago": 3, "payload": {"soil_moisture": 48.2, "soil_temperature": 22.0, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 28.9, "phosphorus": 15.1, "potassium": 23.3}},
|
||||||
|
{"days_ago": 2, "payload": {"soil_moisture": 49.6, "soil_temperature": 22.4, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 29.7, "phosphorus": 15.7, "potassium": 24.1}},
|
||||||
|
{"days_ago": 1, "payload": {"soil_moisture": 50.9, "soil_temperature": 22.8, "soil_ph": 6.7, "electrical_conductivity": 1.3, "nitrogen": 30.8, "phosphorus": 16.2, "potassium": 24.8}},
|
||||||
|
{"days_ago": 0, "payload": {"soil_moisture": 52.4, "soil_temperature": 23.1, "soil_ph": 6.8, "electrical_conductivity": 1.3, "nitrogen": 32.0, "phosphorus": 16.8, "potassium": 25.6}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_sensor_7_in_1_catalog():
|
||||||
|
sensor_catalog, created = SensorCatalog.objects.update_or_create(
|
||||||
|
code=SENSOR_7_IN_1_CATALOG_CODE,
|
||||||
|
defaults={
|
||||||
|
"name": "Sensor 7 in 1 Soil Sensor",
|
||||||
|
"description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.",
|
||||||
|
"customizable_fields": [],
|
||||||
|
"supported_power_sources": ["solar", "battery", "direct_power"],
|
||||||
|
"returned_data_fields": ["soil_moisture", "soil_temperature", "soil_ph", "electrical_conductivity", "nitrogen", "phosphorus", "potassium"],
|
||||||
|
"sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"],
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return sensor_catalog, created
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def seed_sensor_7_in_1_demo_data():
|
||||||
|
farm, _ = seed_admin_farm()
|
||||||
|
sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog()
|
||||||
|
sensor, sensor_created = FarmSensor.objects.update_or_create(
|
||||||
|
farm=farm,
|
||||||
|
physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID,
|
||||||
|
defaults={"sensor_catalog": sensor_catalog, "name": "Sensor 7 in 1 Demo", "sensor_type": "soil_7_in_1", "is_active": True, "specifications": {"capabilities": sensor_catalog.returned_data_fields, "demo_seed": True}, "power_source": {"type": "solar"}},
|
||||||
|
)
|
||||||
|
SensorExternalRequestLog.objects.filter(farm_uuid=farm.farm_uuid, physical_device_uuid=sensor.physical_device_uuid).delete()
|
||||||
|
base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0)
|
||||||
|
created_logs = []
|
||||||
|
for item in SENSOR_7_IN_1_LOG_SERIES:
|
||||||
|
log = SensorExternalRequestLog.objects.create(farm_uuid=farm.farm_uuid, sensor_catalog_uuid=sensor_catalog.uuid, physical_device_uuid=sensor.physical_device_uuid, payload=item["payload"])
|
||||||
|
created_at = base_time - timedelta(days=item["days_ago"])
|
||||||
|
SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at)
|
||||||
|
log.created_at = created_at
|
||||||
|
created_logs.append(log)
|
||||||
|
return {"farm": farm, "sensor_catalog": sensor_catalog, "sensor": sensor, "catalog_created": catalog_created, "sensor_created": sensor_created, "log_count": len(created_logs)}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||||
|
path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
|
||||||
|
path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"),
|
||||||
|
]
|
||||||
@@ -5,3 +5,4 @@ from .views import SensorCatalogListView
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6,3 +6,4 @@ urlpatterns = [
|
|||||||
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
||||||
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
|
path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from soil.serializers import (
|
from soil.serializers import SoilAnomalyDetectionSerializer, SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, SoilRadarChartSerializer
|
||||||
SoilAnomalyDetectionSerializer,
|
|
||||||
SoilComparisonChartSerializer,
|
|
||||||
SoilKpiSerializer,
|
|
||||||
SoilMoistureHeatmapSerializer,
|
|
||||||
SoilRadarChartSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Sensor7In1MetaSerializer(serializers.Serializer):
|
class Sensor7In1MetaSerializer(serializers.Serializer):
|
||||||
@@ -82,3 +76,4 @@ class Sensor7In1SummarySerializer(serializers.Serializer):
|
|||||||
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
sensorComparisonChart = SoilComparisonChartSerializer(required=False)
|
||||||
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False)
|
||||||
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False)
|
||||||
|
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from farm_hub.models import FarmSensor
|
from .models import FarmSensor, SensorCatalog, SensorExternalRequestLog
|
||||||
from sensor_catalog.models import SensorCatalog
|
|
||||||
|
|
||||||
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):
|
class SensorExternalRequestSerializer(serializers.Serializer):
|
||||||
@@ -28,42 +42,6 @@ class SensorExternalRequestLogQuerySerializer(serializers.Serializer):
|
|||||||
return attrs
|
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):
|
class FarmSensorLogSerializer(serializers.ModelSerializer):
|
||||||
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
|
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
|
||||||
|
|
||||||
@@ -99,3 +77,39 @@ class SensorCatalogLogSerializer(serializers.ModelSerializer):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
|
||||||
|
farm_sensor = serializers.SerializerMethodField()
|
||||||
|
sensor_catalog = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SensorExternalRequestLog
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"farm_uuid",
|
||||||
|
"sensor_catalog_uuid",
|
||||||
|
"physical_device_uuid",
|
||||||
|
"farm_sensor",
|
||||||
|
"sensor_catalog",
|
||||||
|
"payload",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_farm_sensor(self, obj):
|
||||||
|
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
||||||
|
farm_sensor = farm_sensor_map.get(
|
||||||
|
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
|
||||||
|
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
|
||||||
|
if farm_sensor is None:
|
||||||
|
return None
|
||||||
|
return FarmSensorLogSerializer(farm_sensor).data
|
||||||
|
|
||||||
|
def get_sensor_catalog(self, obj):
|
||||||
|
farm_sensor_map = self.context.get("farm_sensor_map", {})
|
||||||
|
farm_sensor = farm_sensor_map.get(
|
||||||
|
(obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)
|
||||||
|
) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid))
|
||||||
|
if farm_sensor is None or farm_sensor.sensor_catalog is None:
|
||||||
|
return None
|
||||||
|
return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import OperationalError, ProgrammingError, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from external_api_adapter import request as external_api_request
|
||||||
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||||
|
from notifications.services import create_notification_for_farm_uuid
|
||||||
|
|
||||||
|
from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP
|
||||||
|
from .models import FarmSensor, SensorExternalRequestLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FarmDataForwardError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
SENSOR_FIELDS = [
|
||||||
|
{"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"},
|
||||||
|
{"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"},
|
||||||
|
{"id": "soil_ph", "label": "pH خاک", "unit": "pH", "payload_keys": ("soil_ph", "soilPh", "ph"), "ideal_min": 6.0, "ideal_max": 7.5, "radar_label": "pH"},
|
||||||
|
{"id": "electrical_conductivity", "label": "هدایت الکتریکی", "unit": "dS/m", "payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"), "ideal_min": 0.8, "ideal_max": 1.8, "radar_label": "EC"},
|
||||||
|
{"id": "nitrogen", "label": "نیتروژن", "unit": "mg/kg", "payload_keys": ("nitrogen", "n"), "ideal_min": 20.0, "ideal_max": 40.0, "radar_label": "نیتروژن"},
|
||||||
|
{"id": "phosphorus", "label": "فسفر", "unit": "mg/kg", "payload_keys": ("phosphorus", "p"), "ideal_min": 10.0, "ideal_max": 25.0, "radar_label": "فسفر"},
|
||||||
|
{"id": "potassium", "label": "پتاسیم", "unit": "mg/kg", "payload_keys": ("potassium", "k"), "ideal_min": 15.0, "ideal_max": 35.0, "radar_label": "پتاسیم"},
|
||||||
|
]
|
||||||
|
MAX_HISTORY_ITEMS = 20
|
||||||
|
MAX_CHART_POINTS = 7
|
||||||
|
COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30}
|
||||||
|
VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)}
|
||||||
|
RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||||||
|
PERSIAN_WEEKDAYS = {0: "دوشنبه", 1: "سه شنبه", 2: "چهارشنبه", 3: "پنج شنبه", 4: "جمعه", 5: "شنبه", 6: "یکشنبه"}
|
||||||
|
COMPARISON_CHART_FIELD_ALIASES = {"soil_moisture": "moisture", "soilMoisture": "moisture", "moisture": "moisture", "soil_temperature": "temperature", "soilTemperature": "temperature", "temperature": "temperature", "humidity": "humidity", "soil_ph": "ph", "soilPh": "ph", "ph": "ph", "electrical_conductivity": "ec", "electricalConductivity": "ec", "ec": "ec", "nitrogen": "nitrogen", "n": "nitrogen", "phosphorus": "phosphorus", "p": "phosphorus", "potassium": "potassium", "k": "potassium"}
|
||||||
|
COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium")
|
||||||
|
VALUES_LIST_FIELDS = [("moisture", "Moisture", "%"), ("temperature", "Temperature", "°C"), ("humidity", "Humidity", "%"), ("ph", "pH", "pH"), ("ec", "EC", "dS/m"), ("nitrogen", "Nitrogen", "mg/kg"), ("phosphorus", "Phosphorus", "mg/kg"), ("potassium", "Potassium", "mg/kg")]
|
||||||
|
RADAR_CHART_FIELDS = [("moisture", "Moisture", 60.0), ("temperature", "Temperature", 26.0), ("humidity", "Humidity", 55.0), ("ph", "PH", 6.5), ("ec", "EC", 1.3), ("nitrogen", "Nitrogen", 42.0), ("potassium", "Potassium", 38.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid=None, sensor_type=None, date_from=None, date_to=None):
|
||||||
|
try:
|
||||||
|
queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid)
|
||||||
|
if physical_device_uuid:
|
||||||
|
queryset = queryset.filter(physical_device_uuid=physical_device_uuid)
|
||||||
|
if sensor_type:
|
||||||
|
physical_device_uuids = FarmSensor.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True)
|
||||||
|
queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids)
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(created_at__date__gte=date_from)
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(created_at__date__lte=date_to)
|
||||||
|
return queryset.order_by("-created_at", "-id")
|
||||||
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_farm_sensor_map_for_logs(*, logs):
|
||||||
|
try:
|
||||||
|
logs = list(logs)
|
||||||
|
if not logs:
|
||||||
|
return {}
|
||||||
|
farm_sensor_queryset = FarmSensor.objects.select_related("farm", "sensor_catalog").filter(
|
||||||
|
farm__farm_uuid__in={log.farm_uuid for log in logs},
|
||||||
|
physical_device_uuid__in={log.physical_device_uuid for log in logs},
|
||||||
|
).order_by("-created_at", "-id")
|
||||||
|
farm_sensor_map = {}
|
||||||
|
for farm_sensor in farm_sensor_queryset:
|
||||||
|
exact_key = (farm_sensor.farm.farm_uuid, farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, farm_sensor.physical_device_uuid)
|
||||||
|
fallback_key = (farm_sensor.farm.farm_uuid, None, farm_sensor.physical_device_uuid)
|
||||||
|
farm_sensor_map.setdefault(exact_key, farm_sensor)
|
||||||
|
farm_sensor_map.setdefault(fallback_key, farm_sensor)
|
||||||
|
return farm_sensor_map
|
||||||
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, physical_device_uuid):
|
||||||
|
try:
|
||||||
|
return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid, sensor_catalog_uuid=sensor_catalog_uuid, physical_device_uuid=physical_device_uuid).order_by("-created_at", "-id").first()
|
||||||
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def create_sensor_external_notification(*, physical_device_uuid, payload=None):
|
||||||
|
payload = payload or {}
|
||||||
|
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
|
||||||
|
if sensor is None:
|
||||||
|
raise ValueError("Physical device not found.")
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
SensorExternalRequestLog.objects.create(
|
||||||
|
farm_uuid=sensor.farm.farm_uuid,
|
||||||
|
sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
|
||||||
|
physical_device_uuid=sensor.physical_device_uuid,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return create_notification_for_farm_uuid(
|
||||||
|
farm_uuid=sensor.farm.farm_uuid,
|
||||||
|
title="Sensor external API request",
|
||||||
|
message=f"Payload received from device {sensor.physical_device_uuid}.",
|
||||||
|
level="info",
|
||||||
|
metadata={"farm_uuid": str(sensor.farm.farm_uuid), "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, "physical_device_uuid": str(sensor.physical_device_uuid), "payload": payload},
|
||||||
|
)
|
||||||
|
except (ProgrammingError, OperationalError) as exc:
|
||||||
|
raise ValueError("Sensor external API tables are not migrated.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
|
||||||
|
payload = payload or {}
|
||||||
|
sensor = FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
|
||||||
|
if sensor is None:
|
||||||
|
raise ValueError("Physical device not found.")
|
||||||
|
farm_boundary = _get_farm_boundary(sensor=sensor)
|
||||||
|
api_key = getattr(settings, "FARM_DATA_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
|
||||||
|
sensor_key = _get_sensor_key(sensor=sensor)
|
||||||
|
normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload)
|
||||||
|
request_payload = {"farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, "sensor_key": sensor_key, "sensor_payload": normalized_sensor_payload}
|
||||||
|
try:
|
||||||
|
response = external_api_request(
|
||||||
|
"ai",
|
||||||
|
_get_farm_data_path(),
|
||||||
|
method="POST",
|
||||||
|
payload=request_payload,
|
||||||
|
headers={"Accept": "application/json", "Content-Type": "application/json", "X-API-Key": api_key, "Authorization": f"Api-Key {api_key}"},
|
||||||
|
)
|
||||||
|
except ExternalAPIRequestError as exc:
|
||||||
|
raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise FarmDataForwardError(f"Farm data API returned status {response.status_code}: {response.data}")
|
||||||
|
return request_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _get_farm_boundary(*, sensor):
|
||||||
|
crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first()
|
||||||
|
if crop_area is None:
|
||||||
|
raise FarmDataForwardError("Farm boundary is not configured for this farm.")
|
||||||
|
geometry = crop_area.geometry or {}
|
||||||
|
if geometry.get("type") == "Feature":
|
||||||
|
geometry = geometry.get("geometry") or {}
|
||||||
|
if geometry.get("type") != "Polygon":
|
||||||
|
raise FarmDataForwardError("Farm boundary geometry must be a Polygon.")
|
||||||
|
return geometry
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sensor_payload(*, sensor_key, sensor_payload):
|
||||||
|
if not sensor_payload:
|
||||||
|
return {}
|
||||||
|
if not isinstance(sensor_payload, dict):
|
||||||
|
raise FarmDataForwardError("`payload` must be a JSON object.")
|
||||||
|
if all(isinstance(value, dict) for value in sensor_payload.values()):
|
||||||
|
return sensor_payload
|
||||||
|
return {sensor_key: sensor_payload}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sensor_key(*, sensor):
|
||||||
|
if sensor.sensor_catalog and sensor.sensor_catalog.code:
|
||||||
|
return sensor.sensor_catalog.code
|
||||||
|
return "sensor-7-1"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_farm_data_path():
|
||||||
|
return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value):
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_payload(payload):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
if isinstance(payload.get("payload"), dict):
|
||||||
|
payload = payload["payload"]
|
||||||
|
if isinstance(payload.get("data"), dict):
|
||||||
|
nested = payload["data"]
|
||||||
|
if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS):
|
||||||
|
payload = nested
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_numeric_payload(payload):
|
||||||
|
payload = _extract_payload(payload)
|
||||||
|
return {key: numeric_value for key, value in payload.items() if (numeric_value := _to_float(value)) is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_readings(payload):
|
||||||
|
payload = _extract_payload(payload)
|
||||||
|
readings = {}
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
for key in field["payload_keys"]:
|
||||||
|
value = _to_float(payload.get(key))
|
||||||
|
if value is not None:
|
||||||
|
readings[field["id"]] = value
|
||||||
|
break
|
||||||
|
return readings
|
||||||
|
|
||||||
|
|
||||||
|
def _format_number(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if float(value).is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:.1f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value, unit):
|
||||||
|
number = _format_number(value)
|
||||||
|
if not number:
|
||||||
|
return number
|
||||||
|
if unit in {"", "pH"}:
|
||||||
|
return number
|
||||||
|
if unit in {"%", "°C"}:
|
||||||
|
return f"{number}{unit}"
|
||||||
|
return f"{number} {unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_range(field):
|
||||||
|
lower = _format_number(field["ideal_min"])
|
||||||
|
upper = _format_number(field["ideal_max"])
|
||||||
|
if field["unit"] in {"", "pH"}:
|
||||||
|
return f"{lower}-{upper}"
|
||||||
|
return f"{lower}-{upper} {field['unit']}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_primary_soil_sensor(*, farm):
|
||||||
|
soil_sensors = list(farm.sensors.select_related("sensor_catalog").order_by("created_at", "id"))
|
||||||
|
|
||||||
|
def _sensor_priority(sensor):
|
||||||
|
sensor_type = (sensor.sensor_type or "").lower()
|
||||||
|
catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower()
|
||||||
|
catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower()
|
||||||
|
sensor_name = (sensor.name or "").lower()
|
||||||
|
haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name])
|
||||||
|
if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type:
|
||||||
|
return 0
|
||||||
|
if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack:
|
||||||
|
return 1
|
||||||
|
if "soil" in haystack:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
prioritized_sensors = sorted(soil_sensors, key=_sensor_priority)
|
||||||
|
if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3:
|
||||||
|
return prioritized_sensors[0]
|
||||||
|
return soil_sensors[0] if soil_sensors else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sensor_context(farm=None):
|
||||||
|
if farm is None:
|
||||||
|
return None
|
||||||
|
primary_sensor = get_primary_soil_sensor(farm=farm)
|
||||||
|
if primary_sensor is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
history = []
|
||||||
|
for log in logs_queryset[:MAX_HISTORY_ITEMS]:
|
||||||
|
readings = _extract_readings(log.payload)
|
||||||
|
if readings:
|
||||||
|
history.append((log, readings))
|
||||||
|
if not history:
|
||||||
|
return None
|
||||||
|
latest_log, latest_readings = history[0]
|
||||||
|
farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log])
|
||||||
|
farm_sensor = farm_sensor_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor
|
||||||
|
return {"farm_sensor": farm_sensor, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sensor_meta(context, fallback_sensor):
|
||||||
|
sensor = deepcopy(fallback_sensor)
|
||||||
|
if not context:
|
||||||
|
return sensor
|
||||||
|
farm_sensor = context.get("farm_sensor")
|
||||||
|
latest_log = context["latest_log"]
|
||||||
|
sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid)
|
||||||
|
sensor["updatedAt"] = latest_log.created_at.isoformat()
|
||||||
|
if farm_sensor is not None:
|
||||||
|
sensor["name"] = farm_sensor.name or sensor["name"]
|
||||||
|
if farm_sensor.sensor_catalog is not None:
|
||||||
|
sensor["sensorCatalogCode"] = farm_sensor.sensor_catalog.code
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_status_chip(value):
|
||||||
|
if value is None:
|
||||||
|
return ("نامشخص", "secondary", "secondary")
|
||||||
|
if value >= 60:
|
||||||
|
return ("بهینه", "success", "primary")
|
||||||
|
if value >= 45:
|
||||||
|
return ("متوسط", "warning", "warning")
|
||||||
|
return ("کم", "error", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_values_list_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_VALUES_LIST)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
data["sensor"] = _build_sensor_meta(context, data["sensor"])
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
latest_readings = context["latest_readings"]
|
||||||
|
previous_readings = context["previous_readings"]
|
||||||
|
sensors = []
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = latest_readings.get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
previous = previous_readings.get(field["id"])
|
||||||
|
change = 0.0 if previous is None else round(value - previous, 2)
|
||||||
|
sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]})
|
||||||
|
if sensors:
|
||||||
|
data["sensors"] = sensors
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None):
|
||||||
|
data = deepcopy(AVG_SOIL_MOISTURE)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
moisture = context["latest_readings"].get("soil_moisture")
|
||||||
|
if moisture is None:
|
||||||
|
return data
|
||||||
|
chip_text, chip_color, avatar_color = _calculate_status_chip(moisture)
|
||||||
|
data["stats"] = _format_value(moisture, "%")
|
||||||
|
data["chipText"] = chip_text
|
||||||
|
data["chipColor"] = chip_color
|
||||||
|
data["avatarColor"] = avatar_color
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _score_field(value, field):
|
||||||
|
min_value = field["ideal_min"]
|
||||||
|
max_value = field["ideal_max"]
|
||||||
|
midpoint = (min_value + max_value) / 2
|
||||||
|
half_span = max((max_value - min_value) / 2, 0.1)
|
||||||
|
distance = abs(value - midpoint)
|
||||||
|
if min_value <= value <= max_value:
|
||||||
|
return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1)
|
||||||
|
overflow = max(0.0, distance - half_span)
|
||||||
|
return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_radar_chart_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_RADAR_CHART)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
latest_readings = context["latest_readings"]
|
||||||
|
scores, labels = [], []
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = latest_readings.get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
labels.append(field["radar_label"])
|
||||||
|
scores.append(_score_field(value, field))
|
||||||
|
if labels:
|
||||||
|
data["labels"] = labels
|
||||||
|
data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SENSOR_COMPARISON_CHART)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||||
|
moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None]
|
||||||
|
if not moisture_points:
|
||||||
|
return data
|
||||||
|
categories = [item[0] for item in moisture_points]
|
||||||
|
values = [round(item[1], 2) for item in moisture_points]
|
||||||
|
current_value = values[-1]
|
||||||
|
baseline_value = values[0] if len(values) > 1 else 55.0
|
||||||
|
percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0
|
||||||
|
data["currentValue"] = round(current_value, 2)
|
||||||
|
data["vsLastWeekValue"] = round(percent_change, 2)
|
||||||
|
data["vsLastWeek"] = f"{percent_change:+.1f}%"
|
||||||
|
data["categories"] = categories
|
||||||
|
data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _build_anomaly_item(field, value):
|
||||||
|
lower = field["ideal_min"]
|
||||||
|
upper = field["ideal_max"]
|
||||||
|
if lower <= value <= upper:
|
||||||
|
return None
|
||||||
|
deviation = value - upper if value > upper else value - lower
|
||||||
|
severity = "warning"
|
||||||
|
span = max(upper - lower, 0.1)
|
||||||
|
if abs(deviation) >= span * 0.5:
|
||||||
|
severity = "error"
|
||||||
|
sign = "+" if deviation > 0 else ""
|
||||||
|
return {"sensor": field["label"], "value": _format_value(value, field["unit"]), "expected": _format_range(field), "deviation": f"{sign}{_format_value(deviation, field['unit'])}", "severity": severity}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None):
|
||||||
|
data = deepcopy(ANOMALY_DETECTION_CARD)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
anomalies = []
|
||||||
|
for field in SENSOR_FIELDS:
|
||||||
|
value = context["latest_readings"].get(field["id"])
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
anomaly = _build_anomaly_item(field, value)
|
||||||
|
if anomaly is not None:
|
||||||
|
anomalies.append(anomaly)
|
||||||
|
data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخصها در بازه مجاز هستند", "deviation": "0", "severity": "success"}]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None):
|
||||||
|
data = deepcopy(SOIL_MOISTURE_HEATMAP)
|
||||||
|
context = _get_sensor_context(farm) if context is None else context
|
||||||
|
if not context:
|
||||||
|
return data
|
||||||
|
history = list(reversed(context["history"][:MAX_CHART_POINTS]))
|
||||||
|
chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None]
|
||||||
|
if not chart_points:
|
||||||
|
return data
|
||||||
|
sensor_name = data["zones"][0]
|
||||||
|
farm_sensor = context.get("farm_sensor")
|
||||||
|
if farm_sensor is not None and farm_sensor.name:
|
||||||
|
sensor_name = farm_sensor.name
|
||||||
|
data["zones"] = [sensor_name]
|
||||||
|
data["hours"] = [point["x"] for point in chart_points]
|
||||||
|
data["series"] = [{"name": sensor_name, "data": chart_points}]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_7_in_1_summary_data(farm=None):
|
||||||
|
context = _get_sensor_context(farm)
|
||||||
|
values_list = get_sensor_7_in_1_values_list_data(farm, context=context)
|
||||||
|
return {"sensor": values_list["sensor"], "sensorValuesList": values_list, "avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context), "sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context), "sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context), "anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context), "soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context)}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_comparison_chart_field(field_name):
|
||||||
|
return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_comparison_category(bucket_date, range_value):
|
||||||
|
return PERSIAN_WEEKDAYS[bucket_date.weekday()] if range_value == "7d" else bucket_date.strftime("%m/%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_percent_change(current_value, baseline_value):
|
||||||
|
if not baseline_value:
|
||||||
|
return "+0.0%"
|
||||||
|
return f"{((current_value - baseline_value) / baseline_value) * 100:+.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_current_value_subtitle(title, value, unit):
|
||||||
|
rendered_value = _format_value(value, unit)
|
||||||
|
return f"مقدار فعلی: {rendered_value or title}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
days = COMPARISON_CHART_RANGES[range_value]
|
||||||
|
start_date = timezone.localdate() - timedelta(days=days - 1)
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid, date_from=start_date)
|
||||||
|
except ValueError:
|
||||||
|
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||||
|
grouped_logs = {}
|
||||||
|
for log in reversed(list(logs_queryset[: days * 24])):
|
||||||
|
bucket_date = timezone.localtime(log.created_at).date()
|
||||||
|
numeric_payload = _extract_numeric_payload(log.payload)
|
||||||
|
if numeric_payload:
|
||||||
|
grouped_logs[bucket_date] = numeric_payload
|
||||||
|
if not grouped_logs:
|
||||||
|
return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"}
|
||||||
|
sorted_dates = sorted(grouped_logs.keys())
|
||||||
|
categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates]
|
||||||
|
series_map = {}
|
||||||
|
for bucket_date in sorted_dates:
|
||||||
|
normalized_payload = {_normalize_comparison_chart_field(key): value for key, value in grouped_logs[bucket_date].items()}
|
||||||
|
for key, value in normalized_payload.items():
|
||||||
|
series_map.setdefault(key, []).append(round(value, 2))
|
||||||
|
ordered_field_names = [field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS)
|
||||||
|
series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names]
|
||||||
|
primary_data = series_map[ordered_field_names[0]]
|
||||||
|
return {"series": series, "categories": categories, "currentValue": round(primary_data[-1], 2), "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0])}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
start_time = timezone.now() - VALUES_LIST_RANGES[range_value]
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid)
|
||||||
|
except ValueError:
|
||||||
|
return {"sensors": []}
|
||||||
|
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
|
||||||
|
if not logs:
|
||||||
|
latest_log = logs_queryset.order_by("-created_at", "-id").first()
|
||||||
|
if latest_log is None:
|
||||||
|
return {"sensors": []}
|
||||||
|
logs = [latest_log]
|
||||||
|
earliest_payload, latest_payload = {}, {}
|
||||||
|
for log in logs:
|
||||||
|
numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()}
|
||||||
|
if not numeric_payload:
|
||||||
|
continue
|
||||||
|
if not earliest_payload:
|
||||||
|
earliest_payload = numeric_payload
|
||||||
|
latest_payload = numeric_payload
|
||||||
|
if not latest_payload:
|
||||||
|
return {"sensors": []}
|
||||||
|
sensors = []
|
||||||
|
for field_name, title, unit in VALUES_LIST_FIELDS:
|
||||||
|
current_value = latest_payload.get(field_name)
|
||||||
|
if current_value is None:
|
||||||
|
continue
|
||||||
|
previous_value = earliest_payload.get(field_name, current_value)
|
||||||
|
delta = round(current_value - previous_value, 2)
|
||||||
|
sensors.append({"title": title, "subtitle": _format_current_value_subtitle(title, current_value, unit), "trendNumber": abs(delta), "trend": "positive" if delta >= 0 else "negative", "unit": unit})
|
||||||
|
return {"sensors": sensors}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value):
|
||||||
|
start_time = timezone.now() - RADAR_CHART_RANGES[range_value]
|
||||||
|
try:
|
||||||
|
logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid)
|
||||||
|
except ValueError:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id"))
|
||||||
|
if not logs:
|
||||||
|
latest_log = logs_queryset.order_by("-created_at", "-id").first()
|
||||||
|
if latest_log is None:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
logs = [latest_log]
|
||||||
|
latest_payload = {}
|
||||||
|
for log in logs:
|
||||||
|
numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()}
|
||||||
|
if numeric_payload:
|
||||||
|
latest_payload = numeric_payload
|
||||||
|
if not latest_payload:
|
||||||
|
return {"labels": [], "series": []}
|
||||||
|
labels, current_data, ideal_data = [], [], []
|
||||||
|
for field_name, label, ideal_value in RADAR_CHART_FIELDS:
|
||||||
|
current_value = latest_payload.get(field_name)
|
||||||
|
if current_value is None:
|
||||||
|
continue
|
||||||
|
labels.append(label)
|
||||||
|
current_data.append(round(current_value, 2))
|
||||||
|
ideal_data.append(round(ideal_value, 2))
|
||||||
|
return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import Sensor7In1SummaryView, SensorCatalogListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
|
||||||
|
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||||
|
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
||||||
|
path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema
|
||||||
|
|
||||||
|
from config.swagger import code_response, farm_uuid_query_param
|
||||||
|
from farm_hub.models import FarmHub
|
||||||
|
from notifications.serializers import FarmNotificationSerializer
|
||||||
|
from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer
|
||||||
|
|
||||||
|
from .authentication import SensorExternalAPIKeyAuthentication
|
||||||
|
from .sensor_serializers import Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
|
||||||
|
from .serializers import SensorCatalogSerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
|
||||||
|
from .services import FarmDataForwardError, create_sensor_external_notification, forward_sensor_payload_to_farm_data, get_farm_sensor_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data
|
||||||
|
|
||||||
|
|
||||||
|
class SensorCatalogListView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(tags=["Sensor Catalog"], responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))})
|
||||||
|
def get(self, request):
|
||||||
|
from .models import SensorCatalog
|
||||||
|
return Response({"code": 200, "msg": "success", "data": SensorCatalogSerializer(SensorCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1SummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
required_feature_code = "sensor-7-in-1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_farm(request):
|
||||||
|
farm_uuid = request.query_params.get("farm_uuid")
|
||||||
|
if not farm_uuid:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||||
|
try:
|
||||||
|
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||||
|
except FarmHub.DoesNotExist as exc:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_primary_sensor(*, farm):
|
||||||
|
sensor = get_primary_soil_sensor(farm=farm)
|
||||||
|
if sensor is None:
|
||||||
|
raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]})
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())})
|
||||||
|
def get(self, request):
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1RadarChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())})
|
||||||
|
def get(self, request):
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class Sensor7In1ComparisonChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())})
|
||||||
|
def get(self, request):
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorComparisonChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer})
|
||||||
|
def get(self, request):
|
||||||
|
serializer = SensorComparisonChartQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorValuesListView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer})
|
||||||
|
def get(self, request):
|
||||||
|
serializer = SensorValuesListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorRadarChartView(Sensor7In1SummaryView):
|
||||||
|
@extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer})
|
||||||
|
def get(self, request):
|
||||||
|
serializer = SensorRadarChartQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
farm = self._get_farm(request)
|
||||||
|
sensor = self._get_primary_sensor(farm=farm)
|
||||||
|
return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorExternalRequestLogPagination(PageNumberPagination):
|
||||||
|
page_size = 20
|
||||||
|
page_size_query_param = "page_size"
|
||||||
|
max_page_size = 100
|
||||||
|
|
||||||
|
|
||||||
|
class SensorExternalAPIView(APIView):
|
||||||
|
authentication_classes = [SensorExternalAPIKeyAuthentication]
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@extend_schema(tags=["Sensor External API"], request=SensorExternalRequestSerializer, examples=[OpenApiExample("Sensor External API Request", value={"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"moisture_percent": 32.5, "temperature_c": 21.3, "ph": 6.7, "ec_ds_m": 1.1, "nitrogen_mg_kg": 42, "phosphorus_mg_kg": 18, "potassium_mg_kg": 210}}, request_only=True)], parameters=[OpenApiParameter(name="X-API-Key", type=OpenApiTypes.STR, location=OpenApiParameter.HEADER, required=True, default="12345", description="API key for sensor external API.")], responses={201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), 503: code_response("SensorExternalAPIUnavailableResponse")})
|
||||||
|
def post(self, request):
|
||||||
|
serializer = SensorExternalRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
try:
|
||||||
|
notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
|
||||||
|
forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
|
||||||
|
except ValueError as exc:
|
||||||
|
if "not migrated" in str(exc):
|
||||||
|
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except FarmDataForwardError as exc:
|
||||||
|
return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
return Response({"code": 201, "msg": "success", "data": FarmNotificationSerializer(notification).data}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class SensorExternalRequestLogListAPIView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = SensorExternalRequestLogPagination
|
||||||
|
|
||||||
|
@extend_schema(tags=["Sensor External API"], parameters=[OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False)], responses={200: code_response("SensorExternalRequestLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)}), 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), 503: code_response("SensorExternalRequestLogListUnavailableResponse")})
|
||||||
|
def get(self, request):
|
||||||
|
serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
try:
|
||||||
|
queryset = get_sensor_external_request_logs_for_farm(farm_uuid=serializer.validated_data["farm_uuid"], physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), sensor_type=serializer.validated_data.get("sensor_type"), date_from=serializer.validated_data.get("date_from"), date_to=serializer.validated_data.get("date_to"))
|
||||||
|
except ValueError:
|
||||||
|
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
paginator.page_size = serializer.validated_data["page_size"]
|
||||||
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
|
farm_sensor_map = get_farm_sensor_map_for_logs(logs=page)
|
||||||
|
data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_sensor_map": farm_sensor_map}).data
|
||||||
|
return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: docker.iranserver.com/mysql:8
|
image: mirror-docker.runflare.com/library/mysql:8
|
||||||
container_name: croplogic-db
|
container_name: croplogic-db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_NAME}
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
- crop_network
|
- crop_network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: docker.iranserver.com/redis
|
image: mirror-docker.runflare.com/library/redis:7-alpine
|
||||||
container_name: backend-redis
|
container_name: backend-redis
|
||||||
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
|
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
- crop_network
|
- crop_network
|
||||||
|
|
||||||
accsess:
|
accsess:
|
||||||
image: openpolicyagent/opa:0.67.1-static
|
image: mirror-docker.runflare.com/openpolicyagent/opa:0.67.1-static
|
||||||
container_name: backend-accsess
|
container_name: backend-accsess
|
||||||
command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"]
|
command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"]
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +48,15 @@ services:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
container_name: backend-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_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -76,7 +84,15 @@ services:
|
|||||||
|
|
||||||
celery:
|
celery:
|
||||||
container_name: backend-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"]
|
command: ["celery", "-A", "config", "worker", "-l", "info"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -104,7 +120,15 @@ services:
|
|||||||
|
|
||||||
celery-beat:
|
celery-beat:
|
||||||
container_name: backend-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"]
|
command: ["celery", "-A", "config", "beat", "-l", "info"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
|
("device_hub", "0001_initial"),
|
||||||
("farm_hub", "0002_seed_default_catalog"),
|
("farm_hub", "0002_seed_default_catalog"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
|||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
related_name="farm_sensors",
|
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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,7 +2,6 @@ import uuid as uuid_lib
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from sensor_catalog.models import SensorCatalog
|
|
||||||
|
|
||||||
|
|
||||||
class FarmType(models.Model):
|
class FarmType(models.Model):
|
||||||
@@ -123,33 +122,3 @@ class FarmHub(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.farm_uuid})"
|
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
@@ -3,8 +3,8 @@ import uuid
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from account.seeds import seed_admin_user
|
from account.seeds import seed_admin_user
|
||||||
from sensor_catalog.management import seed_sensor_catalog
|
from device_hub.catalog_seed import seed_sensor_catalog
|
||||||
from sensor_catalog.models import SensorCatalog
|
from device_hub.models import SensorCatalog
|
||||||
|
|
||||||
from .catalog import CATALOG_SEED_DATA
|
from .catalog import CATALOG_SEED_DATA
|
||||||
from .models import FarmHub, FarmType, Product
|
from .models import FarmHub, FarmType, Product
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ from access_control.models import SubscriptionPlan
|
|||||||
from access_control.serializers import SubscriptionPlanSerializer
|
from access_control.serializers import SubscriptionPlanSerializer
|
||||||
from access_control.catalog import GOLD_PLAN_CODE
|
from access_control.catalog import GOLD_PLAN_CODE
|
||||||
from access_control.services import get_effective_subscription_plan
|
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 .services import normalize_farm_boundary_input
|
||||||
from sensor_catalog.models import SensorCatalog
|
|
||||||
|
|
||||||
|
|
||||||
class FarmTypeSerializer(serializers.ModelSerializer):
|
class FarmTypeSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from crop_zoning.services import (
|
|||||||
)
|
)
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||||
|
from plants.services import push_plants_to_ai
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -65,6 +66,7 @@ def sync_farm_data(
|
|||||||
plant_ids=None,
|
plant_ids=None,
|
||||||
irrigation_method_id=None,
|
irrigation_method_id=None,
|
||||||
):
|
):
|
||||||
|
push_plants_to_ai()
|
||||||
request_payload = {
|
request_payload = {
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
"farm_boundary": _extract_boundary_geometry(area_feature, farm=farm),
|
"farm_boundary": _extract_boundary_geometry(area_feature, farm=farm),
|
||||||
|
|||||||
+1
-1
@@ -7,12 +7,12 @@ from access_control.models import AccessFeature, AccessRule, FarmAccessProfile,
|
|||||||
from access_control.services import build_farm_access_profile
|
from access_control.services import build_farm_access_profile
|
||||||
from access_control.views import FarmAccessProfileView
|
from access_control.views import FarmAccessProfileView
|
||||||
from crop_zoning.models import CropArea
|
from crop_zoning.models import CropArea
|
||||||
|
from device_hub.models import SensorCatalog
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType, Product
|
from farm_hub.models import FarmHub, FarmType, Product
|
||||||
from farm_hub.serializers import FarmHubSerializer
|
from farm_hub.serializers import FarmHubSerializer
|
||||||
from farm_hub.seeds import seed_admin_farm
|
from farm_hub.seeds import seed_admin_farm
|
||||||
from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView
|
||||||
from sensor_catalog.models import SensorCatalog
|
|
||||||
|
|
||||||
|
|
||||||
AREA_GEOJSON = {
|
AREA_GEOJSON = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&pag
|
|||||||
"crop_id": "گندم",
|
"crop_id": "گندم",
|
||||||
"plant_name": "گندم",
|
"plant_name": "گندم",
|
||||||
"growth_stage": "flowering",
|
"growth_stage": "flowering",
|
||||||
"is_active": true,
|
"is_active": false,
|
||||||
"created_at": "2025-02-24T10:20:30Z"
|
"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هایی برگردانده میشوند که `is_deleted=False` باشند.
|
||||||
- ترتیب لیست از جدید به قدیم است.
|
- ترتیب لیست از جدید به قدیم است.
|
||||||
|
- در هر مزرعه، در هر نوع plan فقط یک plan میتواند `is_active=true` باشد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
|||||||
"crop_id": "گندم",
|
"crop_id": "گندم",
|
||||||
"plant_name": "گندم",
|
"plant_name": "گندم",
|
||||||
"growth_stage": "flowering",
|
"growth_stage": "flowering",
|
||||||
"is_active": true,
|
"is_active": false,
|
||||||
"created_at": "2025-02-24T10:20:30Z",
|
"created_at": "2025-02-24T10:20:30Z",
|
||||||
"updated_at": "2025-02-24T10:20:30Z",
|
"updated_at": "2025-02-24T10:20:30Z",
|
||||||
"plan_payload": {
|
"plan_payload": {
|
||||||
@@ -198,7 +199,7 @@ Content-Type: application/json
|
|||||||
"msg": "success",
|
"msg": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
"is_active": false
|
"is_active": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -226,6 +227,8 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
- planهای جدید بهصورت پیشفرض `inactive` ساخته میشوند.
|
||||||
|
- در هر مزرعه فقط یک plan از این نوع میتواند `active` باشد.
|
||||||
- `GET /api/fertilization/plans/` لیست برنامهها
|
- `GET /api/fertilization/plans/` لیست برنامهها
|
||||||
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
|
- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه
|
||||||
- `DELETE /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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -72,7 +72,7 @@ class FertilizationPlan(models.Model):
|
|||||||
plan_payload = models.JSONField(default=dict, blank=True)
|
plan_payload = models.JSONField(default=dict, blank=True)
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
response_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)
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
+72
-4
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
|
from farmer_calendar.models import FarmerCalendarEvent
|
||||||
from .models import FertilizationPlan, FertilizationRecommendationRequest
|
from .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||||
from .views import (
|
from .views import (
|
||||||
FertilizationPlanDetailView,
|
FertilizationPlanDetailView,
|
||||||
@@ -159,7 +160,7 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
self.assertEqual(saved_request.growth_stage, "vegetative")
|
self.assertEqual(saved_request.growth_stage, "vegetative")
|
||||||
self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION)
|
self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION)
|
||||||
self.assertEqual(saved_plan.recommendation_id, saved_request.id)
|
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.assertFalse(saved_plan.is_deleted)
|
||||||
self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -280,7 +281,7 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
self.assertEqual(plan.title, "برنامه کوددهی گندم")
|
self.assertEqual(plan.title, "برنامه کوددهی گندم")
|
||||||
self.assertEqual(plan.crop_id, "گندم")
|
self.assertEqual(plan.crop_id, "گندم")
|
||||||
self.assertEqual(plan.growth_stage, "flowering")
|
self.assertEqual(plan.growth_stage, "flowering")
|
||||||
self.assertTrue(plan.is_active)
|
self.assertFalse(plan.is_active)
|
||||||
self.assertFalse(plan.is_deleted)
|
self.assertFalse(plan.is_deleted)
|
||||||
|
|
||||||
def test_recommendation_list_returns_paginated_summary_items(self):
|
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):
|
def test_plan_status_patch_updates_is_active(self):
|
||||||
request = self.factory.patch(
|
request = self.factory.patch(
|
||||||
f"/api/fertilization/plans/{self.plan.uuid}/status/",
|
f"/api/fertilization/plans/{self.plan.uuid}/status/",
|
||||||
{"is_active": False},
|
{"is_active": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
@@ -482,4 +483,71 @@ class FertilizationPlanApiTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.plan.refresh_from_db()
|
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
@@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema
|
|||||||
from config.swagger import code_response, status_response
|
from config.swagger import code_response, status_response
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from farm_hub.models import FarmHub
|
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 .models import FertilizationPlan, FertilizationRecommendationRequest
|
||||||
from .services import build_active_plan_context
|
from .services import build_active_plan_context
|
||||||
from .mock_data import CONFIG_RESPONSE_DATA
|
from .mock_data import CONFIG_RESPONSE_DATA
|
||||||
@@ -371,7 +372,7 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
def _create_plan_from_recommendation(self, recommendation, public_data):
|
def _create_plan_from_recommendation(self, recommendation, public_data):
|
||||||
primary_recommendation = public_data.get("primary_recommendation", {})
|
primary_recommendation = public_data.get("primary_recommendation", {})
|
||||||
FertilizationPlan.objects.create(
|
plan = FertilizationPlan.objects.create(
|
||||||
farm=recommendation.farm,
|
farm=recommendation.farm,
|
||||||
source=FertilizationPlan.SOURCE_RECOMMENDATION,
|
source=FertilizationPlan.SOURCE_RECOMMENDATION,
|
||||||
recommendation=recommendation,
|
recommendation=recommendation,
|
||||||
@@ -382,6 +383,7 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
request_payload=recommendation.request_payload,
|
request_payload=recommendation.request_payload,
|
||||||
response_payload=recommendation.response_payload,
|
response_payload=recommendation.response_payload,
|
||||||
)
|
)
|
||||||
|
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _enrich_ai_payload(payload, farm):
|
def _enrich_ai_payload(payload, farm):
|
||||||
@@ -581,7 +583,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
final_plan = self._extract_final_plan(response_data)
|
final_plan = self._extract_final_plan(response_data)
|
||||||
if final_plan and farm_uuid:
|
if final_plan and farm_uuid:
|
||||||
FertilizationPlan.objects.create(
|
plan = FertilizationPlan.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
source=FertilizationPlan.SOURCE_FREE_TEXT,
|
||||||
title=self._build_free_text_plan_title(final_plan),
|
title=self._build_free_text_plan_title(final_plan),
|
||||||
@@ -596,6 +598,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
request_payload=payload,
|
request_payload=payload,
|
||||||
response_payload=response_data,
|
response_payload=response_data,
|
||||||
)
|
)
|
||||||
|
sync_plan_events(plan, PLAN_TYPE_FERTILIZATION)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
{"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)
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
plan.soft_delete()
|
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)
|
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:
|
if plan is None:
|
||||||
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
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"])
|
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(
|
return Response(
|
||||||
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1
|
|||||||
"crop_id": "گندم",
|
"crop_id": "گندم",
|
||||||
"plant_name": "گندم",
|
"plant_name": "گندم",
|
||||||
"growth_stage": "flowering",
|
"growth_stage": "flowering",
|
||||||
"is_active": true,
|
"is_active": false,
|
||||||
"created_at": "2025-02-24T10:20:30Z"
|
"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هایی برگردانده میشوند که `is_deleted=False` باشند.
|
||||||
- ترتیب لیست از جدید به قدیم است.
|
- ترتیب لیست از جدید به قدیم است.
|
||||||
|
- در هر مزرعه، در هر نوع plan فقط یک plan میتواند `is_active=true` باشد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
|
|||||||
"crop_id": "گندم",
|
"crop_id": "گندم",
|
||||||
"plant_name": "گندم",
|
"plant_name": "گندم",
|
||||||
"growth_stage": "flowering",
|
"growth_stage": "flowering",
|
||||||
"is_active": true,
|
"is_active": false,
|
||||||
"created_at": "2025-02-24T10:20:30Z",
|
"created_at": "2025-02-24T10:20:30Z",
|
||||||
"updated_at": "2025-02-24T10:20:30Z",
|
"updated_at": "2025-02-24T10:20:30Z",
|
||||||
"plan_payload": {
|
"plan_payload": {
|
||||||
@@ -195,7 +196,7 @@ Content-Type: application/json
|
|||||||
"msg": "success",
|
"msg": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
|
||||||
"is_active": false
|
"is_active": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -223,6 +224,8 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
- planهای جدید بهصورت پیشفرض `inactive` ساخته میشوند.
|
||||||
|
- در هر مزرعه فقط یک plan از این نوع میتواند `active` باشد.
|
||||||
- `GET /api/irrigation/plans/` لیست برنامهها
|
- `GET /api/irrigation/plans/` لیست برنامهها
|
||||||
- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه
|
- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه
|
||||||
- `DELETE /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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -74,7 +74,7 @@ class IrrigationPlan(models.Model):
|
|||||||
plan_payload = models.JSONField(default=dict, blank=True)
|
plan_payload = models.JSONField(default=dict, blank=True)
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
response_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)
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
+71
-3
@@ -6,6 +6,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate
|
|||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
|
from farmer_calendar.models import FarmerCalendarEvent
|
||||||
|
|
||||||
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
from .models import IrrigationPlan, IrrigationRecommendationRequest
|
||||||
from .views import (
|
from .views import (
|
||||||
@@ -312,7 +313,7 @@ class RecommendViewTests(TestCase):
|
|||||||
self.assertEqual(IrrigationPlan.objects.count(), 1)
|
self.assertEqual(IrrigationPlan.objects.count(), 1)
|
||||||
plan = IrrigationPlan.objects.get()
|
plan = IrrigationPlan.objects.get()
|
||||||
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
|
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
|
||||||
self.assertTrue(plan.is_active)
|
self.assertFalse(plan.is_active)
|
||||||
self.assertFalse(plan.is_deleted)
|
self.assertFalse(plan.is_deleted)
|
||||||
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
|
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
|
||||||
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
|
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):
|
def test_plan_status_patch_updates_is_active(self):
|
||||||
request = self.factory.patch(
|
request = self.factory.patch(
|
||||||
f"/api/irrigation/plans/{self.plan.uuid}/status/",
|
f"/api/irrigation/plans/{self.plan.uuid}/status/",
|
||||||
{"is_active": False},
|
{"is_active": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
@@ -579,4 +580,71 @@ class IrrigationPlanApiTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.plan.refresh_from_db()
|
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
@@ -15,6 +15,7 @@ from drf_spectacular.utils import extend_schema
|
|||||||
from config.swagger import code_response, status_response
|
from config.swagger import code_response, status_response
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from farm_hub.models import FarmHub
|
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.serializers import WaterStressIndexSerializer
|
||||||
from water.views import WaterStressIndexView
|
from water.views import WaterStressIndexView
|
||||||
from .mock_data import CONFIG_RESPONSE_DATA
|
from .mock_data import CONFIG_RESPONSE_DATA
|
||||||
@@ -171,7 +172,7 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
return " - ".join(parts) if parts else "برنامه آبیاری"
|
return " - ".join(parts) if parts else "برنامه آبیاری"
|
||||||
|
|
||||||
def _create_plan_from_recommendation(self, recommendation, recommendation_data):
|
def _create_plan_from_recommendation(self, recommendation, recommendation_data):
|
||||||
IrrigationPlan.objects.create(
|
plan = IrrigationPlan.objects.create(
|
||||||
farm=recommendation.farm,
|
farm=recommendation.farm,
|
||||||
source=IrrigationPlan.SOURCE_RECOMMENDATION,
|
source=IrrigationPlan.SOURCE_RECOMMENDATION,
|
||||||
recommendation=recommendation,
|
recommendation=recommendation,
|
||||||
@@ -182,6 +183,7 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
request_payload=recommendation.request_payload,
|
request_payload=recommendation.request_payload,
|
||||||
response_payload=recommendation.response_payload,
|
response_payload=recommendation.response_payload,
|
||||||
)
|
)
|
||||||
|
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _enrich_ai_payload(payload, farm):
|
def _enrich_ai_payload(payload, farm):
|
||||||
@@ -451,7 +453,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
final_plan = self._extract_final_plan(response_data)
|
final_plan = self._extract_final_plan(response_data)
|
||||||
if final_plan and farm_uuid:
|
if final_plan and farm_uuid:
|
||||||
IrrigationPlan.objects.create(
|
plan = IrrigationPlan.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
source=IrrigationPlan.SOURCE_FREE_TEXT,
|
||||||
title=self._build_free_text_plan_title(final_plan),
|
title=self._build_free_text_plan_title(final_plan),
|
||||||
@@ -466,6 +468,7 @@ class PlanFromTextView(FarmAccessMixin, APIView):
|
|||||||
request_payload=payload,
|
request_payload=payload,
|
||||||
response_payload=response_data,
|
response_payload=response_data,
|
||||||
)
|
)
|
||||||
|
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
|
{"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)
|
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
plan.soft_delete()
|
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)
|
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:
|
if plan is None:
|
||||||
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
|
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"])
|
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(
|
return Response(
|
||||||
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class PlantSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
|
"description",
|
||||||
|
"metadata",
|
||||||
"light",
|
"light",
|
||||||
"watering",
|
"watering",
|
||||||
"soil",
|
"soil",
|
||||||
@@ -22,6 +24,9 @@ class PlantSerializer(serializers.ModelSerializer):
|
|||||||
"harvest_time",
|
"harvest_time",
|
||||||
"spacing",
|
"spacing",
|
||||||
"fertilizer",
|
"fertilizer",
|
||||||
|
"health_profile",
|
||||||
|
"irrigation_profile",
|
||||||
|
"growth_profile",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|||||||
+55
-72
@@ -1,4 +1,5 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||||
@@ -14,33 +15,13 @@ DEFAULT_GROWTH_STAGES = [
|
|||||||
"fruiting",
|
"fruiting",
|
||||||
"maturity",
|
"maturity",
|
||||||
]
|
]
|
||||||
AI_PLANTS_PATH = "/api/plants/"
|
AI_FARM_DATA_PLANT_SYNC_PATH = "/api/farm-data/plants/sync/"
|
||||||
|
|
||||||
|
|
||||||
class PlantSyncError(Exception):
|
class PlantSyncError(Exception):
|
||||||
pass
|
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):
|
def _clean_stage_name(value):
|
||||||
stage = str(value or "").strip()
|
stage = str(value or "").strip()
|
||||||
return stage
|
return stage
|
||||||
@@ -110,60 +91,62 @@ def ensure_plant_defaults(queryset=None):
|
|||||||
return products
|
return products
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
def serialize_products_for_ai(products=None):
|
||||||
def sync_plants_from_ai():
|
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:
|
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:
|
except ExternalAPIRequestError as exc:
|
||||||
raise PlantSyncError(str(exc)) from exc
|
raise PlantSyncError(str(exc)) from exc
|
||||||
|
|
||||||
if adapter_response.status_code >= 400:
|
if adapter_response.status_code >= 400:
|
||||||
raise PlantSyncError(f"AI service returned status {adapter_response.status_code}.")
|
raise PlantSyncError(f"AI service returned status {adapter_response.status_code}.")
|
||||||
|
return payload
|
||||||
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
|
|
||||||
|
|||||||
+6
-6
@@ -56,9 +56,9 @@ class PlantApiTests(TestCase):
|
|||||||
self.assertIn("flowering", response.data["data"][0]["growth_stages"])
|
self.assertIn("flowering", response.data["data"][0]["growth_stages"])
|
||||||
mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET")
|
mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET")
|
||||||
|
|
||||||
@patch("plants.views.sync_plants_from_ai")
|
@patch("plants.views.push_plants_to_ai")
|
||||||
def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_sync_plants_from_ai):
|
def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai):
|
||||||
mock_sync_plants_from_ai.return_value = []
|
mock_push_plants_to_ai.return_value = []
|
||||||
product = Product.objects.create(
|
product = Product.objects.create(
|
||||||
farm_type=self.farm_type,
|
farm_type=self.farm_type,
|
||||||
name="Pepper",
|
name="Pepper",
|
||||||
@@ -79,9 +79,9 @@ class PlantApiTests(TestCase):
|
|||||||
self.assertEqual(product.icon, "leaf")
|
self.assertEqual(product.icon, "leaf")
|
||||||
self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"])
|
self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"])
|
||||||
|
|
||||||
@patch("plants.views.sync_plants_from_ai")
|
@patch("plants.views.push_plants_to_ai")
|
||||||
def test_selected_endpoint_returns_farmer_products(self, mock_sync_plants_from_ai):
|
def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai):
|
||||||
mock_sync_plants_from_ai.return_value = []
|
mock_push_plants_to_ai.return_value = []
|
||||||
tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"])
|
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"])
|
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")
|
farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="farm-a")
|
||||||
|
|||||||
+7
-8
@@ -8,7 +8,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|||||||
from config.swagger import code_response, farm_uuid_query_param
|
from config.swagger import code_response, farm_uuid_query_param
|
||||||
from farm_hub.models import FarmHub, Product
|
from farm_hub.models import FarmHub, Product
|
||||||
from .serializers import PlantNameSerializer, PlantSerializer
|
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):
|
class PlantBaseView(APIView):
|
||||||
@@ -17,7 +17,7 @@ class PlantBaseView(APIView):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _sync_plants_if_possible():
|
def _sync_plants_if_possible():
|
||||||
try:
|
try:
|
||||||
sync_plants_from_ai()
|
push_plants_to_ai()
|
||||||
except PlantSyncError:
|
except PlantSyncError:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -38,13 +38,12 @@ class PlantListView(PlantBaseView):
|
|||||||
responses={200: code_response("PlantListResponse", data=PlantSerializer(many=True))},
|
responses={200: code_response("PlantListResponse", data=PlantSerializer(many=True))},
|
||||||
)
|
)
|
||||||
def get(self, request):
|
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"))
|
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
|
data = PlantSerializer(products, many=True).data
|
||||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|
||||||
@@ -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},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""
|
|
||||||
This app is service-based and does not define local database models.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
@@ -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},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"),
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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`
|
|
||||||
@@ -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"
|
|
||||||
@@ -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']},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"},
|
|
||||||
)
|
|
||||||
@@ -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`، لاگ های مرتبط را از دیتابیس می خواند، آن ها را صفحه بندی می کند، اطلاعات سنسور و کاتالوگ را به خروجی اضافه می کند و در نهایت پاسخ استاندارد برمی گرداند.
|
|
||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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()}"
|
|
||||||
@@ -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/")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user