UPDATE
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import FarmPlantAssignment, ParameterUpdateLog, PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
|
||||
|
||||
@admin.register(SensorData)
|
||||
class SensorDataAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"farm_uuid",
|
||||
"center_location_id",
|
||||
"weather_forecast_id",
|
||||
"sensor_keys",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("updated_at",)
|
||||
search_fields = ("farm_uuid", "center_location_id")
|
||||
|
||||
@admin.display(description="sensor keys")
|
||||
def sensor_keys(self, obj):
|
||||
payload = obj.sensor_payload if isinstance(obj.sensor_payload, dict) else {}
|
||||
return ", ".join(payload.keys())
|
||||
|
||||
|
||||
@admin.register(PlantCatalogSnapshot)
|
||||
class PlantCatalogSnapshotAdmin(admin.ModelAdmin):
|
||||
list_display = ("backend_plant_id", "name", "is_active", "source_updated_at", "updated_at")
|
||||
search_fields = ("backend_plant_id", "name", "slug")
|
||||
list_filter = ("is_active",)
|
||||
|
||||
|
||||
@admin.register(FarmPlantAssignment)
|
||||
class FarmPlantAssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = ("farm", "plant", "position", "stage", "updated_at")
|
||||
search_fields = ("farm__farm_uuid", "plant__name")
|
||||
list_filter = ("stage",)
|
||||
|
||||
|
||||
@admin.register(SensorParameter)
|
||||
class SensorParameterAdmin(admin.ModelAdmin):
|
||||
list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at")
|
||||
search_fields = ("sensor_key", "code", "name_fa")
|
||||
list_filter = ("sensor_key", "data_type")
|
||||
|
||||
|
||||
@admin.register(ParameterUpdateLog)
|
||||
class ParameterUpdateLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("parameter", "action", "updated_at")
|
||||
list_filter = ("action", "updated_at")
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FarmDataConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "farm_data"
|
||||
label = "sensor_data"
|
||||
verbose_name = "farm-data"
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import date
|
||||
|
||||
|
||||
def load_farm_context(sensor_id: str) -> dict | None:
|
||||
from irrigation.models import IrrigationMethod
|
||||
from location_data.satellite_snapshot import build_location_block_satellite_snapshots
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_farm_plant_snapshots
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("center_location").prefetch_related("plant_assignments__plant").get(
|
||||
farm_uuid=sensor_id
|
||||
)
|
||||
except SensorData.DoesNotExist:
|
||||
return None
|
||||
|
||||
location = sensor.center_location
|
||||
satellite_snapshots = build_location_block_satellite_snapshots(location)
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7]
|
||||
)
|
||||
plants = get_farm_plant_snapshots(sensor)
|
||||
irrigation_methods = list(IrrigationMethod.objects.all()[:5])
|
||||
|
||||
return {
|
||||
"sensor": sensor,
|
||||
"location": location,
|
||||
"satellite_snapshots": satellite_snapshots,
|
||||
"forecasts": forecasts,
|
||||
"history": [],
|
||||
"plants": plants,
|
||||
"irrigation_methods": irrigation_methods,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Management command to seed a fixed demo farm-data record.
|
||||
Run: python manage.py seed_farm_data
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData
|
||||
from farm_data.services import assign_farm_plants_from_backend_ids
|
||||
from location_data.models import SoilLocation
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
|
||||
DEMO_FARM_UUID = UUID("11111111-1111-1111-1111-111111111111")
|
||||
DEMO_LATITUDE = "50.000000"
|
||||
DEMO_LONGITUDE = "50.000000"
|
||||
DEMO_SENSOR_PAYLOAD = {
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 42.3,
|
||||
"soil_temperature": 21.4,
|
||||
"soil_ph": 6.9,
|
||||
"electrical_conductivity": 1.1,
|
||||
"nitrogen": 28.0,
|
||||
"phosphorus": 14.0,
|
||||
"potassium": 19.0,
|
||||
}
|
||||
}
|
||||
DEMO_PLANT_NAMES = [
|
||||
"گوجهفرنگی",
|
||||
"خیار",
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed a fixed farm-data row with farm_uuid=11111111-1111-1111-1111-111111111111."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
location, _ = SoilLocation.objects.get_or_create(
|
||||
latitude=DEMO_LATITUDE,
|
||||
longitude=DEMO_LONGITUDE,
|
||||
)
|
||||
weather_forecast = (
|
||||
WeatherForecast.objects.filter(location=location)
|
||||
.order_by("-forecast_date", "-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
farm_data, created = SensorData.objects.update_or_create(
|
||||
farm_uuid=DEMO_FARM_UUID,
|
||||
defaults={
|
||||
"center_location": location,
|
||||
"weather_forecast": weather_forecast,
|
||||
"sensor_payload": DEMO_SENSOR_PAYLOAD,
|
||||
},
|
||||
)
|
||||
plants = list(
|
||||
PlantCatalogSnapshot.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name")
|
||||
)
|
||||
if plants:
|
||||
assign_farm_plants_from_backend_ids(
|
||||
farm_data,
|
||||
[plant.backend_plant_id for plant in plants],
|
||||
)
|
||||
|
||||
status_text = "Created" if created else "Updated"
|
||||
weather_text = weather_forecast.id if weather_forecast else "None"
|
||||
plant_count = len(plants)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"{status_text} farm-data {farm_data.farm_uuid} for center_location_id={location.id} weather_forecast_id={weather_text} plants={plant_count}"
|
||||
)
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("\nDone seeding farm_data demo record."))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Management command to seed the 7 initial sensor parameters.
|
||||
Run: python manage.py seed_sensor_parameters
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from farm_data.models import (
|
||||
DEFAULT_SENSOR_DATA_TYPE,
|
||||
DEFAULT_SENSOR_KEY,
|
||||
ParameterUpdateLog,
|
||||
SensorParameter,
|
||||
)
|
||||
|
||||
|
||||
INITIAL_PARAMETERS = [
|
||||
("soil_moisture", "رطوبت خاک", "%"),
|
||||
("soil_temperature", "دما خاک", "°C"),
|
||||
("soil_ph", "pH خاک", ""),
|
||||
("electrical_conductivity", "هدایت الکتریکی", "dS/m"),
|
||||
("nitrogen", "ازت (N)", "mg/kg"),
|
||||
("phosphorus", "فسفر", "mg/kg"),
|
||||
("potassium", "پتاسیم", "mg/kg"),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed 7 initial sensor parameters (soil_moisture, soil_temperature, etc.)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--sensor-key",
|
||||
default=DEFAULT_SENSOR_KEY,
|
||||
help='کلید سنسور مثل "sensor-7-1" یا "leaf-sensor"',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
sensor_key = options["sensor_key"]
|
||||
created_count = 0
|
||||
for code, name_fa, unit in INITIAL_PARAMETERS:
|
||||
param, created = SensorParameter.objects.get_or_create(
|
||||
sensor_key=sensor_key,
|
||||
code=code,
|
||||
defaults={
|
||||
"name_fa": name_fa,
|
||||
"unit": unit,
|
||||
"data_type": DEFAULT_SENSOR_DATA_TYPE,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
ParameterUpdateLog.objects.create(
|
||||
parameter=param,
|
||||
action="added",
|
||||
payload={
|
||||
"sensor_key": sensor_key,
|
||||
"code": code,
|
||||
"name_fa": name_fa,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f" Created: {sensor_key}.{code} ({name_fa})"
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nDone. Created {created_count} new parameters for {sensor_key}."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-27 09:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('location_data', '0002_soildepthdata_refactor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SensorDataHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid_sensor', models.UUIDField(help_text='شناسه سنسور')),
|
||||
('location_id', models.IntegerField(help_text='location_id از location_data')),
|
||||
('soil_moisture', models.FloatField(blank=True, null=True)),
|
||||
('soil_temperature', models.FloatField(blank=True, null=True)),
|
||||
('soil_ph', models.FloatField(blank=True, null=True)),
|
||||
('electrical_conductivity', models.FloatField(blank=True, null=True)),
|
||||
('nitrogen', models.FloatField(blank=True, null=True)),
|
||||
('phosphorus', models.FloatField(blank=True, null=True)),
|
||||
('potassium', models.FloatField(blank=True, null=True)),
|
||||
('recorded_at', models.DateTimeField(auto_now_add=True, help_text='زمان ثبت در تاریخچه')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تاریخچه داده سنسور',
|
||||
'verbose_name_plural': 'تاریخچه داده\u200cهای سنسور',
|
||||
'ordering': ['-recorded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SensorParameter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(db_index=True, help_text='کد یکتا (مثلاً soil_moisture)', max_length=64, unique=True)),
|
||||
('name_fa', models.CharField(help_text='نام فارسی', max_length=128)),
|
||||
('unit', models.CharField(blank=True, help_text='واحد اندازه\u200cگیری', max_length=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'پارامتر سنسور',
|
||||
'verbose_name_plural': 'پارامترهای سنسور',
|
||||
'ordering': ['code'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SensorData',
|
||||
fields=[
|
||||
('uuid_sensor', models.UUIDField(default=uuid.uuid4, editable=False, help_text='شناسه یکتای سنسور', primary_key=True, serialize=False)),
|
||||
('soil_moisture', models.FloatField(blank=True, help_text='رطوبت خاک', null=True)),
|
||||
('soil_temperature', models.FloatField(blank=True, help_text='دما خاک', null=True)),
|
||||
('soil_ph', models.FloatField(blank=True, help_text='pH خاک', null=True)),
|
||||
('electrical_conductivity', models.FloatField(blank=True, help_text='هدایت الکتریکی', null=True)),
|
||||
('nitrogen', models.FloatField(blank=True, help_text='ازت (N)', null=True)),
|
||||
('phosphorus', models.FloatField(blank=True, help_text='فسفر', null=True)),
|
||||
('potassium', models.FloatField(blank=True, help_text='پتاسیم', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('location', models.ForeignKey(db_column='location_id', help_text='همان location_id در location_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='location_data.soillocation')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'داده سنسور',
|
||||
'verbose_name_plural': 'داده\u200cهای سنسور',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ParameterUpdateLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('added', 'اضافه شده'), ('modified', 'ویرایش شده')], max_length=16)),
|
||||
('updated_at', models.DateTimeField(auto_now_add=True)),
|
||||
('parameter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='update_logs', to='sensor_data.sensorparameter')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'لاگ آپدیت پارامتر',
|
||||
'verbose_name_plural': 'لاگ آپدیت پارامترها',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-27 09:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sensor_data', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-19 15:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('plant', '0001_initial'),
|
||||
('sensor_data', '0002_seed_initial_parameters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sensordata',
|
||||
name='plants',
|
||||
field=models.ManyToManyField(blank=True, help_text='گیاهان مرتبط با این سنسور', related_name='sensor_data', to='plant.plant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.15 on 2026-03-27 08:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('location_data', '0005_merge_20260327_0840'),
|
||||
('sensor_data', '0003_sensordata_plants'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='sensordata',
|
||||
name='location',
|
||||
field=models.ForeignKey(db_column='location_id', help_text='همان location_id از location_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='location_data.soillocation'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0004_alter_sensordata_location"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="SensorDataHistory",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
DEFAULT_SENSOR_KEY = "sensor-7-1"
|
||||
|
||||
|
||||
def migrate_sensor_fields_to_payload(apps, schema_editor):
|
||||
SensorData = apps.get_model("sensor_data", "SensorData")
|
||||
field_names = [
|
||||
"soil_moisture",
|
||||
"soil_temperature",
|
||||
"soil_ph",
|
||||
"electrical_conductivity",
|
||||
"nitrogen",
|
||||
"phosphorus",
|
||||
"potassium",
|
||||
]
|
||||
|
||||
for sensor in SensorData.objects.all().iterator():
|
||||
values = {}
|
||||
for field_name in field_names:
|
||||
value = getattr(sensor, field_name, None)
|
||||
if value is not None:
|
||||
values[field_name] = value
|
||||
|
||||
sensor.sensor_payload = {DEFAULT_SENSOR_KEY: values} if values else {}
|
||||
sensor.save(update_fields=["sensor_payload"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0005_delete_sensordatahistory"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sensordata",
|
||||
name="sensor_payload",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sensorparameter",
|
||||
name="sensor_key",
|
||||
field=models.CharField(
|
||||
db_index=True,
|
||||
default=DEFAULT_SENSOR_KEY,
|
||||
help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"',
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sensorparameter",
|
||||
name="data_type",
|
||||
field=models.CharField(
|
||||
default="float",
|
||||
help_text="نوع داده پارامتر مثل float, int, string, bool",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sensorparameter",
|
||||
name="metadata",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parameterupdatelog",
|
||||
name="payload",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="خلاصه تغییرات پارامتر برای audit",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_sensor_fields_to_payload,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="soil_moisture",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="soil_temperature",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="soil_ph",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="electrical_conductivity",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="nitrogen",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="phosphorus",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="sensordata",
|
||||
name="potassium",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sensorparameter",
|
||||
name="code",
|
||||
field=models.CharField(
|
||||
db_index=True,
|
||||
help_text="کد پارامتر (مثلاً soil_moisture)",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="sensorparameter",
|
||||
options={
|
||||
"ordering": ["sensor_key", "code"],
|
||||
"verbose_name": "پارامتر سنسور",
|
||||
"verbose_name_plural": "پارامترهای سنسور",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="sensorparameter",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("sensor_key", "code"),
|
||||
name="sensor_parameter_unique_sensor_code",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0006_sensor_payload_and_dynamic_parameters"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="sensordata",
|
||||
old_name="uuid_sensor",
|
||||
new_name="farm_uuid",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0005_merge_20260327_0840"),
|
||||
("sensor_data", "0007_rename_uuid_sensor_to_farm_uuid"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="sensordata",
|
||||
old_name="location",
|
||||
new_name="center_location",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sensordata",
|
||||
name="center_location",
|
||||
field=models.ForeignKey(
|
||||
db_column="center_location_id",
|
||||
help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="farm_data",
|
||||
to="location_data.soillocation",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def link_latest_weather_forecast(apps, schema_editor):
|
||||
SensorData = apps.get_model("sensor_data", "SensorData")
|
||||
WeatherForecast = apps.get_model("weather", "WeatherForecast")
|
||||
|
||||
for farm_data in SensorData.objects.all().iterator():
|
||||
forecast = (
|
||||
WeatherForecast.objects.filter(location_id=farm_data.center_location_id)
|
||||
.order_by("-forecast_date", "-id")
|
||||
.first()
|
||||
)
|
||||
if forecast:
|
||||
farm_data.weather_forecast_id = forecast.id
|
||||
farm_data.save(update_fields=["weather_forecast"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0008_rename_location_to_center_location"),
|
||||
("weather", "0003_seed_weather_forecasts"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sensordata",
|
||||
name="weather_forecast",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_column="weather_forecast_id",
|
||||
help_text="رکورد آب وهوای مرتبط با مرکز زمین",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="farm_data_entries",
|
||||
to="weather.weatherforecast",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
link_latest_weather_forecast,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0009_add_weather_forecast_to_sensordata"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="sensordata",
|
||||
name="farm_uuid",
|
||||
field=models.UUIDField(
|
||||
editable=False,
|
||||
help_text="شناسه یکتای farm که از API دریافت میشود",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="sensordata",
|
||||
name="plants",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
db_table="farm_data_sensordata_plants",
|
||||
help_text="گیاهان مرتبط با این farm",
|
||||
related_name="farm_data",
|
||||
to="plant.plant",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name="sensordata",
|
||||
table="farm_data_sensordata",
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name="sensorparameter",
|
||||
table="farm_data_sensorparameter",
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name="parameterupdatelog",
|
||||
table="farm_data_parameterupdatelog",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("irrigation", "0001_initial"),
|
||||
("sensor_data", "0010_rename_tables_to_farm_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sensordata",
|
||||
name="irrigation_method",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_column="irrigation_method_id",
|
||||
help_text="روش آبیاری انتخابشده برای این farm",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="farm_data",
|
||||
to="irrigation.irrigationmethod",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("sensor_data", "0011_sensordata_irrigation_method"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PlantCatalogSnapshot",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("backend_plant_id", models.PositiveIntegerField(db_index=True, help_text="شناسه گیاه در Backend/plants", unique=True)),
|
||||
("name", models.CharField(db_index=True, max_length=255)),
|
||||
("slug", models.SlugField(blank=True, default="", max_length=255)),
|
||||
("icon", models.CharField(blank=True, default="leaf", max_length=255)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("light", models.CharField(blank=True, default="", max_length=255)),
|
||||
("watering", models.CharField(blank=True, default="", max_length=255)),
|
||||
("soil", models.CharField(blank=True, default="", max_length=255)),
|
||||
("temperature", models.CharField(blank=True, default="", max_length=255)),
|
||||
("growth_stage", models.CharField(blank=True, default="", max_length=255)),
|
||||
("growth_stages", models.JSONField(blank=True, default=list)),
|
||||
("planting_season", models.CharField(blank=True, default="", max_length=255)),
|
||||
("harvest_time", models.CharField(blank=True, default="", max_length=255)),
|
||||
("spacing", models.CharField(blank=True, default="", max_length=255)),
|
||||
("fertilizer", models.CharField(blank=True, default="", max_length=255)),
|
||||
("health_profile", models.JSONField(blank=True, default=dict)),
|
||||
("irrigation_profile", models.JSONField(blank=True, default=dict)),
|
||||
("growth_profile", models.JSONField(blank=True, default=dict)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("source_updated_at", models.DateTimeField(blank=True, help_text="updated_at رکورد canonical در Backend", null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "plant catalog snapshot",
|
||||
"verbose_name_plural": "plant catalog snapshots",
|
||||
"db_table": "farm_data_plantcatalogsnapshot",
|
||||
"ordering": ["name", "backend_plant_id"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FarmPlantAssignment",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("position", models.PositiveIntegerField(default=0)),
|
||||
("stage", models.CharField(blank=True, default="", max_length=64)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("assigned_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="plant_assignments", to="sensor_data.sensordata")),
|
||||
("plant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_assignments", to="sensor_data.plantcatalogsnapshot")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "farm plant assignment",
|
||||
"verbose_name_plural": "farm plant assignments",
|
||||
"db_table": "farm_data_farmplantassignment",
|
||||
"ordering": ["position", "id"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="farmplantassignment",
|
||||
constraint=models.UniqueConstraint(fields=("farm", "plant"), name="farm_data_unique_farm_plant_assignment"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
DEFAULT_SENSOR_KEY = "sensor-7-1"
|
||||
DEFAULT_SENSOR_DATA_TYPE = "float"
|
||||
|
||||
|
||||
class SensorPayloadMixin:
|
||||
"""دسترسی سازگار به مقادیر سنسور از payload پویا."""
|
||||
|
||||
sensor_payload: dict
|
||||
|
||||
def _payload(self) -> dict:
|
||||
if isinstance(self.sensor_payload, dict):
|
||||
return self.sensor_payload
|
||||
return {}
|
||||
|
||||
def get_sensor_block(self, sensor_key: str | None = None) -> dict:
|
||||
payload = self._payload()
|
||||
if sensor_key:
|
||||
block = payload.get(sensor_key, {})
|
||||
return block if isinstance(block, dict) else {}
|
||||
|
||||
for _sensor_key, block in self.iter_sensor_blocks():
|
||||
return block
|
||||
return {}
|
||||
|
||||
def iter_sensor_blocks(self):
|
||||
for sensor_key, block in self._payload().items():
|
||||
if isinstance(block, dict):
|
||||
yield sensor_key, block
|
||||
|
||||
def get_metric(self, metric_name: str, sensor_key: str | None = None):
|
||||
block = self.get_sensor_block(sensor_key)
|
||||
if metric_name in block:
|
||||
return block.get(metric_name)
|
||||
|
||||
for _candidate_key, candidate in self.iter_sensor_blocks():
|
||||
if metric_name in candidate:
|
||||
return candidate.get(metric_name)
|
||||
return None
|
||||
|
||||
def get_sensor_keys(self) -> list[str]:
|
||||
return [sensor_key for sensor_key, _block in self.iter_sensor_blocks()]
|
||||
|
||||
def get_all_metrics(self) -> dict[str, dict]:
|
||||
return {
|
||||
sensor_key: dict(block)
|
||||
for sensor_key, block in self.iter_sensor_blocks()
|
||||
}
|
||||
|
||||
@property
|
||||
def soil_moisture(self):
|
||||
return self.get_metric("soil_moisture")
|
||||
|
||||
@property
|
||||
def soil_temperature(self):
|
||||
return self.get_metric("soil_temperature")
|
||||
|
||||
@property
|
||||
def soil_ph(self):
|
||||
return self.get_metric("soil_ph")
|
||||
|
||||
@property
|
||||
def electrical_conductivity(self):
|
||||
return self.get_metric("electrical_conductivity")
|
||||
|
||||
@property
|
||||
def nitrogen(self):
|
||||
return self.get_metric("nitrogen")
|
||||
|
||||
@property
|
||||
def phosphorus(self):
|
||||
return self.get_metric("phosphorus")
|
||||
|
||||
@property
|
||||
def potassium(self):
|
||||
return self.get_metric("potassium")
|
||||
|
||||
|
||||
class SensorData(SensorPayloadMixin, models.Model):
|
||||
"""
|
||||
دادههای مزرعه/سنسور برای مرکز زمین.
|
||||
مقادیر سنسورها بهصورت JSON ذخیره میشوند تا بتوان چند نوع سنسور
|
||||
و پارامترهای دلخواه را در یک رکورد نگه داشت.
|
||||
نمونه:
|
||||
{
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 22.4,
|
||||
"soil_temperature": 18.1
|
||||
},
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 11
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
farm_uuid = models.UUIDField(
|
||||
primary_key=True,
|
||||
editable=False,
|
||||
help_text="شناسه یکتای farm که از API دریافت میشود",
|
||||
)
|
||||
center_location = models.ForeignKey(
|
||||
"location_data.SoilLocation",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="farm_data",
|
||||
db_column="center_location_id",
|
||||
help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation",
|
||||
)
|
||||
weather_forecast = models.ForeignKey(
|
||||
"weather.WeatherForecast",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="farm_data_entries",
|
||||
db_column="weather_forecast_id",
|
||||
help_text="رکورد آب وهوای مرتبط با مرکز زمین",
|
||||
)
|
||||
sensor_payload = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}',
|
||||
)
|
||||
plants = models.ManyToManyField(
|
||||
"plant.Plant",
|
||||
blank=True,
|
||||
db_table="farm_data_sensordata_plants",
|
||||
related_name="farm_data",
|
||||
help_text="مسیر legacy برای گیاهان farm. برای خواندن canonical از plant_assignments/plant_snapshots استفاده شود.",
|
||||
)
|
||||
irrigation_method = models.ForeignKey(
|
||||
"irrigation.IrrigationMethod",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="farm_data",
|
||||
db_column="irrigation_method_id",
|
||||
help_text="روش آبیاری انتخابشده برای این farm",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_sensordata"
|
||||
ordering = ["-updated_at"]
|
||||
verbose_name = "farm-data"
|
||||
verbose_name_plural = "farm-data"
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"SensorData({self.farm_uuid}, center_location={self.center_location_id}, "
|
||||
f"weather_forecast={self.weather_forecast_id})"
|
||||
)
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
return self.center_location
|
||||
|
||||
@location.setter
|
||||
def location(self, value):
|
||||
self.center_location = value
|
||||
|
||||
@property
|
||||
def location_id(self):
|
||||
return self.center_location_id
|
||||
|
||||
@property
|
||||
def plant_snapshots(self):
|
||||
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
|
||||
|
||||
|
||||
class PlantCatalogSnapshot(models.Model):
|
||||
"""
|
||||
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
|
||||
"""
|
||||
|
||||
backend_plant_id = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="شناسه گیاه در Backend/plants",
|
||||
)
|
||||
name = models.CharField(max_length=255, db_index=True)
|
||||
slug = models.SlugField(max_length=255, blank=True, default="")
|
||||
icon = models.CharField(max_length=255, blank=True, default="leaf")
|
||||
description = models.TextField(blank=True, default="")
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
light = models.CharField(max_length=255, blank=True, default="")
|
||||
watering = models.CharField(max_length=255, blank=True, default="")
|
||||
soil = models.CharField(max_length=255, blank=True, default="")
|
||||
temperature = models.CharField(max_length=255, blank=True, default="")
|
||||
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||
growth_stages = models.JSONField(blank=True, default=list)
|
||||
planting_season = models.CharField(max_length=255, blank=True, default="")
|
||||
harvest_time = models.CharField(max_length=255, blank=True, default="")
|
||||
spacing = models.CharField(max_length=255, blank=True, default="")
|
||||
fertilizer = models.CharField(max_length=255, blank=True, default="")
|
||||
health_profile = models.JSONField(default=dict, blank=True)
|
||||
irrigation_profile = models.JSONField(default=dict, blank=True)
|
||||
growth_profile = models.JSONField(default=dict, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
source_updated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="updated_at رکورد canonical در Backend",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_plantcatalogsnapshot"
|
||||
ordering = ["name", "backend_plant_id"]
|
||||
verbose_name = "plant catalog snapshot"
|
||||
verbose_name_plural = "plant catalog snapshots"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.backend_plant_id})"
|
||||
|
||||
|
||||
class FarmPlantAssignment(models.Model):
|
||||
"""
|
||||
رابطه مزرعه با snapshot گیاه برای read-model هوش مصنوعی.
|
||||
"""
|
||||
|
||||
farm = models.ForeignKey(
|
||||
SensorData,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="plant_assignments",
|
||||
db_column="farm_uuid",
|
||||
)
|
||||
plant = models.ForeignKey(
|
||||
PlantCatalogSnapshot,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="farm_assignments",
|
||||
)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
stage = models.CharField(max_length=64, blank=True, default="")
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
assigned_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_farmplantassignment"
|
||||
ordering = ["position", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["farm", "plant"],
|
||||
name="farm_data_unique_farm_plant_assignment",
|
||||
)
|
||||
]
|
||||
verbose_name = "farm plant assignment"
|
||||
verbose_name_plural = "farm plant assignments"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.farm_id} -> {self.plant_id}"
|
||||
|
||||
|
||||
class SensorParameter(models.Model):
|
||||
"""
|
||||
تعریف پارامترهای سنسور برای هر نوع سنسور.
|
||||
با این ساختار میتوان برای sensor-7-1 یا هر سنسور جدید،
|
||||
پارامترهای اختصاصی تعریف کرد.
|
||||
"""
|
||||
|
||||
sensor_key = models.CharField(
|
||||
max_length=64,
|
||||
db_index=True,
|
||||
default=DEFAULT_SENSOR_KEY,
|
||||
help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"',
|
||||
)
|
||||
code = models.CharField(
|
||||
max_length=64,
|
||||
db_index=True,
|
||||
help_text="کد پارامتر (مثلاً soil_moisture)",
|
||||
)
|
||||
name_fa = models.CharField(max_length=128, help_text="نام فارسی")
|
||||
unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازهگیری")
|
||||
data_type = models.CharField(
|
||||
max_length=32,
|
||||
default=DEFAULT_SENSOR_DATA_TYPE,
|
||||
help_text="نوع داده پارامتر مثل float, int, string, bool",
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_sensorparameter"
|
||||
ordering = ["sensor_key", "code"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["sensor_key", "code"],
|
||||
name="sensor_parameter_unique_sensor_code",
|
||||
)
|
||||
]
|
||||
verbose_name = "پارامتر سنسور"
|
||||
verbose_name_plural = "پارامترهای سنسور"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sensor_key}.{self.code} ({self.name_fa})"
|
||||
|
||||
|
||||
class ParameterUpdateLog(models.Model):
|
||||
"""
|
||||
لاگ آپدیت لیست پارامترها.
|
||||
"""
|
||||
|
||||
ACTION_ADDED = "added"
|
||||
ACTION_MODIFIED = "modified"
|
||||
ACTION_CHOICES = [
|
||||
(ACTION_ADDED, "اضافه شده"),
|
||||
(ACTION_MODIFIED, "ویرایش شده"),
|
||||
]
|
||||
|
||||
parameter = models.ForeignKey(
|
||||
SensorParameter,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="update_logs",
|
||||
)
|
||||
action = models.CharField(max_length=16, choices=ACTION_CHOICES)
|
||||
payload = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="خلاصه تغییرات پارامتر برای audit",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_data_parameterupdatelog"
|
||||
ordering = ["-updated_at"]
|
||||
verbose_name = "لاگ آپدیت پارامتر"
|
||||
verbose_name_plural = "لاگ آپدیت پارامترها"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.parameter.code} - {self.action} - {self.updated_at}"
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Farm Data",
|
||||
"description": "API دادههای farm: ایجاد/آپدیت رکورد farm و مدیریت پارامترهای سنسور",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{"key": "baseUrl", "value": "http://localhost:8020"},
|
||||
{"key": "farm_uuid", "value": "00000000-0000-0000-0000-000000000000"}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "Upsert Farm Data (POST)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Accept", "value": "application/json"}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"farm_uuid\": \"{{farm_uuid}}\",\n \"farm_boundary\": {\n \"corners\": [\n {\"lat\": 35.7000, \"lon\": 51.3900},\n {\"lat\": 35.7000, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.3900}\n ]\n },\n \"sensor_payload\": {\n \"sensor-7-1\": {\n \"soil_moisture\": 25.5,\n \"soil_temperature\": 22.3,\n \"soil_ph\": 7.2,\n \"electrical_conductivity\": 1.8,\n \"nitrogen\": 120.0,\n \"phosphorus\": 45.0,\n \"potassium\": 180.0\n }\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/farm-data/",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "farm-data", ""]
|
||||
}
|
||||
},
|
||||
"description": "ایجاد یا آپدیت داده farm. مختصات گوشههای زمین را میگیرد، مرکز را خودش محاسبه میکند، location را میسازد و weather را از همان location پیدا میکند."
|
||||
},
|
||||
{
|
||||
"name": "Add Parameter",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Accept", "value": "application/json"}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"sensor_key\": \"sensor-7-1\",\n \"code\": \"soil_moisture\",\n \"name_fa\": \"رطوبت خاک\",\n \"unit\": \"%\",\n \"data_type\": \"float\",\n \"metadata\": {\n \"min\": 0,\n \"max\": 100\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/farm-data/parameters/",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "farm-data", "parameters", ""]
|
||||
}
|
||||
},
|
||||
"description": "اضافه کردن یا ویرایش پارامتر جدید. در ParameterUpdateLog ثبت میشود."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from irrigation.models import IrrigationMethod
|
||||
from irrigation.serializers import IrrigationMethodSerializer
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from .models import (
|
||||
DEFAULT_SENSOR_DATA_TYPE,
|
||||
DEFAULT_SENSOR_KEY,
|
||||
FarmPlantAssignment,
|
||||
PlantCatalogSnapshot,
|
||||
SensorData,
|
||||
)
|
||||
|
||||
|
||||
class SensorDataUpdateSerializer(serializers.Serializer):
|
||||
"""ورودی آپدیت داده سنسور در ساختار JSON."""
|
||||
|
||||
farm_uuid = serializers.UUIDField(required=True)
|
||||
farm_boundary = serializers.JSONField(required=True)
|
||||
block_count = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
sensor_key = serializers.CharField(required=False, default=DEFAULT_SENSOR_KEY)
|
||||
sensor_payload = serializers.JSONField(required=False)
|
||||
plant_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
help_text="لیست شناسه گیاهان canonical در Backend/plants",
|
||||
)
|
||||
irrigation_method_id = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="شناسه روش آبیاری مرتبط",
|
||||
)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, dict):
|
||||
raise serializers.ValidationError("بدنه درخواست باید JSON object باشد.")
|
||||
|
||||
payload = dict(data)
|
||||
known_fields = {
|
||||
"farm_uuid",
|
||||
"farm_boundary",
|
||||
"block_count",
|
||||
"sensor_key",
|
||||
"sensor_payload",
|
||||
"plant_ids",
|
||||
"irrigation_method_id",
|
||||
}
|
||||
flat_metrics = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in known_fields
|
||||
}
|
||||
|
||||
if flat_metrics:
|
||||
sensor_key = payload.get("sensor_key", DEFAULT_SENSOR_KEY)
|
||||
nested_payload = payload.get("sensor_payload") or {}
|
||||
if nested_payload and not isinstance(nested_payload, dict):
|
||||
raise serializers.ValidationError(
|
||||
{"sensor_payload": "sensor_payload باید object باشد."}
|
||||
)
|
||||
merged_payload = dict(nested_payload)
|
||||
current_sensor_payload = merged_payload.get(sensor_key, {})
|
||||
if current_sensor_payload and not isinstance(current_sensor_payload, dict):
|
||||
raise serializers.ValidationError(
|
||||
{"sensor_payload": f"مقدار {sensor_key} باید object باشد."}
|
||||
)
|
||||
merged_sensor_payload = dict(current_sensor_payload)
|
||||
merged_sensor_payload.update(flat_metrics)
|
||||
merged_payload[sensor_key] = merged_sensor_payload
|
||||
payload["sensor_payload"] = merged_payload
|
||||
|
||||
for key in flat_metrics:
|
||||
payload.pop(key, None)
|
||||
|
||||
return super().to_internal_value(payload)
|
||||
|
||||
def validate_sensor_payload(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("sensor_payload باید object باشد.")
|
||||
for sensor_key, sensor_values in value.items():
|
||||
if not isinstance(sensor_values, dict):
|
||||
raise serializers.ValidationError(
|
||||
f"مقدار سنسور {sensor_key} باید object باشد."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_irrigation_method_id(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if not IrrigationMethod.objects.filter(pk=value).exists():
|
||||
raise serializers.ValidationError("روش آبیاری معتبر نیست.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
if (
|
||||
"sensor_payload" not in attrs
|
||||
and "plant_ids" not in attrs
|
||||
and "irrigation_method_id" not in attrs
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"حداقل یکی از sensor_payload یا plant_ids یا irrigation_method_id باید ارسال شود."
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class SensorDataResponseSerializer(serializers.ModelSerializer):
|
||||
"""سریالایزر خروجی برای SensorData."""
|
||||
|
||||
plant_ids = serializers.SerializerMethodField()
|
||||
irrigation_method_id = serializers.IntegerField(
|
||||
source="irrigation_method.id",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
def get_plant_ids(self, obj):
|
||||
return [plant.backend_plant_id for plant in obj.plant_snapshots]
|
||||
|
||||
class Meta:
|
||||
model = SensorData
|
||||
fields = [
|
||||
"farm_uuid",
|
||||
"center_location_id",
|
||||
"weather_forecast_id",
|
||||
"sensor_payload",
|
||||
"plant_ids",
|
||||
"irrigation_method_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class SensorParameterSerializer(serializers.Serializer):
|
||||
"""سریالایزر ورودی برای تعریف پارامترهای سنسورهای مختلف."""
|
||||
|
||||
sensor_key = serializers.CharField(max_length=64, required=False, default=DEFAULT_SENSOR_KEY)
|
||||
code = serializers.CharField(max_length=64)
|
||||
name_fa = serializers.CharField(max_length=128)
|
||||
unit = serializers.CharField(max_length=32, required=False, allow_blank=True)
|
||||
data_type = serializers.CharField(
|
||||
max_length=32,
|
||||
required=False,
|
||||
default=DEFAULT_SENSOR_DATA_TYPE,
|
||||
)
|
||||
metadata = serializers.JSONField(required=False, default=dict)
|
||||
|
||||
|
||||
class FarmCenterLocationSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
lat = serializers.DecimalField(max_digits=9, decimal_places=6)
|
||||
lon = serializers.DecimalField(max_digits=9, decimal_places=6)
|
||||
farm_boundary = serializers.JSONField()
|
||||
input_block_count = serializers.IntegerField()
|
||||
block_layout = serializers.JSONField()
|
||||
|
||||
|
||||
class WeatherForecastDetailSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WeatherForecast
|
||||
fields = [
|
||||
"id",
|
||||
"forecast_date",
|
||||
"temperature_min",
|
||||
"temperature_max",
|
||||
"temperature_mean",
|
||||
"precipitation",
|
||||
"precipitation_probability",
|
||||
"humidity_mean",
|
||||
"wind_speed_max",
|
||||
"et0",
|
||||
"weather_code",
|
||||
]
|
||||
|
||||
|
||||
class FarmSoilPayloadSerializer(serializers.Serializer):
|
||||
resolved_metrics = serializers.JSONField()
|
||||
metric_sources = serializers.JSONField()
|
||||
satellite_snapshots = serializers.JSONField()
|
||||
|
||||
|
||||
class PlantCatalogSnapshotSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(source="backend_plant_id", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PlantCatalogSnapshot
|
||||
fields = [
|
||||
"id",
|
||||
"backend_plant_id",
|
||||
"name",
|
||||
"slug",
|
||||
"icon",
|
||||
"description",
|
||||
"metadata",
|
||||
"light",
|
||||
"watering",
|
||||
"soil",
|
||||
"temperature",
|
||||
"growth_stage",
|
||||
"growth_stages",
|
||||
"planting_season",
|
||||
"harvest_time",
|
||||
"spacing",
|
||||
"fertilizer",
|
||||
"health_profile",
|
||||
"irrigation_profile",
|
||||
"growth_profile",
|
||||
"is_active",
|
||||
"source_updated_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class FarmPlantAssignmentSerializer(serializers.ModelSerializer):
|
||||
plant_id = serializers.IntegerField(source="plant.backend_plant_id", read_only=True)
|
||||
plant = PlantCatalogSnapshotSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmPlantAssignment
|
||||
fields = [
|
||||
"plant_id",
|
||||
"position",
|
||||
"stage",
|
||||
"metadata",
|
||||
"assigned_at",
|
||||
"updated_at",
|
||||
"plant",
|
||||
]
|
||||
|
||||
|
||||
class FarmDetailSerializer(serializers.Serializer):
|
||||
center_location = FarmCenterLocationSerializer()
|
||||
weather = WeatherForecastDetailSerializer(allow_null=True)
|
||||
sensor_payload = serializers.JSONField()
|
||||
sensor_schema = serializers.JSONField()
|
||||
soil = FarmSoilPayloadSerializer()
|
||||
plant_ids = serializers.ListField(child=serializers.IntegerField())
|
||||
plants = PlantCatalogSnapshotSerializer(many=True)
|
||||
plant_assignments = FarmPlantAssignmentSerializer(many=True)
|
||||
irrigation_method_id = serializers.IntegerField(allow_null=True)
|
||||
irrigation_method = IrrigationMethodSerializer(allow_null=True)
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
@@ -0,0 +1,761 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from numbers import Number
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.apps import apps
|
||||
from django.db import transaction
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
import requests
|
||||
|
||||
from location_data.block_subdivision import create_or_get_block_subdivision
|
||||
from location_data.models import BlockSubdivision, SoilLocation
|
||||
from location_data.satellite_snapshot import (
|
||||
build_location_block_satellite_snapshots,
|
||||
build_location_satellite_snapshot,
|
||||
)
|
||||
from irrigation.serializers import IrrigationMethodSerializer
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from .models import (
|
||||
FarmPlantAssignment,
|
||||
ParameterUpdateLog,
|
||||
PlantCatalogSnapshot,
|
||||
SensorData,
|
||||
SensorParameter,
|
||||
)
|
||||
from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer
|
||||
|
||||
|
||||
DECIMAL_PRECISION = Decimal("0.000001")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExternalDataSyncError(Exception):
|
||||
"""خطا در همگامسازی داده از سرویسهای بیرونی."""
|
||||
|
||||
|
||||
class BackendSyncError(Exception):
|
||||
"""خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend."""
|
||||
|
||||
|
||||
class LegacyFarmPlantRelationWarning(DeprecationWarning):
|
||||
"""هشدار برای relation قدیمی SensorData.plants."""
|
||||
|
||||
|
||||
PARAMETER_LABEL_OVERRIDES = {
|
||||
"soil_moisture": "رطوبت خاک",
|
||||
"soil_temperature": "دمای خاک",
|
||||
"soil_ph": "pH خاک",
|
||||
"electrical_conductivity": "هدایت الکتریکی",
|
||||
"nitrogen": "نیتروژن",
|
||||
"phosphorus": "فسفر",
|
||||
"potassium": "پتاسیم",
|
||||
}
|
||||
PARAMETER_UNIT_OVERRIDES = {
|
||||
"soil_moisture": "%",
|
||||
"soil_temperature": "°C",
|
||||
"soil_ph": "",
|
||||
"electrical_conductivity": "dS/m",
|
||||
"nitrogen": "mg/kg",
|
||||
"phosphorus": "mg/kg",
|
||||
"potassium": "mg/kg",
|
||||
}
|
||||
|
||||
|
||||
def get_backend_plant_base_url() -> str:
|
||||
return getattr(settings, "BACKEND_PLANT_SYNC_BASE_URL", "").rstrip("/")
|
||||
|
||||
|
||||
def get_backend_plant_timeout() -> int:
|
||||
return int(getattr(settings, "BACKEND_PLANT_SYNC_TIMEOUT", 20))
|
||||
|
||||
|
||||
def get_backend_plant_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
api_key = getattr(settings, "BACKEND_PLANT_SYNC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
headers["Authorization"] = f"Api-Key {api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _extract_envelope_list(payload):
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
data = payload.get("data")
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
result = payload.get("result")
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if isinstance(data, dict) and isinstance(data.get("result"), list):
|
||||
return data["result"]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_growth_stages(item: dict) -> list[str]:
|
||||
stages = item.get("growth_stages")
|
||||
if isinstance(stages, list):
|
||||
return [str(stage).strip() for stage in stages if str(stage).strip()]
|
||||
|
||||
growth_stage = str(item.get("growth_stage") or "").strip()
|
||||
if not growth_stage:
|
||||
return []
|
||||
return [part.strip() for part in growth_stage.replace("،", ",").split(",") if part.strip()]
|
||||
|
||||
|
||||
def _snapshot_defaults_from_payload(item: dict) -> dict:
|
||||
source_updated_at = parse_datetime(str(item.get("updated_at") or "").strip()) if item.get("updated_at") else None
|
||||
return {
|
||||
"name": str(item.get("name") or "").strip(),
|
||||
"slug": str(item.get("slug") or "").strip(),
|
||||
"icon": str(item.get("icon") or "leaf").strip() or "leaf",
|
||||
"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": _normalize_growth_stages(item),
|
||||
"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(),
|
||||
"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": item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {},
|
||||
"is_active": bool(item.get("is_active", True)),
|
||||
"source_updated_at": source_updated_at,
|
||||
}
|
||||
|
||||
|
||||
def sync_plant_catalog_from_backend(plant_payloads: list[dict] | None = None) -> list[PlantCatalogSnapshot]:
|
||||
if plant_payloads is None:
|
||||
base_url = get_backend_plant_base_url()
|
||||
if not base_url:
|
||||
raise BackendSyncError("BACKEND_PLANT_SYNC_BASE_URL is not configured.")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/api/plants/",
|
||||
headers=get_backend_plant_headers(),
|
||||
timeout=get_backend_plant_timeout(),
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise BackendSyncError(f"Backend plant catalog request failed: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise BackendSyncError(f"Backend plant catalog returned status {response.status_code}.")
|
||||
plant_payloads = _extract_envelope_list(response.json())
|
||||
|
||||
snapshots: list[PlantCatalogSnapshot] = []
|
||||
with transaction.atomic():
|
||||
for item in plant_payloads or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
plant_id = item.get("id") or item.get("backend_plant_id")
|
||||
if plant_id in (None, ""):
|
||||
continue
|
||||
snapshot, _ = PlantCatalogSnapshot.objects.update_or_create(
|
||||
backend_plant_id=int(plant_id),
|
||||
defaults=_snapshot_defaults_from_payload(item),
|
||||
)
|
||||
snapshots.append(snapshot)
|
||||
return snapshots
|
||||
|
||||
|
||||
def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: list[int] | None) -> list[PlantCatalogSnapshot]:
|
||||
if backend_plant_ids is None:
|
||||
return list(get_farm_plant_snapshots(farm))
|
||||
|
||||
normalized_ids = [int(plant_id) for plant_id in backend_plant_ids]
|
||||
snapshots = list(PlantCatalogSnapshot.objects.filter(backend_plant_id__in=normalized_ids))
|
||||
snapshot_by_backend_id = {snapshot.backend_plant_id: snapshot for snapshot in snapshots}
|
||||
missing_ids = [plant_id for plant_id in normalized_ids if plant_id not in snapshot_by_backend_id]
|
||||
if missing_ids:
|
||||
raise BackendSyncError(
|
||||
"Plant catalog snapshot missing for backend ids: "
|
||||
+ ", ".join(str(plant_id) for plant_id in missing_ids)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
FarmPlantAssignment.objects.filter(farm=farm).exclude(
|
||||
plant__backend_plant_id__in=normalized_ids
|
||||
).delete()
|
||||
for position, backend_plant_id in enumerate(normalized_ids):
|
||||
FarmPlantAssignment.objects.update_or_create(
|
||||
farm=farm,
|
||||
plant=snapshot_by_backend_id[backend_plant_id],
|
||||
defaults={"position": position},
|
||||
)
|
||||
snapshots_in_order = [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids]
|
||||
reconcile_legacy_farm_plants_relation(farm, snapshots_in_order)
|
||||
return snapshots_in_order
|
||||
|
||||
|
||||
def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]:
|
||||
return list(
|
||||
farm.plant_assignments.select_related("plant").order_by("position", "id")
|
||||
)
|
||||
|
||||
|
||||
def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]:
|
||||
return [assignment.plant for assignment in get_farm_plant_assignments(farm)]
|
||||
|
||||
|
||||
def reconcile_legacy_farm_plants_relation(
|
||||
farm: SensorData,
|
||||
snapshots: list[PlantCatalogSnapshot] | None = None,
|
||||
) -> None:
|
||||
snapshots = list(snapshots if snapshots is not None else get_farm_plant_snapshots(farm))
|
||||
Plant = apps.get_model("plant", "Plant")
|
||||
if Plant is None:
|
||||
return
|
||||
names = [snapshot.name for snapshot in snapshots if snapshot and snapshot.name]
|
||||
if not names:
|
||||
farm.plants.clear()
|
||||
return
|
||||
legacy_plants = list(Plant.objects.filter(name__in=names).order_by("name", "id"))
|
||||
farm.plants.set(legacy_plants)
|
||||
|
||||
|
||||
def get_canonical_farm_record(farm_uuid: str) -> SensorData | None:
|
||||
return (
|
||||
SensorData.objects.select_related(
|
||||
"center_location",
|
||||
"weather_forecast",
|
||||
"irrigation_method",
|
||||
)
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_legacy_farm_plants(farm: SensorData):
|
||||
warnings.warn(
|
||||
"SensorData.plants is deprecated; use farm_data.services canonical plant snapshot helpers instead.",
|
||||
LegacyFarmPlantRelationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return farm.plants.all()
|
||||
|
||||
|
||||
def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None:
|
||||
assignments = get_farm_plant_assignments(farm)
|
||||
return assignments[0].plant if assignments else None
|
||||
|
||||
|
||||
def get_farm_plant_snapshot_by_name(
|
||||
farm: SensorData,
|
||||
plant_name: str | None,
|
||||
) -> PlantCatalogSnapshot | None:
|
||||
normalized_name = str(plant_name or "").strip().lower()
|
||||
if not normalized_name:
|
||||
return get_primary_plant_snapshot(farm)
|
||||
for assignment in get_farm_plant_assignments(farm):
|
||||
if assignment.plant.name.strip().lower() == normalized_name:
|
||||
return assignment.plant
|
||||
return get_primary_plant_snapshot(farm)
|
||||
|
||||
|
||||
def clone_snapshot_as_runtime_plant(
|
||||
snapshot: PlantCatalogSnapshot | None,
|
||||
*,
|
||||
growth_stage: str | None = None,
|
||||
):
|
||||
if snapshot is None:
|
||||
return None
|
||||
|
||||
class RuntimePlant:
|
||||
pass
|
||||
|
||||
runtime = RuntimePlant()
|
||||
for field_name in (
|
||||
"backend_plant_id",
|
||||
"name",
|
||||
"slug",
|
||||
"icon",
|
||||
"description",
|
||||
"metadata",
|
||||
"light",
|
||||
"watering",
|
||||
"soil",
|
||||
"temperature",
|
||||
"growth_stage",
|
||||
"growth_stages",
|
||||
"planting_season",
|
||||
"harvest_time",
|
||||
"spacing",
|
||||
"fertilizer",
|
||||
"health_profile",
|
||||
"irrigation_profile",
|
||||
"growth_profile",
|
||||
"is_active",
|
||||
):
|
||||
setattr(runtime, field_name, getattr(snapshot, field_name))
|
||||
if growth_stage:
|
||||
runtime.growth_stage = growth_stage
|
||||
runtime.id = snapshot.backend_plant_id
|
||||
return runtime
|
||||
|
||||
|
||||
def get_runtime_plant_for_farm(
|
||||
farm: SensorData,
|
||||
*,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
):
|
||||
snapshot = get_farm_plant_snapshot_by_name(farm, plant_name)
|
||||
return clone_snapshot_as_runtime_plant(snapshot, growth_stage=growth_stage)
|
||||
|
||||
|
||||
def list_runtime_plants_for_farm(farm: SensorData) -> list[object]:
|
||||
return [clone_snapshot_as_runtime_plant(snapshot) for snapshot in get_farm_plant_snapshots(farm)]
|
||||
|
||||
|
||||
def build_plant_text_from_snapshot(
|
||||
plant: PlantCatalogSnapshot | None,
|
||||
growth_stage: str,
|
||||
) -> str | None:
|
||||
if plant is None:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
f"نام گیاه: {plant.name}",
|
||||
f"مرحله رشد: {growth_stage}",
|
||||
]
|
||||
if plant.light:
|
||||
lines.append(f"نور مورد نیاز: {plant.light}")
|
||||
if plant.watering:
|
||||
lines.append(f"آبیاری: {plant.watering}")
|
||||
if plant.soil:
|
||||
lines.append(f"خاک مناسب: {plant.soil}")
|
||||
if plant.temperature:
|
||||
lines.append(f"دمای مناسب: {plant.temperature}")
|
||||
if plant.planting_season:
|
||||
lines.append(f"فصل کاشت: {plant.planting_season}")
|
||||
if plant.harvest_time:
|
||||
lines.append(f"زمان برداشت: {plant.harvest_time}")
|
||||
if plant.spacing:
|
||||
lines.append(f"فاصله کاشت: {plant.spacing}")
|
||||
if plant.fertilizer:
|
||||
lines.append(f"کود مناسب: {plant.fertilizer}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_farm_plant_context(farm_uuid: str) -> dict | None:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
return None
|
||||
assignments = get_farm_plant_assignments(farm)
|
||||
snapshots = [assignment.plant for assignment in assignments]
|
||||
return {
|
||||
"farm": farm,
|
||||
"plant_ids": [plant.backend_plant_id for plant in snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(snapshots, many=True).data,
|
||||
"plant_snapshots": snapshots,
|
||||
"plant_assignments": assignments,
|
||||
"primary_plant": snapshots[0] if snapshots else None,
|
||||
}
|
||||
|
||||
|
||||
def infer_sensor_parameter_data_type(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "list"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return "string"
|
||||
|
||||
|
||||
def build_parameter_defaults(sensor_key: str, code: str, value: object) -> dict[str, object]:
|
||||
return {
|
||||
"name_fa": PARAMETER_LABEL_OVERRIDES.get(code) or code.replace("_", " ").strip(),
|
||||
"unit": PARAMETER_UNIT_OVERRIDES.get(code, ""),
|
||||
"data_type": infer_sensor_parameter_data_type(value),
|
||||
"metadata": {
|
||||
"source": "auto_discovered",
|
||||
"sensor_key": sensor_key,
|
||||
"code": code,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[SensorParameter]:
|
||||
if not isinstance(sensor_payload, dict):
|
||||
return []
|
||||
|
||||
synced_parameters: list[SensorParameter] = []
|
||||
with transaction.atomic():
|
||||
for sensor_key, sensor_values in sensor_payload.items():
|
||||
if not isinstance(sensor_values, dict):
|
||||
continue
|
||||
for code, value in sensor_values.items():
|
||||
defaults = build_parameter_defaults(sensor_key, code, value)
|
||||
parameter, created = SensorParameter.objects.get_or_create(
|
||||
sensor_key=sensor_key,
|
||||
code=code,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
ParameterUpdateLog.objects.create(
|
||||
parameter=parameter,
|
||||
action=ParameterUpdateLog.ACTION_ADDED,
|
||||
payload={
|
||||
"sensor_key": parameter.sensor_key,
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
"source": "farm_data_auto_sync",
|
||||
},
|
||||
)
|
||||
synced_parameters.append(parameter)
|
||||
return synced_parameters
|
||||
|
||||
|
||||
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
|
||||
if sensor_payload and isinstance(sensor_payload, dict):
|
||||
parameter_queryset = parameter_queryset.filter(sensor_key__in=list(sensor_payload.keys()))
|
||||
|
||||
catalog: dict[str, list[dict[str, object]]] = {}
|
||||
for parameter in parameter_queryset:
|
||||
catalog.setdefault(parameter.sensor_key, []).append(
|
||||
{
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def get_farm_details(farm_uuid: str):
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
return None
|
||||
|
||||
sync_sensor_parameters_from_payload(farm.sensor_payload)
|
||||
|
||||
center_location = farm.center_location
|
||||
weather = farm.weather_forecast
|
||||
if weather is None:
|
||||
weather = (
|
||||
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||
)
|
||||
|
||||
latest_satellite = build_location_satellite_snapshot(center_location)
|
||||
soil_metrics = dict(latest_satellite.get("resolved_metrics") or {})
|
||||
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
|
||||
|
||||
resolved_metrics = dict(soil_metrics)
|
||||
metric_sources = {key: "remote_sensing" for key in soil_metrics}
|
||||
for key, value in sensor_metrics.items():
|
||||
resolved_metrics[key] = value
|
||||
metric_sources[key] = sensor_metric_sources[key]
|
||||
|
||||
plant_assignments = get_farm_plant_assignments(farm)
|
||||
plant_snapshots = [assignment.plant for assignment in plant_assignments]
|
||||
|
||||
return {
|
||||
"center_location": {
|
||||
"id": center_location.id,
|
||||
"lat": center_location.latitude,
|
||||
"lon": center_location.longitude,
|
||||
"farm_boundary": center_location.farm_boundary,
|
||||
"input_block_count": center_location.input_block_count,
|
||||
"block_layout": center_location.block_layout,
|
||||
},
|
||||
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||
"sensor_payload": farm.sensor_payload or {},
|
||||
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
|
||||
"soil": {
|
||||
"resolved_metrics": resolved_metrics,
|
||||
"metric_sources": metric_sources,
|
||||
"satellite_snapshots": build_location_block_satellite_snapshots(center_location),
|
||||
},
|
||||
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
||||
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
||||
"plant_assignments": [
|
||||
{
|
||||
"plant_id": assignment.plant.backend_plant_id,
|
||||
"position": assignment.position,
|
||||
"stage": assignment.stage,
|
||||
"metadata": assignment.metadata,
|
||||
"assigned_at": assignment.assigned_at,
|
||||
"updated_at": assignment.updated_at,
|
||||
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
|
||||
}
|
||||
for assignment in plant_assignments
|
||||
],
|
||||
"irrigation_method_id": farm.irrigation_method_id,
|
||||
"irrigation_method": (
|
||||
IrrigationMethodSerializer(farm.irrigation_method).data
|
||||
if farm.irrigation_method
|
||||
else None
|
||||
),
|
||||
"created_at": farm.created_at,
|
||||
"updated_at": farm.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def resolve_center_location_from_boundary(
|
||||
farm_boundary: dict | list,
|
||||
block_count: int = 1,
|
||||
) -> SoilLocation:
|
||||
"""
|
||||
مرز مزرعه را میگیرد، مرکز را محاسبه میکند و رکورد SoilLocation را
|
||||
ایجاد/بهروزرسانی میکند.
|
||||
"""
|
||||
points = _extract_boundary_points(farm_boundary)
|
||||
if not points:
|
||||
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
|
||||
|
||||
normalized_points = _normalize_points(points)
|
||||
if len(normalized_points) < 3:
|
||||
raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.")
|
||||
|
||||
center_lat, center_lon = _compute_polygon_centroid(normalized_points)
|
||||
serialized_boundary = _serialize_boundary(farm_boundary)
|
||||
normalized_block_count = max(int(block_count or 1), 1)
|
||||
|
||||
with transaction.atomic():
|
||||
location, created = SoilLocation.objects.get_or_create(
|
||||
latitude=center_lat,
|
||||
longitude=center_lon,
|
||||
defaults={
|
||||
"farm_boundary": serialized_boundary,
|
||||
"input_block_count": normalized_block_count,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
location.set_input_block_count(normalized_block_count)
|
||||
location.farm_boundary = serialized_boundary
|
||||
location.save(update_fields=["farm_boundary", "input_block_count", "block_layout", "updated_at"])
|
||||
if normalized_block_count == 1:
|
||||
_create_initial_block_subdivision(location, serialized_boundary)
|
||||
else:
|
||||
changed_fields = []
|
||||
if location.farm_boundary != serialized_boundary:
|
||||
location.farm_boundary = serialized_boundary
|
||||
changed_fields.append("farm_boundary")
|
||||
if location.input_block_count != normalized_block_count:
|
||||
location.set_input_block_count(normalized_block_count)
|
||||
changed_fields.extend(["input_block_count", "block_layout"])
|
||||
if changed_fields:
|
||||
changed_fields.append("updated_at")
|
||||
location.save(update_fields=changed_fields)
|
||||
return location
|
||||
|
||||
|
||||
def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None:
|
||||
return (
|
||||
WeatherForecast.objects.filter(location=location)
|
||||
.order_by("-forecast_date", "-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]:
|
||||
"""
|
||||
در فاز فعلی برای location_data و بلوکها هیچ ریکوئست خارجی زده نمیشود
|
||||
و فقط دادههای محلی موجود برگردانده میشوند.
|
||||
"""
|
||||
weather_forecast = resolve_weather_for_location(location)
|
||||
return location, weather_forecast
|
||||
|
||||
|
||||
def _create_initial_block_subdivision(
|
||||
location: SoilLocation,
|
||||
block_boundary: dict | list,
|
||||
) -> BlockSubdivision:
|
||||
subdivision, _created = create_or_get_block_subdivision(
|
||||
location=location,
|
||||
block_code="block-1",
|
||||
boundary=block_boundary,
|
||||
)
|
||||
return subdivision
|
||||
|
||||
|
||||
def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]:
|
||||
if not isinstance(sensor_payload, dict):
|
||||
return {}, {}
|
||||
|
||||
readings_by_metric: dict[str, list[tuple[str, object]]] = {}
|
||||
for sensor_key, sensor_values in sorted(sensor_payload.items()):
|
||||
if not isinstance(sensor_values, dict):
|
||||
continue
|
||||
for metric_key, metric_value in sensor_values.items():
|
||||
readings_by_metric.setdefault(metric_key, []).append((sensor_key, metric_value))
|
||||
|
||||
resolved_metrics = {}
|
||||
metric_sources = {}
|
||||
for metric_key, readings in readings_by_metric.items():
|
||||
resolved_value, source = _resolve_metric_readings(readings)
|
||||
resolved_metrics[metric_key] = resolved_value
|
||||
metric_sources[metric_key] = source
|
||||
return resolved_metrics, metric_sources
|
||||
|
||||
|
||||
def _resolve_metric_readings(readings: list[tuple[str, object]]) -> tuple[object, dict[str, object]]:
|
||||
if not readings:
|
||||
return None, {"type": "sensor", "strategy": "empty", "sensor_keys": []}
|
||||
|
||||
sensor_keys = [sensor_key for sensor_key, _value in readings]
|
||||
distinct_values: list[object] = []
|
||||
for _sensor_key, value in readings:
|
||||
if value not in distinct_values:
|
||||
distinct_values.append(value)
|
||||
|
||||
if len(distinct_values) == 1:
|
||||
return distinct_values[0], {
|
||||
"type": "sensor",
|
||||
"strategy": "single_value",
|
||||
"sensor_keys": sensor_keys,
|
||||
"sensor_count": len(sensor_keys),
|
||||
}
|
||||
|
||||
numeric_values = [_coerce_numeric(value) for value in distinct_values]
|
||||
if all(value is not None for value in numeric_values):
|
||||
average = sum(numeric_values) / len(numeric_values)
|
||||
resolved_value = _normalize_numeric_result(average, distinct_values)
|
||||
return resolved_value, {
|
||||
"type": "sensor",
|
||||
"strategy": "average",
|
||||
"sensor_keys": sensor_keys,
|
||||
"sensor_count": len(sensor_keys),
|
||||
"conflict": True,
|
||||
"distinct_values": distinct_values,
|
||||
}
|
||||
|
||||
return distinct_values, {
|
||||
"type": "sensor",
|
||||
"strategy": "distinct_values",
|
||||
"sensor_keys": sensor_keys,
|
||||
"sensor_count": len(sensor_keys),
|
||||
"conflict": True,
|
||||
"distinct_values": distinct_values,
|
||||
}
|
||||
|
||||
|
||||
def _coerce_numeric(value: object) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, Number):
|
||||
return float(value)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_numeric_result(value: float, source_values: list[object]) -> int | float:
|
||||
if all(isinstance(item, int) and not isinstance(item, bool) for item in source_values):
|
||||
if value.is_integer():
|
||||
return int(value)
|
||||
return float(Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _extract_boundary_points(boundary: dict | list) -> list:
|
||||
if isinstance(boundary, dict):
|
||||
if boundary.get("type") == "Polygon":
|
||||
coordinates = boundary.get("coordinates") or []
|
||||
if coordinates and isinstance(coordinates[0], list):
|
||||
return coordinates[0]
|
||||
return []
|
||||
if "corners" in boundary:
|
||||
return boundary.get("corners") or []
|
||||
if isinstance(boundary, list):
|
||||
return boundary
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_points(points: list) -> list[tuple[Decimal, Decimal]]:
|
||||
normalized: list[tuple[Decimal, Decimal]] = []
|
||||
for point in points:
|
||||
lat = lon = None
|
||||
if isinstance(point, dict):
|
||||
lat = point.get("lat", point.get("latitude"))
|
||||
lon = point.get("lon", point.get("longitude"))
|
||||
elif isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||
lon, lat = point[0], point[1]
|
||||
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
|
||||
lat_decimal = Decimal(str(lat))
|
||||
lon_decimal = Decimal(str(lon))
|
||||
normalized.append((lat_decimal, lon_decimal))
|
||||
|
||||
if len(normalized) > 1 and normalized[0] == normalized[-1]:
|
||||
normalized = normalized[:-1]
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_boundary(boundary: dict | list) -> dict:
|
||||
if isinstance(boundary, dict) and boundary.get("type") == "Polygon":
|
||||
return boundary
|
||||
raw_points = boundary.get("corners") if isinstance(boundary, dict) else boundary
|
||||
normalized = _normalize_points(raw_points or [])
|
||||
coordinates = [[float(lon), float(lat)] for lat, lon in normalized]
|
||||
if coordinates and coordinates[0] != coordinates[-1]:
|
||||
coordinates.append(coordinates[0])
|
||||
return {
|
||||
"type": "Polygon",
|
||||
"coordinates": [coordinates],
|
||||
}
|
||||
|
||||
|
||||
def _compute_polygon_centroid(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
|
||||
polygon = list(points)
|
||||
if polygon[0] != polygon[-1]:
|
||||
polygon.append(polygon[0])
|
||||
|
||||
twice_area = Decimal("0")
|
||||
centroid_lon = Decimal("0")
|
||||
centroid_lat = Decimal("0")
|
||||
|
||||
for index in range(len(polygon) - 1):
|
||||
lat1, lon1 = polygon[index]
|
||||
lat2, lon2 = polygon[index + 1]
|
||||
cross = (lon1 * lat2) - (lon2 * lat1)
|
||||
twice_area += cross
|
||||
centroid_lon += (lon1 + lon2) * cross
|
||||
centroid_lat += (lat1 + lat2) * cross
|
||||
|
||||
if twice_area == 0:
|
||||
return _compute_average_center(points)
|
||||
|
||||
factor = Decimal("3") * twice_area
|
||||
return (
|
||||
(centroid_lat / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
|
||||
(centroid_lon / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
|
||||
)
|
||||
|
||||
|
||||
def _compute_average_center(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]:
|
||||
lat_sum = sum(lat for lat, _ in points)
|
||||
lon_sum = sum(lon for _, lon in points)
|
||||
count = Decimal(len(points))
|
||||
return (
|
||||
(lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
|
||||
(lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from location_data.models import BlockSubdivision, SoilLocation
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
|
||||
from farm_data.services import (
|
||||
assign_farm_plants_from_backend_ids,
|
||||
get_canonical_farm_record,
|
||||
get_runtime_plant_for_farm,
|
||||
list_runtime_plants_for_farm,
|
||||
)
|
||||
from irrigation.models import IrrigationMethod
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
from farm_data.services import resolve_center_location_from_boundary
|
||||
|
||||
|
||||
def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict:
|
||||
return {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[lon - delta, lat - delta],
|
||||
[lon + delta, lat - delta],
|
||||
[lon + delta, lat + delta],
|
||||
[lon - delta, lat + delta],
|
||||
[lon - delta, lat - delta],
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FarmDetailApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.700000",
|
||||
longitude="51.400000",
|
||||
farm_boundary={"type": "Polygon", "coordinates": []},
|
||||
)
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=23.0,
|
||||
temperature_mean=18.0,
|
||||
precipitation=1.2,
|
||||
humidity_mean=52.0,
|
||||
)
|
||||
self.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجهفرنگی")
|
||||
self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="خیار")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطرهای")
|
||||
self.farm_uuid = uuid.uuid4()
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid=self.farm_uuid,
|
||||
center_location=self.location,
|
||||
weather_forecast=self.weather,
|
||||
irrigation_method=self.irrigation_method,
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 33.5,
|
||||
"nitrogen": 99.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id])
|
||||
|
||||
def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self):
|
||||
farm = get_canonical_farm_record(str(self.farm_uuid))
|
||||
|
||||
self.assertIsNotNone(farm)
|
||||
self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجهفرنگی"])
|
||||
self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار")
|
||||
|
||||
def test_assignment_sync_reconciles_legacy_relation_for_transition(self):
|
||||
self.assertEqual(list(self.farm.plants.values_list("name", flat=True)), ["خیار", "گوجهفرنگی"])
|
||||
|
||||
def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self):
|
||||
farm = get_canonical_farm_record(str(self.farm_uuid))
|
||||
|
||||
resolved = get_runtime_plant_for_farm(farm, plant_name="گوجهفرنگی")
|
||||
|
||||
self.assertIsNotNone(resolved)
|
||||
self.assertEqual(resolved.name, "گوجهفرنگی")
|
||||
self.assertEqual(resolved.id, self.plant1.backend_plant_id)
|
||||
|
||||
def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self):
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
|
||||
self.assertNotIn("farm_uuid", payload)
|
||||
self.assertEqual(payload["center_location"]["id"], self.location.id)
|
||||
self.assertEqual(payload["weather"]["id"], self.weather.id)
|
||||
self.assertEqual(
|
||||
payload["sensor_payload"]["sensor-7-1"]["soil_moisture"],
|
||||
33.5,
|
||||
)
|
||||
self.assertIn("sensor_schema", payload)
|
||||
self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen")
|
||||
|
||||
resolved_metrics = payload["soil"]["resolved_metrics"]
|
||||
metric_sources = payload["soil"]["metric_sources"]
|
||||
|
||||
self.assertEqual(resolved_metrics["nitrogen"], 99.0)
|
||||
self.assertEqual(metric_sources["nitrogen"]["type"], "sensor")
|
||||
self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value")
|
||||
self.assertEqual(payload["soil"]["satellite_snapshots"], [])
|
||||
self.assertCountEqual(payload["plant_ids"], [self.plant1.backend_plant_id, self.plant2.backend_plant_id])
|
||||
self.assertEqual(len(payload["plants"]), 2)
|
||||
returned_plants = {item["id"]: item for item in payload["plants"]}
|
||||
self.assertEqual(returned_plants[self.plant1.backend_plant_id]["name"], self.plant1.name)
|
||||
self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name)
|
||||
self.assertIn("light", returned_plants[self.plant1.backend_plant_id])
|
||||
self.assertEqual(len(payload["plant_assignments"]), 2)
|
||||
self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id)
|
||||
self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name)
|
||||
|
||||
def test_returns_404_when_farm_is_missing(self):
|
||||
response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["msg"], "farm یافت نشد.")
|
||||
|
||||
def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self):
|
||||
self.farm.sensor_payload = {
|
||||
"sensor-a": {
|
||||
"soil_moisture": 20.0,
|
||||
"nitrogen": 90.0,
|
||||
"status": "ok",
|
||||
},
|
||||
"sensor-b": {
|
||||
"soil_moisture": 40.0,
|
||||
"nitrogen": 110.0,
|
||||
"status": "needs-check",
|
||||
},
|
||||
}
|
||||
self.farm.save(update_fields=["sensor_payload"])
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
resolved_metrics = payload["soil"]["resolved_metrics"]
|
||||
metric_sources = payload["soil"]["metric_sources"]
|
||||
|
||||
self.assertEqual(resolved_metrics["soil_moisture"], 30.0)
|
||||
self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average")
|
||||
self.assertCountEqual(
|
||||
metric_sources["soil_moisture"]["sensor_keys"],
|
||||
["sensor-a", "sensor-b"],
|
||||
)
|
||||
self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0])
|
||||
self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"])
|
||||
self.assertEqual(metric_sources["status"]["strategy"], "distinct_values")
|
||||
|
||||
def test_detail_auto_registers_unknown_sensor_parameters(self):
|
||||
self.farm.sensor_payload = {
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 11.0,
|
||||
"leaf_temperature": 19.8,
|
||||
}
|
||||
}
|
||||
self.farm.save(update_fields=["sensor_payload"])
|
||||
|
||||
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
leaf_schema = payload["sensor_schema"]["leaf-sensor"]
|
||||
self.assertCountEqual(
|
||||
[item["code"] for item in leaf_schema],
|
||||
["leaf_temperature", "leaf_wetness"],
|
||||
)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists()
|
||||
)
|
||||
|
||||
|
||||
class FarmDataUpsertApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.710000",
|
||||
longitude="51.410000",
|
||||
)
|
||||
self.boundary = square_boundary_for_center(35.71, 51.41)
|
||||
self.weather = WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 11),
|
||||
temperature_min=11.0,
|
||||
temperature_max=24.0,
|
||||
temperature_mean=17.5,
|
||||
)
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="بارانی")
|
||||
|
||||
def test_post_creates_farm_data_with_explicit_farm_uuid(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 31.2,
|
||||
"nitrogen": 18.0,
|
||||
}
|
||||
},
|
||||
"irrigation_method_id": self.irrigation_method.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid))
|
||||
self.assertEqual(response.json()["data"]["center_location_id"], self.location.id)
|
||||
self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id)
|
||||
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertEqual(farm.center_location_id, self.location.id)
|
||||
self.assertEqual(farm.weather_forecast_id, self.weather.id)
|
||||
self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id)
|
||||
self.assertEqual(
|
||||
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
|
||||
31.2,
|
||||
)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
||||
)
|
||||
|
||||
def test_post_auto_registers_new_sensor_without_manual_parameter_creation(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {
|
||||
"canopy-sensor-v2": {
|
||||
"leaf_wetness": 12.4,
|
||||
"leaf_temperature": 21.6,
|
||||
"disease_pressure_index": 0.41,
|
||||
}
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
SensorParameter.objects.filter(
|
||||
sensor_key="canopy-sensor-v2",
|
||||
code="leaf_wetness",
|
||||
).exists()
|
||||
)
|
||||
detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/")
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
schema = detail_response.json()["data"]["sensor_schema"]["canopy-sensor-v2"]
|
||||
self.assertCountEqual(
|
||||
[item["code"] for item in schema],
|
||||
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
|
||||
)
|
||||
|
||||
def test_post_requires_farm_uuid_in_request_body(self):
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_boundary": self.boundary,
|
||||
"sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("farm_uuid", response.json()["data"])
|
||||
|
||||
def test_post_creates_center_location_from_boundary_when_missing(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": {
|
||||
"corners": [
|
||||
{"lat": 50.0, "lon": 50.0},
|
||||
{"lat": 50.0, "lon": 50.02},
|
||||
{"lat": 50.02, "lon": 50.02},
|
||||
{"lat": 50.02, "lon": 50.0},
|
||||
]
|
||||
},
|
||||
"sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertIsNotNone(farm.center_location_id)
|
||||
self.assertEqual(str(farm.center_location.latitude), "50.010000")
|
||||
self.assertEqual(str(farm.center_location.longitude), "50.010000")
|
||||
self.assertIsNone(farm.weather_forecast_id)
|
||||
self.assertEqual(farm.center_location.input_block_count, 1)
|
||||
self.assertEqual(len(farm.center_location.block_layout["blocks"]), 1)
|
||||
subdivision = BlockSubdivision.objects.get(soil_location=farm.center_location, block_code="block-1")
|
||||
self.assertGreater(subdivision.grid_point_count, 0)
|
||||
self.assertEqual(subdivision.grid_point_count, subdivision.centroid_count)
|
||||
|
||||
def test_post_persists_requested_block_count_on_center_location(self):
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": self.boundary,
|
||||
"block_count": 3,
|
||||
"sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertEqual(farm.center_location.input_block_count, 3)
|
||||
self.assertEqual(len(farm.center_location.block_layout["blocks"]), 3)
|
||||
self.assertFalse(
|
||||
BlockSubdivision.objects.filter(soil_location=farm.center_location).exists()
|
||||
)
|
||||
|
||||
def test_resolve_center_location_runs_subdivision_only_on_creation(self):
|
||||
boundary = square_boundary_for_center(35.75, 51.45)
|
||||
|
||||
first_location = resolve_center_location_from_boundary(boundary, block_count=1)
|
||||
first_subdivision = BlockSubdivision.objects.get(
|
||||
soil_location=first_location,
|
||||
block_code="block-1",
|
||||
)
|
||||
|
||||
second_location = resolve_center_location_from_boundary(boundary, block_count=1)
|
||||
|
||||
self.assertEqual(first_location.id, second_location.id)
|
||||
self.assertEqual(
|
||||
BlockSubdivision.objects.filter(
|
||||
soil_location=second_location,
|
||||
block_code="block-1",
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
BlockSubdivision.objects.get(
|
||||
soil_location=second_location,
|
||||
block_code="block-1",
|
||||
).id,
|
||||
first_subdivision.id,
|
||||
)
|
||||
|
||||
def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self):
|
||||
location = resolve_center_location_from_boundary(
|
||||
{
|
||||
"corners": [
|
||||
{"lat": 0.0, "lon": 0.0},
|
||||
{"lat": 0.0, "lon": 4.0},
|
||||
{"lat": 4.0, "lon": 4.0},
|
||||
{"lat": 4.0, "lon": 0.0},
|
||||
{"lat": 1.0, "lon": 0.0},
|
||||
{"lat": 1.0, "lon": 3.0},
|
||||
{"lat": 3.0, "lon": 3.0},
|
||||
{"lat": 3.0, "lon": 1.0},
|
||||
{"lat": 0.0, "lon": 1.0},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(str(location.latitude), "2.078947")
|
||||
self.assertEqual(str(location.longitude), "2.078947")
|
||||
|
||||
def test_post_keeps_missing_location_without_external_sync(self):
|
||||
missing_boundary = square_boundary_for_center(36.0, 52.0)
|
||||
farm_uuid = uuid.uuid4()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/farm-data/",
|
||||
data={
|
||||
"farm_uuid": str(farm_uuid),
|
||||
"farm_boundary": missing_boundary,
|
||||
"sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||
self.assertIsNone(farm.weather_forecast_id)
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import FarmDetailView, FarmDataUpsertView, PlantCatalogSyncView, SensorParameterCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<uuid:farm_uuid>/detail/",
|
||||
FarmDetailView.as_view(),
|
||||
name="farm-detail",
|
||||
),
|
||||
path(
|
||||
"",
|
||||
FarmDataUpsertView.as_view(),
|
||||
name="farm-data-upsert",
|
||||
),
|
||||
path(
|
||||
"parameters/",
|
||||
SensorParameterCreateView.as_view(),
|
||||
name="farm-parameter-create",
|
||||
),
|
||||
path(
|
||||
"plants/sync/",
|
||||
PlantCatalogSyncView.as_view(),
|
||||
name="farm-data-plant-sync",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,481 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.db import transaction
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.integration_contract import build_integration_meta
|
||||
from config.openapi import build_envelope_serializer, build_response
|
||||
from .models import ParameterUpdateLog, SensorData, SensorParameter
|
||||
from .serializers import (
|
||||
FarmDetailSerializer,
|
||||
SensorDataResponseSerializer,
|
||||
SensorDataUpdateSerializer,
|
||||
SensorParameterSerializer,
|
||||
)
|
||||
from .services import (
|
||||
BackendSyncError,
|
||||
assign_farm_plants_from_backend_ids,
|
||||
ExternalDataSyncError,
|
||||
ensure_location_and_weather_data,
|
||||
get_farm_details,
|
||||
resolve_center_location_from_boundary,
|
||||
sync_sensor_parameters_from_payload,
|
||||
sync_plant_catalog_from_backend,
|
||||
)
|
||||
|
||||
|
||||
SensorDataEnvelopeSerializer = build_envelope_serializer(
|
||||
"SensorDataEnvelopeSerializer",
|
||||
SensorDataResponseSerializer,
|
||||
)
|
||||
SensorDataValidationErrorSerializer = build_envelope_serializer(
|
||||
"SensorDataValidationErrorSerializer",
|
||||
data_required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
SensorDataNotFoundSerializer = build_envelope_serializer(
|
||||
"SensorDataNotFoundSerializer",
|
||||
data_required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
FarmDetailEnvelopeSerializer = build_envelope_serializer(
|
||||
"FarmDetailEnvelopeSerializer",
|
||||
FarmDetailSerializer,
|
||||
)
|
||||
SensorParameterResponseSerializer = build_envelope_serializer(
|
||||
"SensorParameterEnvelopeSerializer",
|
||||
inline_serializer(
|
||||
name="SensorParameterPayloadSerializer",
|
||||
fields={
|
||||
"id": drf_serializers.IntegerField(),
|
||||
"sensor_key": drf_serializers.CharField(),
|
||||
"code": drf_serializers.CharField(),
|
||||
"name_fa": drf_serializers.CharField(),
|
||||
"unit": drf_serializers.CharField(),
|
||||
"data_type": drf_serializers.CharField(),
|
||||
"metadata": drf_serializers.JSONField(),
|
||||
"created_at": drf_serializers.DateTimeField(),
|
||||
"action": drf_serializers.CharField(),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FarmDataUpsertView(APIView):
|
||||
"""
|
||||
ایجاد یا آپدیت داده farm.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Data"],
|
||||
summary="ایجاد یا آپدیت داده farm",
|
||||
description=(
|
||||
"داده farm را با `POST /api/farm-data/` ایجاد یا آپدیت میکند. "
|
||||
"`farm_uuid` باید از API ارسال شود و هرگز خودکار ساخته نمیشود. "
|
||||
"مرز مزرعه را میگیرد، مرکز زمین را خودش محاسبه و در location_data ذخیره میکند. "
|
||||
"رکورد آبوهوا هم از همان مرکز زمین بهصورت خودکار پیدا میشود. "
|
||||
"در این مرحله برای location_data هیچ ریکوئست خارجی برای بلوکها زده نمیشود. "
|
||||
'خوانشها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگهداری میشوند.'
|
||||
),
|
||||
request=SensorDataUpdateSerializer,
|
||||
responses={
|
||||
200: build_response(
|
||||
SensorDataEnvelopeSerializer,
|
||||
"داده farm با موفقیت بهروزرسانی شد.",
|
||||
),
|
||||
201: build_response(
|
||||
SensorDataEnvelopeSerializer,
|
||||
"داده farm با موفقیت ایجاد شد.",
|
||||
),
|
||||
400: build_response(
|
||||
SensorDataValidationErrorSerializer,
|
||||
"داده ورودی نامعتبر است.",
|
||||
),
|
||||
502: build_response(
|
||||
SensorDataNotFoundSerializer,
|
||||
"واکشی داده خاک یا آبوهوا از سرویس بیرونی ناموفق بود.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"farm_boundary": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.3900, 35.7000],
|
||||
[51.4100, 35.7000],
|
||||
[51.4100, 35.7200],
|
||||
[51.3900, 35.7200],
|
||||
[51.3900, 35.7000],
|
||||
]
|
||||
],
|
||||
},
|
||||
"block_count": 3,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 45.2,
|
||||
"soil_temperature": 22.5,
|
||||
"soil_ph": 6.8,
|
||||
"electrical_conductivity": 1.2,
|
||||
"nitrogen": 30.0,
|
||||
"phosphorus": 15.0,
|
||||
"potassium": 20.0,
|
||||
}
|
||||
},
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
OpenApiExample(
|
||||
"نمونه چند سنسور",
|
||||
value={
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"farm_boundary": {
|
||||
"corners": [
|
||||
{"lat": 35.7000, "lon": 51.3900},
|
||||
{"lat": 35.7000, "lon": 51.4100},
|
||||
{"lat": 35.7200, "lon": 51.4100},
|
||||
{"lat": 35.7200, "lon": 51.3900},
|
||||
]
|
||||
},
|
||||
"block_count": 2,
|
||||
"sensor_payload": {
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 45.2,
|
||||
"soil_temperature": 22.5,
|
||||
},
|
||||
"leaf-sensor": {
|
||||
"leaf_wetness": 11.0,
|
||||
"leaf_temperature": 19.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = SensorDataUpdateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
farm_uuid = serializer.validated_data["farm_uuid"]
|
||||
farm_boundary = serializer.validated_data["farm_boundary"]
|
||||
block_count = serializer.validated_data.get("block_count", 1)
|
||||
plant_ids = serializer.validated_data.get("plant_ids")
|
||||
irrigation_method_id = serializer.validated_data.get("irrigation_method_id")
|
||||
sensor_payload = serializer.validated_data.get("sensor_payload", {})
|
||||
try:
|
||||
center_location = resolve_center_location_from_boundary(
|
||||
farm_boundary,
|
||||
block_count=block_count,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
center_location, weather_forecast = ensure_location_and_weather_data(
|
||||
center_location
|
||||
)
|
||||
except ExternalDataSyncError as exc:
|
||||
return Response(
|
||||
{"code": 502, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
sync_sensor_parameters_from_payload(sensor_payload)
|
||||
farm_data, created = SensorData.objects.get_or_create(
|
||||
farm_uuid=farm_uuid,
|
||||
defaults={
|
||||
"center_location": center_location,
|
||||
"weather_forecast": weather_forecast,
|
||||
"sensor_payload": sensor_payload,
|
||||
},
|
||||
)
|
||||
|
||||
if not created and sensor_payload:
|
||||
merged_payload = deepcopy(farm_data.sensor_payload or {})
|
||||
for sensor_key, sensor_values in sensor_payload.items():
|
||||
current_values = merged_payload.get(sensor_key, {})
|
||||
if not isinstance(current_values, dict):
|
||||
current_values = {}
|
||||
current_values.update(sensor_values)
|
||||
merged_payload[sensor_key] = current_values
|
||||
farm_data.sensor_payload = merged_payload
|
||||
elif created:
|
||||
farm_data.sensor_payload = sensor_payload
|
||||
|
||||
farm_data.center_location = center_location
|
||||
farm_data.weather_forecast = weather_forecast
|
||||
if "irrigation_method_id" in serializer.validated_data:
|
||||
farm_data.irrigation_method_id = irrigation_method_id
|
||||
if not created:
|
||||
farm_data.save(
|
||||
update_fields=[
|
||||
"center_location",
|
||||
"weather_forecast",
|
||||
"sensor_payload",
|
||||
"irrigation_method",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
else:
|
||||
farm_data.save()
|
||||
|
||||
if plant_ids is not None:
|
||||
try:
|
||||
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
|
||||
except BackendSyncError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
response_status = (
|
||||
status.HTTP_201_CREATED if created else status.HTTP_200_OK
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"code": 201 if created else 200,
|
||||
"msg": "success",
|
||||
"data": SensorDataResponseSerializer(farm_data).data,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="provider",
|
||||
source_service="ai_farm_data",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
generated_at=farm_data.updated_at,
|
||||
notes=["AI farm_data stores a derived read-model enriched with location and weather data."],
|
||||
),
|
||||
},
|
||||
status=response_status,
|
||||
)
|
||||
|
||||
|
||||
class FarmDetailView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Farm Data"],
|
||||
summary="دریافت همه اطلاعات farm",
|
||||
description=(
|
||||
"اطلاعات تجمیعی farm را برمیگرداند. "
|
||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
||||
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
||||
),
|
||||
responses={
|
||||
200: build_response(
|
||||
FarmDetailEnvelopeSerializer,
|
||||
"اطلاعات farm با موفقیت بازگردانده شد.",
|
||||
),
|
||||
404: build_response(
|
||||
SensorDataNotFoundSerializer,
|
||||
"farm موردنظر یافت نشد.",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, farm_uuid):
|
||||
data = get_farm_details(str(farm_uuid))
|
||||
if data is None:
|
||||
return Response(
|
||||
{"code": 404, "msg": "farm یافت نشد.", "data": None},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="db",
|
||||
source_service="ai_farm_data",
|
||||
ownership="ai",
|
||||
live=False,
|
||||
cached=True,
|
||||
snapshot_at=getattr(data, "get", lambda *_: None)("updated_at") if isinstance(data, dict) else None,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class PlantCatalogSyncView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Farm Data"],
|
||||
summary="همگامسازی کاتالوگ گیاه از Backend",
|
||||
description="payload گیاههای canonical را از Backend دریافت و در `farm_data` snapshot میکند.",
|
||||
request=drf_serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="PlantCatalogSyncItem",
|
||||
fields={
|
||||
"id": drf_serializers.IntegerField(),
|
||||
"name": drf_serializers.CharField(),
|
||||
},
|
||||
)
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(description="کاتالوگ گیاه با موفقیت sync شد."),
|
||||
400: OpenApiResponse(description="payload نامعتبر است."),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
if not isinstance(request.data, list):
|
||||
return Response(
|
||||
{"code": 400, "msg": "payload باید آرایهای از گیاهها باشد.", "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
snapshots = sync_plant_catalog_from_backend(request.data)
|
||||
except BackendSyncError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"count": len(snapshots),
|
||||
"plant_ids": [snapshot.backend_plant_id for snapshot in snapshots],
|
||||
},
|
||||
"meta": build_integration_meta(
|
||||
flow_type="backend_owned_data_with_ai_enrichment",
|
||||
source_type="db",
|
||||
source_service="ai_farm_data_plant_catalog",
|
||||
ownership="backend",
|
||||
live=False,
|
||||
cached=False,
|
||||
generated_at=snapshots[-1].updated_at if snapshots else None,
|
||||
notes=["Backend is canonical for plant catalog; AI stores snapshots for derived services."],
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class SensorParameterCreateView(APIView):
|
||||
"""
|
||||
اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Parameters"],
|
||||
summary="افزودن/ویرایش پارامتر سنسور",
|
||||
description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش میکند و در لاگ ثبت میشود.",
|
||||
request=SensorParameterSerializer,
|
||||
responses={
|
||||
201: build_response(
|
||||
SensorParameterResponseSerializer,
|
||||
"پارامتر سنسور با موفقیت ایجاد یا ویرایش شد.",
|
||||
),
|
||||
400: build_response(
|
||||
SensorDataValidationErrorSerializer,
|
||||
"داده ورودی نامعتبر است.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"sensor_key": "sensor-7-1",
|
||||
"code": "soil_moisture",
|
||||
"name_fa": "رطوبت خاک",
|
||||
"unit": "%",
|
||||
"data_type": "float",
|
||||
"metadata": {"min": 0, "max": 100},
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = SensorParameterSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sensor_key = serializer.validated_data.get("sensor_key")
|
||||
code = serializer.validated_data["code"]
|
||||
name_fa = serializer.validated_data["name_fa"]
|
||||
unit = serializer.validated_data.get("unit", "")
|
||||
data_type = serializer.validated_data.get("data_type", "")
|
||||
metadata = serializer.validated_data.get("metadata", {})
|
||||
|
||||
with transaction.atomic():
|
||||
parameter, created = SensorParameter.objects.update_or_create(
|
||||
sensor_key=sensor_key,
|
||||
code=code,
|
||||
defaults={
|
||||
"name_fa": name_fa,
|
||||
"unit": unit,
|
||||
"data_type": data_type,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
action = (
|
||||
ParameterUpdateLog.ACTION_ADDED
|
||||
if created
|
||||
else ParameterUpdateLog.ACTION_MODIFIED
|
||||
)
|
||||
ParameterUpdateLog.objects.create(
|
||||
parameter=parameter,
|
||||
action=action,
|
||||
payload={
|
||||
"sensor_key": parameter.sensor_key,
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"code": 201,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": parameter.id,
|
||||
"sensor_key": parameter.sensor_key,
|
||||
"code": parameter.code,
|
||||
"name_fa": parameter.name_fa,
|
||||
"unit": parameter.unit,
|
||||
"data_type": parameter.data_type,
|
||||
"metadata": parameter.metadata,
|
||||
"created_at": parameter.created_at,
|
||||
"action": action,
|
||||
},
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="db",
|
||||
source_service="ai_farm_parameters",
|
||||
ownership="ai",
|
||||
live=False,
|
||||
cached=False,
|
||||
generated_at=parameter.created_at,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
Reference in New Issue
Block a user