This commit is contained in:
2026-04-06 23:50:24 +03:30
parent a67236d45c
commit ff464cb4a5
140 changed files with 2061 additions and 2702 deletions
View File
+35
View File
@@ -0,0 +1,35 @@
from django.contrib import admin
from .models import ParameterUpdateLog, 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")
filter_horizontal = ("plants",)
@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(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")
+8
View File
@@ -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"
View File
@@ -0,0 +1,69 @@
"""
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 SensorData
from location_data.models import SoilLocation
from plant.models import Plant
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(Plant.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name"))
if plants:
farm_data.plants.set(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}."
)
)
+88
View File
@@ -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",
),
]
View File
+226
View File
@@ -0,0 +1,226 @@
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 block in payload.values():
if isinstance(block, dict):
return block
return {}
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 in self._payload().values():
if isinstance(candidate, dict) and metric_name in candidate:
return candidate.get(metric_name)
return None
@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="گیاهان مرتبط با این 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
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}"
+53
View File
@@ -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 ثبت می‌شود."
}
]
}
+159
View File
@@ -0,0 +1,159 @@
from rest_framework import serializers
from location_data.serializers import SoilDepthDataSerializer
from plant.serializers import PlantSerializer
from weather.models import WeatherForecast
from .models import DEFAULT_SENSOR_DATA_TYPE, DEFAULT_SENSOR_KEY, SensorData
class SensorDataUpdateSerializer(serializers.Serializer):
"""ورودی آپدیت داده سنسور در ساختار JSON."""
farm_uuid = serializers.UUIDField(required=True)
farm_boundary = serializers.JSONField(required=True)
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="لیست شناسه گیاهان مرتبط",
)
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",
"sensor_key",
"sensor_payload",
"plant_ids",
}
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(self, attrs):
if "sensor_payload" not in attrs and "plant_ids" not in attrs:
raise serializers.ValidationError(
"حداقل یکی از sensor_payload یا plant_ids باید ارسال شود."
)
return attrs
class SensorDataResponseSerializer(serializers.ModelSerializer):
"""سریالایزر خروجی برای SensorData."""
plant_ids = serializers.PrimaryKeyRelatedField(
source="plants",
many=True,
read_only=True,
)
class Meta:
model = SensorData
fields = [
"farm_uuid",
"center_location_id",
"weather_forecast_id",
"sensor_payload",
"plant_ids",
"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()
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()
depths = SoilDepthDataSerializer(many=True)
class FarmDetailSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
center_location = FarmCenterLocationSerializer()
weather = WeatherForecastDetailSerializer(allow_null=True)
sensor_payload = serializers.JSONField()
soil = FarmSoilPayloadSerializer()
plant_ids = serializers.ListField(child=serializers.IntegerField())
plants = PlantSerializer(many=True)
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from django.db import transaction
from location_data.models import SoilLocation
from location_data.serializers import SoilDepthDataSerializer
from plant.serializers import PlantSerializer
from weather.models import WeatherForecast
from .models import SensorData
from .serializers import WeatherForecastDetailSerializer
DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"]
DECIMAL_PRECISION = Decimal("0.000001")
def get_farm_details(farm_uuid: str):
farm = (
SensorData.objects.select_related("center_location", "weather_forecast")
.prefetch_related("plants", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None:
return None
center_location = farm.center_location
weather = farm.weather_forecast
if weather is None:
weather = (
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
)
depths = list(center_location.depths.all())
depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99)
soil_metrics = _surface_soil_metrics(depths)
sensor_metrics = _flatten_sensor_metrics(farm.sensor_payload)
resolved_metrics = dict(soil_metrics)
metric_sources = {key: "soil" for key in soil_metrics}
for key, value in sensor_metrics.items():
resolved_metrics[key] = value
metric_sources[key] = "sensor"
return {
"farm_uuid": farm.farm_uuid,
"center_location": {
"id": center_location.id,
"lat": center_location.latitude,
"lon": center_location.longitude,
"farm_boundary": center_location.farm_boundary,
},
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
"sensor_payload": farm.sensor_payload or {},
"soil": {
"resolved_metrics": resolved_metrics,
"metric_sources": metric_sources,
"depths": SoilDepthDataSerializer(depths, many=True).data,
},
"plant_ids": list(farm.plants.values_list("id", flat=True)),
"plants": PlantSerializer(farm.plants.all(), many=True).data,
"created_at": farm.created_at,
"updated_at": farm.updated_at,
}
def resolve_center_location_from_boundary(farm_boundary: dict | list) -> 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 گوشه معتبر داشته باشد.")
lat_sum = sum(lat for lat, _ in normalized_points)
lon_sum = sum(lon for _, lon in normalized_points)
count = Decimal(len(normalized_points))
center_lat = (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
center_lon = (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP)
with transaction.atomic():
location, _ = SoilLocation.objects.update_or_create(
latitude=center_lat,
longitude=center_lon,
defaults={"farm_boundary": _serialize_boundary(farm_boundary)},
)
return location
def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None:
return (
WeatherForecast.objects.filter(location=location)
.order_by("-forecast_date", "-id")
.first()
)
def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict:
if not isinstance(sensor_payload, dict):
return {}
flattened = {}
for sensor_values in sensor_payload.values():
if not isinstance(sensor_values, dict):
continue
flattened.update(sensor_values)
return flattened
def _surface_soil_metrics(depths) -> dict:
if not depths:
return {}
primary_depth = depths[0]
fields = [
"bdod",
"cec",
"cfvo",
"clay",
"nitrogen",
"ocd",
"ocs",
"phh2o",
"sand",
"silt",
"soc",
"wv0010",
"wv0033",
"wv1500",
]
return {
field: getattr(primary_depth, field)
for field in fields
if getattr(primary_depth, field) is not None
}
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],
}
+1
View File
@@ -0,0 +1 @@
+195
View File
@@ -0,0 +1,195 @@
from datetime import date
import uuid
from django.test import TestCase
from rest_framework.test import APIClient
from location_data.models import SoilDepthData, SoilLocation
from farm_data.models import SensorData
from plant.models import Plant
from weather.models import WeatherForecast
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": []},
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="0-5cm",
clay=22.0,
nitrogen=10.0,
sand=40.0,
)
SoilDepthData.objects.create(
soil_location=self.location,
depth_label="5-15cm",
clay=18.0,
nitrogen=8.0,
)
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 = Plant.objects.create(name="گوجه‌فرنگی")
self.plant2 = Plant.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,
sensor_payload={
"sensor-7-1": {
"soil_moisture": 33.5,
"nitrogen": 99.0,
}
},
)
self.farm.plants.set([self.plant2, self.plant1])
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.assertEqual(payload["farm_uuid"], str(self.farm_uuid))
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,
)
resolved_metrics = payload["soil"]["resolved_metrics"]
metric_sources = payload["soil"]["metric_sources"]
self.assertEqual(resolved_metrics["nitrogen"], 99.0)
self.assertEqual(metric_sources["nitrogen"], "sensor")
self.assertEqual(resolved_metrics["clay"], 22.0)
self.assertEqual(metric_sources["clay"], "soil")
self.assertEqual(len(payload["soil"]["depths"]), 2)
self.assertCountEqual(payload["plant_ids"], [self.plant1.id, self.plant2.id])
self.assertEqual(len(payload["plants"]), 2)
returned_plants = {item["id"]: item for item in payload["plants"]}
self.assertEqual(returned_plants[self.plant1.id]["name"], self.plant1.name)
self.assertEqual(returned_plants[self.plant2.id]["name"], self.plant2.name)
self.assertIn("light", returned_plants[self.plant1.id])
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 یافت نشد.")
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,
)
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,
}
},
},
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.sensor_payload["sensor-7-1"]["soil_moisture"],
31.2,
)
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)
+21
View File
@@ -0,0 +1,21 @@
from django.urls import path
from .views import FarmDetailView, FarmDataUpsertView, 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",
),
]
+357
View File
@@ -0,0 +1,357 @@
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.openapi import build_envelope_serializer, build_response
from .models import ParameterUpdateLog, SensorData, SensorParameter
from .serializers import (
FarmDetailSerializer,
SensorDataResponseSerializer,
SensorDataUpdateSerializer,
SensorParameterSerializer,
)
from .services import (
get_farm_details,
resolve_center_location_from_boundary,
resolve_weather_for_location,
)
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 ذخیره می‌کند. "
"رکورد آب‌وهوا هم از همان مرکز زمین به‌صورت خودکار پیدا می‌شود. "
'خوانش‌ها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگه‌داری می‌شوند.'
),
request=SensorDataUpdateSerializer,
responses={
200: build_response(
SensorDataEnvelopeSerializer,
"داده farm با موفقیت به‌روزرسانی شد.",
),
201: build_response(
SensorDataEnvelopeSerializer,
"داده farm با موفقیت ایجاد شد.",
),
400: build_response(
SensorDataValidationErrorSerializer,
"داده ورودی نامعتبر است.",
),
},
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],
]
],
},
"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},
]
},
"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"]
plant_ids = serializer.validated_data.get("plant_ids")
sensor_payload = serializer.validated_data.get("sensor_payload", {})
try:
center_location = resolve_center_location_from_boundary(farm_boundary)
except ValueError as exc:
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}},
status=status.HTTP_400_BAD_REQUEST,
)
weather_forecast = resolve_weather_for_location(center_location)
with transaction.atomic():
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 not created:
farm_data.save(
update_fields=[
"center_location",
"weather_forecast",
"sensor_payload",
"updated_at",
]
)
else:
farm_data.save()
if plant_ids is not None:
farm_data.plants.set(plant_ids)
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,
},
status=response_status,
)
class FarmDetailView(APIView):
@extend_schema(
tags=["Farm Data"],
summary="دریافت همه اطلاعات farm",
description=(
"اطلاعات تجمیعی farm را برمی‌گرداند. "
"برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند."
),
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},
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,
},
},
status=status.HTTP_201_CREATED,
)