This commit is contained in:
2026-04-03 15:15:41 +03:30
parent bd0d04256c
commit e2728871ee
36 changed files with 1071 additions and 222 deletions
+175
View File
@@ -0,0 +1,175 @@
# Farm Hub & Sensor Catalog Changes
این فایل برای تیم فرانت آماده شده و فقط تغییرات موثر روی قرارداد API و داده‌ها را توضیح می‌دهد.
## 1) تغییر اصلی
مدل قدیمی `plant` از جریان اصلی حذف شده و اطلاعات محصول حالا از مدل `Product` در اپ `farm_hub` خوانده می‌شود.
به همین خاطر endpoint زیر:
`/api/farm-hub/farm-types/{farm_type_uuid}/products/`
اکنون باید داده‌های هر محصول را از جدول `products` برگرداند، نه از جدول قدیمی `plant`.
## 2) endpoint های مرتبط
### لیست نوع مزرعه
`GET /api/farm-hub/farm-types/`
نمونه پاسخ:
```json
{
"code": 200,
"msg": "success",
"data": [
{
"uuid": "farm-type-uuid",
"name": "زراعی",
"description": "",
"metadata": {}
}
]
}
```
### لیست محصولات بر اساس نوع مزرعه
`GET /api/farm-hub/farm-types/{farm_type_uuid}/products/`
نمونه پاسخ جدید:
```json
{
"code": 200,
"msg": "success",
"data": [
{
"uuid": "product-uuid",
"name": "گندم",
"description": "",
"metadata": {},
"light": "",
"watering": "",
"soil": "لومی",
"temperature": "",
"planting_season": "پاییز",
"harvest_time": "اواخر بهار",
"spacing": "",
"fertilizer": "",
"health_profile": {},
"irrigation_profile": {},
"growth_profile": {}
}
]
}
```
## 3) فیلدهای جدید هر product
قبلا فرانت فقط این‌ها را می‌گرفت:
- `uuid`
- `name`
- `description`
- `metadata`
الان این فیلدها هم اضافه شده‌اند:
- `light`
- `watering`
- `soil`
- `temperature`
- `planting_season`
- `harvest_time`
- `spacing`
- `fertilizer`
- `health_profile`
- `irrigation_profile`
- `growth_profile`
## 4) نکات مهم برای فرانت
- `health_profile`، `irrigation_profile` و `growth_profile` از نوع `JSON object` هستند
- بعضی فیلدها ممکن است رشته خالی `""` یا آبجکت خالی `{}` برگردانند
- بهتر است UI برای نبودن داده fallback داشته باشد
- مرتب‌سازی محصولات در endpoint بر اساس `name` است
## 5) سیدرهای جدید catalog
برای farm type ها و product ها سیدر گسترده‌تر اضافه شده است.
### farm type ها
- `زراعی`
- `درختی`
- `غرقابی`
- `گلخانه ای`
### محصولات seed شده
#### زراعی
- `گندم`
- `ذرت`
- `جو`
- `کلزا`
- `پنبه`
#### درختی
- `سیب`
- `پسته`
- `انگور`
- `انار`
#### غرقابی
- `برنج`
#### گلخانه ای
- `گوجه فرنگی`
- `خیار`
- `فلفل دلمه ای`
## 6) تغییرات مرتبط با farm object
در پاسخ farm، این فیلدها هم الان مهم هستند:
- `area_uuid`
- `sensors[].sensor_catalog_uuid`
- `sensors[].physical_device_uuid`
و این فیلدها از مدل حذف شده‌اند:
- `customization` در سطح farm
- `customization` در سطح sensor
## 7) sensor_catalog/apps.py
فایل:
`sensor_catalog/apps.py`
محتوا:
- اپ با نام `sensor_catalog` ثبت شده
- کلاس کانفیگ آن `SensorCatalogConfig` است
- `verbose_name` برابر `Sensor Catalog` است
نکته مهم برای فرانت:
- خود `sensor_catalog/apps.py` خروجی API را تغییر نمی‌دهد
- اثر عملی آن این است که اپ `sensor_catalog` به صورت رسمی در پروژه register شده و داده‌های سنسور حالا در `farm_hub` استفاده می‌شوند
- از این به بعد فرانت می‌تواند روی `sensor_catalog_uuid` برای سنسورها حساب کند
## 8) نتیجه نهایی برای فرانت
- منبع نمایش محصولات باید `farm_hub.products` باشد
- endpoint اصلی برای لیست محصولات هر نوع مزرعه:
- `GET /api/farm-hub/farm-types/{farm_type_uuid}/products/`
- UI جزئیات محصول می‌تواند فیلدهای زراعی/رشد/آبیاری را مستقیم از response بخواند
- برای سنسورها باید از `sensor_catalog_uuid` و `physical_device_uuid` استفاده شود
+1 -1
View File
@@ -28,7 +28,7 @@ INSTALLED_APPS = [
"auth.apps.AuthConfig", "auth.apps.AuthConfig",
"account.apps.AccountConfig", "account.apps.AccountConfig",
"farm_hub.apps.FarmHubConfig", "farm_hub.apps.FarmHubConfig",
"plant.apps.PlantConfig", "sensor_catalog.apps.SensorCatalogConfig",
"dashboard", "dashboard",
"crop_zoning", "crop_zoning",
"plant_simulator", "plant_simulator",
+1
View File
@@ -10,6 +10,7 @@ urlpatterns = [
path("api/auth/", include("auth.urls")), path("api/auth/", include("auth.urls")),
path("api/account/", include("account.urls")), path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.urls")), path("api/farm-hub/", include("farm_hub.urls")),
path("api/sensor-catalog/", include("sensor_catalog.urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")), path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")), path("api/crop-zoning/", include("crop_zoning.urls")),
+23
View File
@@ -0,0 +1,23 @@
CATALOG_SEED_DATA = {
"زراعی": [
{"name": "گندم", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"},
{"name": "ذرت", "planting_season": "بهار", "harvest_time": "تابستان", "soil": "لومی شنی"},
{"name": "جو", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"},
{"name": "کلزا", "planting_season": "پاییز", "harvest_time": "بهار", "soil": "لومی رسی"},
{"name": "پنبه", "planting_season": "بهار", "harvest_time": "پاییز", "soil": "لومی"},
],
"درختی": [
{"name": "سیب", "planting_season": "زمستان", "harvest_time": "پاییز", "soil": "لومی"},
{"name": "پسته", "planting_season": "زمستان", "harvest_time": "اواخر تابستان", "soil": "شنی لومی"},
{"name": "انگور", "planting_season": "اواخر زمستان", "harvest_time": "تابستان", "soil": "لومی"},
{"name": "انار", "planting_season": "اواخر زمستان", "harvest_time": "پاییز", "soil": "لومی شنی"},
],
"غرقابی": [
{"name": "برنج", "planting_season": "بهار", "harvest_time": "اواخر تابستان", "soil": "رسی"},
],
"گلخانه ای": [
{"name": "گوجه فرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت"},
{"name": "خیار", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "پرلیت"},
{"name": "فلفل دلمه ای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک"},
],
}
@@ -0,0 +1,29 @@
from django.core.management.base import BaseCommand
from farm_hub.catalog import CATALOG_SEED_DATA
from farm_hub.models import FarmType, Product
class Command(BaseCommand):
help = "Seed farm types and products catalog data."
def handle(self, *args, **options):
farm_type_count = 0
product_count = 0
for farm_type_name, products in CATALOG_SEED_DATA.items():
farm_type, created = FarmType.objects.get_or_create(name=farm_type_name)
farm_type_count += int(created)
for product_data in products:
_, product_created = Product.objects.update_or_create(
farm_type=farm_type,
name=product_data["name"],
defaults={key: value for key, value in product_data.items() if key != "name"},
)
product_count += int(product_created)
self.stdout.write(
self.style.SUCCESS(
f"Farm catalog seeded successfully. Created farm types: {farm_type_count}, products: {product_count}."
)
)
@@ -2,9 +2,10 @@ from django.db import migrations
FARM_TYPES = { FARM_TYPES = {
"زراعی": ["گندم", "ذرت"], "زراعی": ["گندم", "ذرت", "جو", "کلزا", "پنبه"],
"درختی": ["سیب", "پسته"], "درختی": ["سیب", "پسته", "انگور", "انار"],
"غرقابی": ["برنج"], "غرقابی": ["برنج"],
"گلخانه ای": ["گوجه فرنگی", "خیار", "فلفل دلمه ای"],
} }
@@ -0,0 +1,31 @@
# Generated by Django 5.2.12 on 2026-03-20 00:30
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
("farm_hub", "0002_seed_default_catalog"),
]
operations = [
migrations.AddField(
model_name="farmsensor",
name="physical_device_uuid",
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
),
migrations.AddField(
model_name="farmsensor",
name="sensor_catalog",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="farm_sensors",
to="sensor_catalog.sensorcatalog",
),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.12 on 2026-03-20 01:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("crop_zoning", "0004_croparea_farm"),
("farm_hub", "0003_farmsensor_catalog_and_physical_device"),
]
operations = [
migrations.RemoveField(
model_name="farmhub",
name="customization",
),
migrations.RemoveField(
model_name="farmsensor",
name="customization",
),
migrations.AddField(
model_name="farmhub",
name="current_crop_area",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="current_for_farms",
to="crop_zoning.croparea",
),
),
]
@@ -0,0 +1,163 @@
import json
from django.db import migrations, models
DEFAULT_FARM_TYPE_NAME = "زراعی"
def _table_exists(schema_editor, table_name):
with schema_editor.connection.cursor() as cursor:
existing_tables = set(schema_editor.connection.introspection.table_names(cursor))
return table_name in existing_tables
def _deserialize_json(value):
if value in (None, "", b""):
return {}
if isinstance(value, (dict, list)):
return value
if isinstance(value, bytes):
value = value.decode("utf-8")
try:
return json.loads(value)
except (TypeError, ValueError):
return {}
def migrate_plant_rows_to_products(apps, schema_editor):
if not _table_exists(schema_editor, "plant_plant"):
return
FarmType = apps.get_model("farm_hub", "FarmType")
Product = apps.get_model("farm_hub", "Product")
farm_type, _ = FarmType.objects.get_or_create(name=DEFAULT_FARM_TYPE_NAME)
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT
name,
light,
watering,
soil,
temperature,
planting_season,
harvest_time,
spacing,
fertilizer,
health_profile,
irrigation_profile,
growth_profile,
created_at,
updated_at
FROM plant_plant
"""
)
columns = [column[0] for column in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
for row in rows:
Product.objects.update_or_create(
farm_type=farm_type,
name=row["name"],
defaults={
"light": row["light"] or "",
"watering": row["watering"] or "",
"soil": row["soil"] or "",
"temperature": row["temperature"] or "",
"planting_season": row["planting_season"] or "",
"harvest_time": row["harvest_time"] or "",
"spacing": row["spacing"] or "",
"fertilizer": row["fertilizer"] or "",
"health_profile": _deserialize_json(row["health_profile"]),
"irrigation_profile": _deserialize_json(row["irrigation_profile"]),
"growth_profile": _deserialize_json(row["growth_profile"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
},
)
def drop_legacy_plant_table(apps, schema_editor):
if _table_exists(schema_editor, "plant_plant"):
schema_editor.execute("DROP TABLE plant_plant")
class Migration(migrations.Migration):
dependencies = [
("farm_hub", "0004_remove_customization_add_current_crop_area"),
]
operations = [
migrations.AddField(
model_name="product",
name="fertilizer",
field=models.CharField(blank=True, default="", help_text="کود مناسب", max_length=255),
),
migrations.AddField(
model_name="product",
name="growth_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text='پروفایل رشد محصول برای مدل GDD. {"base_temperature": 10, "required_gdd_for_maturity": 1200, "stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}',
),
),
migrations.AddField(
model_name="product",
name="harvest_time",
field=models.CharField(blank=True, default="", help_text="زمان برداشت", max_length=255),
),
migrations.AddField(
model_name="product",
name="health_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text='پروفایل سلامت محصول برای KPIها. ساختار نمونه: {"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}',
),
),
migrations.AddField(
model_name="product",
name="irrigation_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text='پروفایل آبیاری محصول برای محاسبات ETc. {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, "growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}',
),
),
migrations.AddField(
model_name="product",
name="light",
field=models.CharField(blank=True, default="", help_text="نور مورد نیاز", max_length=255),
),
migrations.AddField(
model_name="product",
name="planting_season",
field=models.CharField(blank=True, default="", help_text="فصل کاشت", max_length=255),
),
migrations.AddField(
model_name="product",
name="soil",
field=models.CharField(blank=True, default="", help_text="خاک مناسب", max_length=255),
),
migrations.AddField(
model_name="product",
name="spacing",
field=models.CharField(blank=True, default="", help_text="فاصله کاشت", max_length=255),
),
migrations.AddField(
model_name="product",
name="temperature",
field=models.CharField(blank=True, default="", help_text="دمای مناسب", max_length=255),
),
migrations.AddField(
model_name="product",
name="watering",
field=models.CharField(blank=True, default="", help_text="آبیاری", max_length=255),
),
migrations.RunPython(migrate_plant_rows_to_products, migrations.RunPython.noop),
migrations.RunPython(drop_legacy_plant_table, migrations.RunPython.noop),
]
@@ -0,0 +1,55 @@
from django.db import migrations
CATALOG_SEED_DATA = {
"زراعی": [
{"name": "گندم", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"},
{"name": "ذرت", "planting_season": "بهار", "harvest_time": "تابستان", "soil": "لومی شنی"},
{"name": "جو", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"},
{"name": "کلزا", "planting_season": "پاییز", "harvest_time": "بهار", "soil": "لومی رسی"},
{"name": "پنبه", "planting_season": "بهار", "harvest_time": "پاییز", "soil": "لومی"},
],
"درختی": [
{"name": "سیب", "planting_season": "زمستان", "harvest_time": "پاییز", "soil": "لومی"},
{"name": "پسته", "planting_season": "زمستان", "harvest_time": "اواخر تابستان", "soil": "شنی لومی"},
{"name": "انگور", "planting_season": "اواخر زمستان", "harvest_time": "تابستان", "soil": "لومی"},
{"name": "انار", "planting_season": "اواخر زمستان", "harvest_time": "پاییز", "soil": "لومی شنی"},
],
"غرقابی": [
{"name": "برنج", "planting_season": "بهار", "harvest_time": "اواخر تابستان", "soil": "رسی"},
],
"گلخانه ای": [
{"name": "گوجه فرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت"},
{"name": "خیار", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "پرلیت"},
{"name": "فلفل دلمه ای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک"},
],
}
def seed_expanded_catalog(apps, schema_editor):
FarmType = apps.get_model("farm_hub", "FarmType")
Product = apps.get_model("farm_hub", "Product")
for farm_type_name, products in CATALOG_SEED_DATA.items():
farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name)
for product_data in products:
Product.objects.update_or_create(
farm_type=farm_type,
name=product_data["name"],
defaults={key: value for key, value in product_data.items() if key != "name"},
)
def unseed_expanded_catalog(apps, schema_editor):
FarmType = apps.get_model("farm_hub", "FarmType")
FarmType.objects.filter(name__in=CATALOG_SEED_DATA.keys()).delete()
class Migration(migrations.Migration):
dependencies = [
("farm_hub", "0005_product_profiles_and_plant_migration"),
]
operations = [
migrations.RunPython(seed_expanded_catalog, unseed_expanded_catalog),
]
+55 -7
View File
@@ -1,11 +1,12 @@
import uuid import uuid as uuid_lib
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from sensor_catalog.models import SensorCatalog
class FarmType(models.Model): class FarmType(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
name = models.CharField(max_length=255, unique=True, db_index=True) name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)
@@ -21,7 +22,7 @@ class FarmType(models.Model):
class Product(models.Model): class Product(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm_type = models.ForeignKey( farm_type = models.ForeignKey(
FarmType, FarmType,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -30,6 +31,40 @@ class Product(models.Model):
name = models.CharField(max_length=255, db_index=True) name = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)
light = models.CharField(max_length=255, blank=True, default="", help_text="نور مورد نیاز")
watering = models.CharField(max_length=255, blank=True, default="", help_text="آبیاری")
soil = models.CharField(max_length=255, blank=True, default="", help_text="خاک مناسب")
temperature = models.CharField(max_length=255, blank=True, default="", help_text="دمای مناسب")
planting_season = models.CharField(max_length=255, blank=True, default="", help_text="فصل کاشت")
harvest_time = models.CharField(max_length=255, blank=True, default="", help_text="زمان برداشت")
spacing = models.CharField(max_length=255, blank=True, default="", help_text="فاصله کاشت")
fertilizer = models.CharField(max_length=255, blank=True, default="", help_text="کود مناسب")
health_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل سلامت محصول برای KPIها. ساختار نمونه: "
'{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}'
),
)
irrigation_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل آبیاری محصول برای محاسبات ETc. "
'{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, '
'"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}'
),
)
growth_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل رشد محصول برای مدل GDD. "
'{"base_temperature": 10, "required_gdd_for_maturity": 1200, '
'"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}'
),
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -45,7 +80,7 @@ class Product(models.Model):
class FarmHub(models.Model): class FarmHub(models.Model):
farm_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) farm_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
owner = models.ForeignKey( owner = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -58,7 +93,13 @@ class FarmHub(models.Model):
) )
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
customization = models.JSONField(default=dict, blank=True) current_crop_area = models.ForeignKey(
"crop_zoning.CropArea",
on_delete=models.SET_NULL,
related_name="current_for_farms",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
products = models.ManyToManyField(Product, related_name="farms", blank=True) products = models.ManyToManyField(Product, related_name="farms", blank=True)
@@ -72,18 +113,25 @@ class FarmHub(models.Model):
class FarmSensor(models.Model): class FarmSensor(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey( farm = models.ForeignKey(
FarmHub, FarmHub,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="sensors", related_name="sensors",
) )
sensor_catalog = models.ForeignKey(
SensorCatalog,
on_delete=models.PROTECT,
related_name="farm_sensors",
null=True,
blank=True,
)
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sensor_type = models.CharField(max_length=255, blank=True, default="") sensor_type = models.CharField(max_length=255, blank=True, default="")
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
specifications = models.JSONField(default=dict, blank=True) specifications = models.JSONField(default=dict, blank=True)
power_source = models.JSONField(default=dict, blank=True) power_source = models.JSONField(default=dict, blank=True)
customization = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+35 -31
View File
@@ -3,7 +3,9 @@ import uuid
from django.db import transaction from django.db import transaction
from account.seeds import seed_admin_user from account.seeds import seed_admin_user
from sensor_catalog.models import SensorCatalog
from .catalog import CATALOG_SEED_DATA
from .models import FarmHub, FarmType, Product from .models import FarmHub, FarmType, Product
from .services import dispatch_farm_zoning from .services import dispatch_farm_zoning
@@ -12,19 +14,10 @@ ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
ADMIN_FARM_DATA = { ADMIN_FARM_DATA = {
"name": "Admin Smart Farm", "name": "Admin Smart Farm",
"is_active": True, "is_active": True,
"customization": {
"irrigation": {
"mode": "smart",
"report_interval_sec": 300,
},
"alerts": {
"sms": True,
"email": True,
"push": True,
},
},
"sensors": [ "sensors": [
{ {
"sensor_catalog_name": "Sensor 7 - Soil Moisture Sensor v1.2",
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222221"),
"name": "Station 1", "name": "Station 1",
"sensor_type": "weather_station", "sensor_type": "weather_station",
"is_active": True, "is_active": True,
@@ -38,14 +31,10 @@ ADMIN_FARM_DATA = {
"battery": {"capacity_mah": 12000, "voltage": 12}, "battery": {"capacity_mah": 12000, "voltage": 12},
"solar": {"panel_watt": 40, "controller": "MPPT"}, "solar": {"panel_watt": 40, "controller": "MPPT"},
}, },
"customization": {
"thresholds": {
"temperature_c": {"min": 10, "max": 36},
"humidity_percent": {"min": 30, "max": 85},
}
},
}, },
{ {
"sensor_catalog_name": "Sensor 7 - Soil Moisture Sensor v1.2",
"physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222222"),
"name": "Soil Probe 1", "name": "Soil Probe 1",
"sensor_type": "soil_probe", "sensor_type": "soil_probe",
"is_active": True, "is_active": True,
@@ -53,13 +42,6 @@ ADMIN_FARM_DATA = {
"capabilities": ["soil_moisture", "soil_temperature", "ph", "ec"], "capabilities": ["soil_moisture", "soil_temperature", "ph", "ec"],
}, },
"power_source": {"type": "battery", "backup": "solar"}, "power_source": {"type": "battery", "backup": "solar"},
"customization": {
"depth_cm": [20, 40],
"thresholds": {
"soil_moisture_percent": {"min": 25, "max": 70},
"ph": {"min": 5.8, "max": 7.2},
},
},
}, },
], ],
} }
@@ -82,10 +64,25 @@ ADMIN_FARM_AREA_GEOJSON = {
def _get_default_catalog(): def _get_default_catalog():
farm_type, _ = FarmType.objects.get_or_create(name="زراعی") default_farm_type_name = "زراعی"
wheat, _ = Product.objects.get_or_create(farm_type=farm_type, name="گندم") created_products = []
corn, _ = Product.objects.get_or_create(farm_type=farm_type, name="ذرت")
return farm_type, [wheat, corn] for farm_type_name, products in CATALOG_SEED_DATA.items():
farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name)
for product_data in products:
product, _ = Product.objects.update_or_create(
farm_type=farm_type,
name=product_data["name"],
defaults={key: value for key, value in product_data.items() if key != "name"},
)
if farm_type_name == default_farm_type_name:
created_products.append(product)
return FarmType.objects.get(name=default_farm_type_name), created_products[:2]
def _get_sensor_catalog_by_name(name):
return SensorCatalog.objects.filter(name=name).first()
@transaction.atomic @transaction.atomic
@@ -99,12 +96,19 @@ def seed_admin_farm():
"farm_type": farm_type, "farm_type": farm_type,
"name": ADMIN_FARM_DATA["name"], "name": ADMIN_FARM_DATA["name"],
"is_active": ADMIN_FARM_DATA["is_active"], "is_active": ADMIN_FARM_DATA["is_active"],
"customization": ADMIN_FARM_DATA["customization"],
}, },
) )
farm.products.set(products) farm.products.set(products)
farm.sensors.all().delete() farm.sensors.all().delete()
farm.sensors.bulk_create([farm.sensors.model(farm=farm, **sensor_data) for sensor_data in ADMIN_FARM_DATA["sensors"]]) sensors = []
for sensor_data in ADMIN_FARM_DATA["sensors"]:
sensor_data = sensor_data.copy()
sensor_catalog_name = sensor_data.pop("sensor_catalog_name", None)
sensor_data["sensor_catalog"] = _get_sensor_catalog_by_name(sensor_catalog_name) if sensor_catalog_name else None
sensors.append(farm.sensors.model(farm=farm, **sensor_data))
farm.sensors.bulk_create(sensors)
if created: if created:
dispatch_farm_zoning(ADMIN_FARM_AREA_GEOJSON, farm) crop_area, _zoning_payload = dispatch_farm_zoning(ADMIN_FARM_AREA_GEOJSON, farm)
farm.current_crop_area = crop_area
farm.save(update_fields=["current_crop_area", "updated_at"])
return farm, created return farm, created
+45 -5
View File
@@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import FarmHub, FarmSensor, FarmType, Product from .models import FarmHub, FarmSensor, FarmType, Product
from sensor_catalog.models import SensorCatalog
class FarmTypeSerializer(serializers.ModelSerializer): class FarmTypeSerializer(serializers.ModelSerializer):
@@ -12,22 +13,40 @@ class FarmTypeSerializer(serializers.ModelSerializer):
class ProductSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Product model = Product
fields = ["uuid", "name", "description", "metadata"] fields = [
"uuid",
"name",
"description",
"metadata",
"light",
"watering",
"soil",
"temperature",
"planting_season",
"harvest_time",
"spacing",
"fertilizer",
"health_profile",
"irrigation_profile",
"growth_profile",
]
class FarmSensorSerializer(serializers.ModelSerializer): class FarmSensorSerializer(serializers.ModelSerializer):
last_updated = serializers.DateTimeField(source="updated_at", read_only=True) last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True)
class Meta: class Meta:
model = FarmSensor model = FarmSensor
fields = [ fields = [
"uuid", "uuid",
"sensor_catalog_uuid",
"physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
"customization",
"last_updated", "last_updated",
] ]
read_only_fields = ["uuid", "last_updated"] read_only_fields = ["uuid", "last_updated"]
@@ -38,14 +57,15 @@ class FarmHubSerializer(serializers.ModelSerializer):
farm_type = FarmTypeSerializer(read_only=True) farm_type = FarmTypeSerializer(read_only=True)
products = ProductSerializer(many=True, read_only=True) products = ProductSerializer(many=True, read_only=True)
sensors = FarmSensorSerializer(many=True, read_only=True) sensors = FarmSensorSerializer(many=True, read_only=True)
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True)
class Meta: class Meta:
model = FarmHub model = FarmHub
fields = [ fields = [
"farm_uuid", "farm_uuid",
"area_uuid",
"name", "name",
"is_active", "is_active",
"customization",
"farm_type", "farm_type",
"products", "products",
"sensors", "sensors",
@@ -55,17 +75,32 @@ class FarmHubSerializer(serializers.ModelSerializer):
class FarmSensorWriteSerializer(serializers.ModelSerializer): class FarmSensorWriteSerializer(serializers.ModelSerializer):
sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False)
class Meta: class Meta:
model = FarmSensor model = FarmSensor
fields = [ fields = [
"sensor_catalog_uuid",
"physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
"customization",
] ]
def validate(self, attrs):
sensor_catalog_uuid = attrs.pop("sensor_catalog_uuid", None)
if sensor_catalog_uuid is not None:
try:
sensor_catalog = SensorCatalog.objects.get(uuid=sensor_catalog_uuid)
except SensorCatalog.DoesNotExist as exc:
raise serializers.ValidationError({"sensor_catalog_uuid": ["Sensor catalog not found."]}) from exc
attrs["sensor_catalog"] = sensor_catalog
attrs.setdefault("name", sensor_catalog.name)
return attrs
class FarmHubCreateSerializer(serializers.ModelSerializer): class FarmHubCreateSerializer(serializers.ModelSerializer):
area_geojson = serializers.JSONField(write_only=True, required=False) area_geojson = serializers.JSONField(write_only=True, required=False)
@@ -82,13 +117,18 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
fields = [ fields = [
"name", "name",
"is_active", "is_active",
"customization",
"farm_type_uuid", "farm_type_uuid",
"product_uuids", "product_uuids",
"sensors", "sensors",
"area_geojson", "area_geojson",
] ]
def to_internal_value(self, data):
if hasattr(data, "copy"):
data = data.copy()
data.pop("farm_uuid", None)
return super().to_internal_value(data)
def validate_area_geojson(self, value): def validate_area_geojson(self, value):
if not isinstance(value, dict): if not isinstance(value, dict):
raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.")
+11 -7
View File
@@ -1,21 +1,25 @@
from django.db import transaction from django.db import transaction
from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature from crop_zoning.services import (
create_zones_and_dispatch,
get_default_area_feature,
get_initial_zones_payload,
normalize_area_feature,
)
def dispatch_farm_zoning(area_feature, farm): def dispatch_farm_zoning(area_feature, farm):
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm) crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm)
return get_initial_zones_payload(crop_area) return crop_area, get_initial_zones_payload(crop_area)
def create_farm_with_zoning(serializer, owner): def create_farm_with_zoning(serializer, owner):
area_feature = serializer.validated_data.pop("area_geojson", None) area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature()
with transaction.atomic(): with transaction.atomic():
farm = serializer.save(owner=owner) farm = serializer.save(owner=owner)
zoning_payload = None crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm)
farm.current_crop_area = crop_area
if area_feature is not None: farm.save(update_fields=["current_crop_area", "updated_at"])
zoning_payload = dispatch_farm_zoning(area_feature, farm)
return farm, zoning_payload return farm, zoning_payload
+118 -3
View File
@@ -5,7 +5,8 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea from crop_zoning.models import CropArea
from farm_hub.models import FarmType, Product from farm_hub.models import FarmType, Product
from farm_hub.seeds import seed_admin_farm from farm_hub.seeds import seed_admin_farm
from farm_hub.views import FarmListCreateView from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
from sensor_catalog.models import SensorCatalog
AREA_GEOJSON = { AREA_GEOJSON = {
@@ -40,22 +41,27 @@ class FarmListCreateViewTests(TestCase):
) )
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم") self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
self.weather_station, _ = SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self): def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self):
physical_device_uuid = "33333333-3333-3333-3333-333333333333"
request = self.factory.post( request = self.factory.post(
"/api/farm-hub/", "/api/farm-hub/",
{ {
"name": "farm-1", "name": "farm-1",
"farm_type_uuid": str(self.farm_type.uuid), "farm_type_uuid": str(self.farm_type.uuid),
"product_uuids": [str(self.wheat.uuid)], "product_uuids": [str(self.wheat.uuid)],
"customization": {"report_interval_sec": 300},
"sensors": [ "sensors": [
{ {
"sensor_catalog_uuid": str(self.weather_station.uuid),
"physical_device_uuid": physical_device_uuid,
"name": "zone-sensor", "name": "zone-sensor",
"sensor_type": "weather_station", "sensor_type": "weather_station",
"specifications": {"model": "FH-1"}, "specifications": {"model": "FH-1"},
"power_source": {"type": "battery"}, "power_source": {"type": "battery"},
"customization": {"report_interval_sec": 300},
} }
], ],
"area_geojson": AREA_GEOJSON, "area_geojson": AREA_GEOJSON,
@@ -70,7 +76,10 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(response.data["code"], 201) self.assertEqual(response.data["code"], 201)
self.assertEqual(response.data["data"]["name"], "farm-1") self.assertEqual(response.data["data"]["name"], "farm-1")
self.assertIn("zoning", response.data["data"]) self.assertIn("zoning", response.data["data"])
self.assertIsNotNone(response.data["data"]["area_uuid"])
self.assertEqual(len(response.data["data"]["sensors"]), 1) self.assertEqual(len(response.data["data"]["sensors"]), 1)
self.assertEqual(response.data["data"]["sensors"][0]["sensor_catalog_uuid"], str(self.weather_station.uuid))
self.assertEqual(response.data["data"]["sensors"][0]["physical_device_uuid"], physical_device_uuid)
self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1) self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1)
self.assertEqual( self.assertEqual(
response.data["data"]["zoning"]["zone_count"], response.data["data"]["zoning"]["zone_count"],
@@ -78,6 +87,48 @@ class FarmListCreateViewTests(TestCase):
) )
self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(CropArea.objects.count(), 1)
def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self):
request = self.factory.post(
"/api/farm-hub/",
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"name": "farm-2",
"farm_type_uuid": str(self.farm_type.uuid),
"product_uuids": [str(self.wheat.uuid)],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmListCreateView.as_view()(request)
self.assertEqual(response.status_code, 201)
self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111")
self.assertIsNotNone(response.data["data"]["area_uuid"])
def test_create_farm_rejects_unknown_sensor_catalog_uuid(self):
request = self.factory.post(
"/api/farm-hub/",
{
"name": "farm-3",
"farm_type_uuid": str(self.farm_type.uuid),
"product_uuids": [str(self.wheat.uuid)],
"sensors": [
{
"sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444",
"name": "zone-sensor",
}
],
},
format="json",
)
force_authenticate(request, user=self.user)
response = FarmListCreateView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("sensor_catalog_uuid", response.data["sensors"][0])
@override_settings( @override_settings(
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
@@ -85,14 +136,23 @@ class FarmListCreateViewTests(TestCase):
) )
class FarmSeedTests(TestCase): class FarmSeedTests(TestCase):
def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self): def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self):
SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
farm, created = seed_admin_farm() farm, created = seed_admin_farm()
self.assertTrue(created) self.assertTrue(created)
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111") self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(CropArea.objects.count(), 1)
self.assertEqual(farm.sensors.count(), 2) self.assertEqual(farm.sensors.count(), 2)
self.assertIsNotNone(farm.sensors.first().physical_device_uuid)
def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self): def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self):
SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]},
)
first_farm, first_created = seed_admin_farm() first_farm, first_created = seed_admin_farm()
second_farm, second_created = seed_admin_farm() second_farm, second_created = seed_admin_farm()
@@ -100,3 +160,58 @@ class FarmSeedTests(TestCase):
self.assertFalse(second_created) self.assertFalse(second_created)
self.assertEqual(first_farm.id, second_farm.id) self.assertEqual(first_farm.id, second_farm.id)
self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(CropArea.objects.count(), 1)
class FarmCatalogViewsTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="catalog-user",
password="secret123",
email="catalog@example.com",
phone_number="09120000001",
)
self.field_farm_type = FarmType.objects.create(name="زراعی")
self.tree_farm_type = FarmType.objects.create(name="درختی")
self.wheat = Product.objects.create(
farm_type=self.field_farm_type,
name="گندم",
planting_season="پاییز",
harvest_time="بهار",
health_profile={"moisture": {"ideal_value": 65}},
)
self.corn = Product.objects.create(farm_type=self.field_farm_type, name="ذرت")
Product.objects.create(farm_type=self.tree_farm_type, name="سیب")
def test_farm_type_list_returns_all_farm_types(self):
request = self.factory.get("/api/farm-hub/farm-types/")
force_authenticate(request, user=self.user)
response = FarmTypeListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 2)
def test_farm_type_products_returns_products_for_selected_type(self):
request = self.factory.get(f"/api/farm-hub/farm-types/{self.field_farm_type.uuid}/products/")
force_authenticate(request, user=self.user)
response = FarmTypeProductsView.as_view()(request, farm_type_uuid=self.field_farm_type.uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual({item["name"] for item in response.data["data"]}, {self.wheat.name, self.corn.name})
wheat_payload = next(item for item in response.data["data"] if item["name"] == self.wheat.name)
self.assertEqual(wheat_payload["planting_season"], "پاییز")
self.assertEqual(wheat_payload["health_profile"]["moisture"]["ideal_value"], 65)
def test_farm_type_products_returns_404_for_unknown_type(self):
unknown_farm_type_uuid = "11111111-1111-1111-1111-111111111111"
request = self.factory.get(f"/api/farm-hub/farm-types/{unknown_farm_type_uuid}/products/")
force_authenticate(request, user=self.user)
response = FarmTypeProductsView.as_view()(request, farm_type_uuid=unknown_farm_type_uuid)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm type not found.")
+10 -1
View File
@@ -1,10 +1,19 @@
from django.urls import path from django.urls import path
from .views import FarmActiveView, FarmDeactiveView, FarmDetailView, FarmListCreateView from .views import (
FarmActiveView,
FarmDeactiveView,
FarmDetailView,
FarmListCreateView,
FarmTypeListView,
FarmTypeProductsView,
)
urlpatterns = [ urlpatterns = [
path("active/", FarmActiveView.as_view(), name="farm-hub-active"), path("active/", FarmActiveView.as_view(), name="farm-hub-active"),
path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"), path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"),
path("farm-types/", FarmTypeListView.as_view(), name="farm-type-list"),
path("farm-types/<uuid:farm_type_uuid>/products/", FarmTypeProductsView.as_view(), name="farm-type-products"),
path("<uuid:farm_uuid>/", FarmDetailView.as_view(), name="farm-hub-detail"), path("<uuid:farm_uuid>/", FarmDetailView.as_view(), name="farm-hub-detail"),
path("", FarmListCreateView.as_view(), name="farm-hub-list"), path("", FarmListCreateView.as_view(), name="farm-hub-list"),
] ]
+44 -4
View File
@@ -6,8 +6,14 @@ from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from config.swagger import code_response from config.swagger import code_response
from .models import FarmHub from .models import FarmHub, FarmType, Product
from .serializers import FarmHubCreateSerializer, FarmHubSerializer, FarmToggleSerializer from .serializers import (
FarmHubCreateSerializer,
FarmHubSerializer,
FarmToggleSerializer,
FarmTypeSerializer,
ProductSerializer,
)
from .services import create_farm_with_zoning from .services import create_farm_with_zoning
@@ -16,7 +22,10 @@ class FarmHubBaseView(APIView):
def _get_farm(self, request, farm_uuid): def _get_farm(self, request, farm_uuid):
try: try:
return FarmHub.objects.prefetch_related("products", "sensors").select_related("farm_type").get( return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
"farm_type",
"current_crop_area",
).get(
farm_uuid=farm_uuid, farm_uuid=farm_uuid,
owner=request.user, owner=request.user,
) )
@@ -30,9 +39,10 @@ class FarmListCreateView(FarmHubBaseView):
responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))},
) )
def get(self, request): def get(self, request):
farms = FarmHub.objects.filter(owner=request.user).select_related("farm_type").prefetch_related( farms = FarmHub.objects.filter(owner=request.user).select_related("farm_type", "current_crop_area").prefetch_related(
"products", "products",
"sensors", "sensors",
"sensors__sensor_catalog",
) )
data = FarmHubSerializer(farms, many=True).data data = FarmHubSerializer(farms, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@@ -57,6 +67,36 @@ class FarmListCreateView(FarmHubBaseView):
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
class FarmTypeListView(FarmHubBaseView):
@extend_schema(
tags=["Farm Hub"],
responses={200: code_response("FarmTypeListResponse", data=FarmTypeSerializer(many=True))},
)
def get(self, request):
farm_types = FarmType.objects.order_by("name")
data = FarmTypeSerializer(farm_types, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class FarmTypeProductsView(FarmHubBaseView):
@extend_schema(
tags=["Farm Hub"],
responses={
200: code_response("FarmTypeProductsResponse", data=ProductSerializer(many=True)),
404: code_response("FarmTypeProductsNotFoundResponse"),
},
)
def get(self, request, farm_type_uuid):
try:
farm_type = FarmType.objects.get(uuid=farm_type_uuid)
except FarmType.DoesNotExist:
return Response({"code": 404, "msg": "Farm type not found."}, status=status.HTTP_404_NOT_FOUND)
products = Product.objects.filter(farm_type=farm_type).order_by("name")
data = ProductSerializer(products, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class FarmDetailView(FarmHubBaseView): class FarmDetailView(FarmHubBaseView):
@extend_schema( @extend_schema(
tags=["Farm Hub"], tags=["Farm Hub"],
-6
View File
@@ -1,6 +0,0 @@
from django.apps import AppConfig
class PlantConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plant"
-35
View File
@@ -1,35 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-19 15:01
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Plant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='نام گیاه', max_length=255, unique=True)),
('light', models.CharField(blank=True, help_text='نور مورد نیاز', max_length=255)),
('watering', models.CharField(blank=True, help_text='آبیاری', max_length=255)),
('soil', models.CharField(blank=True, help_text='خاک مناسب', max_length=255)),
('temperature', models.CharField(blank=True, help_text='دمای مناسب', max_length=255)),
('planting_season', models.CharField(blank=True, help_text='فصل کاشت', max_length=255)),
('harvest_time', models.CharField(blank=True, help_text='زمان برداشت', max_length=255)),
('spacing', models.CharField(blank=True, help_text='فاصله کاشت', max_length=255)),
('fertilizer', models.CharField(blank=True, help_text='کود مناسب', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'گیاه',
'verbose_name_plural': 'گیاهان',
'ordering': ['name'],
},
),
]
@@ -1,23 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("plant", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="plant",
name="health_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل سلامت گیاه برای KPIها. ساختار نمونه: "
'{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}'
),
),
),
]
@@ -1,24 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("plant", "0002_plant_health_profile"),
]
operations = [
migrations.AddField(
model_name="plant",
name="irrigation_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل آبیاری گیاه برای محاسبات ETc. "
'{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, '
'"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}'
),
),
),
]
@@ -1,24 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("plant", "0003_plant_irrigation_profile"),
]
operations = [
migrations.AddField(
model_name="plant",
name="growth_profile",
field=models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل رشد گیاه برای مدل GDD. "
'{"base_temperature": 10, "required_gdd_for_maturity": 1200, '
'"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}'
),
),
),
]
-49
View File
@@ -1,49 +0,0 @@
from django.db import models
class Plant(models.Model):
name = models.CharField(max_length=255, unique=True, db_index=True, help_text="نام گیاه")
light = models.CharField(max_length=255, blank=True, help_text="نور مورد نیاز")
watering = models.CharField(max_length=255, blank=True, help_text="آبیاری")
soil = models.CharField(max_length=255, blank=True, help_text="خاک مناسب")
temperature = models.CharField(max_length=255, blank=True, help_text="دمای مناسب")
planting_season = models.CharField(max_length=255, blank=True, help_text="فصل کاشت")
harvest_time = models.CharField(max_length=255, blank=True, help_text="زمان برداشت")
spacing = models.CharField(max_length=255, blank=True, help_text="فاصله کاشت")
fertilizer = models.CharField(max_length=255, blank=True, help_text="کود مناسب")
health_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل سلامت گیاه برای KPIها. ساختار نمونه: "
'{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}'
),
)
irrigation_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل آبیاری گیاه برای محاسبات ETc. "
'{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, '
'"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}'
),
)
growth_profile = models.JSONField(
blank=True,
default=dict,
help_text=(
"پروفایل رشد گیاه برای مدل GDD. "
'{"base_temperature": 10, "required_gdd_for_maturity": 1200, '
'"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}'
),
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "گیاه"
verbose_name_plural = "گیاهان"
ordering = ["name"]
def __str__(self):
return self.name
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class SensorCatalogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_catalog"
verbose_name = "Sensor Catalog"
@@ -0,0 +1,57 @@
from django.core.management.base import BaseCommand
from sensor_catalog.models import SensorCatalog
SENSOR_CATALOG_ITEMS = [
{
"name": "Sensor 7 - Soil Moisture Sensor v1.2",
"description": (
"This sensor is typically the YL-69 or FC-28 soil moisture sensor. "
"It measures only soil moisture and provides analog and digital outputs. "
"It does not report soil temperature, pH, or nutrients."
),
"customizable_fields": [],
"supported_power_sources": ["solar", "direct_power"],
"returned_data_fields": ["soil_moisture", "analog_output", "digital_output"],
"sample_payload": {
"soil_moisture": 42,
"analog_output": 610,
"digital_output": 1,
},
"is_active": True,
}
]
class Command(BaseCommand):
help = "Seed sensor catalog data."
def handle(self, *args, **options):
created_count = 0
updated_count = 0
for item in SENSOR_CATALOG_ITEMS:
sensor, created = SensorCatalog.objects.update_or_create(
name=item["name"],
defaults={
"description": item["description"],
"customizable_fields": item["customizable_fields"],
"supported_power_sources": item["supported_power_sources"],
"returned_data_fields": item["returned_data_fields"],
"sample_payload": item["sample_payload"],
"is_active": item["is_active"],
},
)
if created:
created_count += 1
self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}"))
else:
updated_count += 1
self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}"))
self.stdout.write(
self.style.SUCCESS(
f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}"
)
)
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 5.2.12 on 2026-03-20 00:00
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="SensorCatalog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("name", models.CharField(db_index=True, max_length=255, unique=True)),
("description", models.TextField(blank=True, default="")),
("customizable_fields", models.JSONField(blank=True, default=list)),
("returned_data_fields", models.JSONField(blank=True, default=list)),
("sample_payload", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "sensor_catalogs",
"ordering": ["name"],
},
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.12 on 2026-03-20 01:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sensor_catalog", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="sensorcatalog",
name="supported_power_sources",
field=models.JSONField(blank=True, default=list),
),
]
+23
View File
@@ -0,0 +1,23 @@
import uuid
from django.db import models
class SensorCatalog(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, default="")
customizable_fields = models.JSONField(default=list, blank=True)
supported_power_sources = models.JSONField(default=list, blank=True)
returned_data_fields = models.JSONField(default=list, blank=True)
sample_payload = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "sensor_catalogs"
ordering = ["name"]
def __str__(self):
return self.name
+19
View File
@@ -0,0 +1,19 @@
from rest_framework import serializers
from .models import SensorCatalog
class SensorCatalogSerializer(serializers.ModelSerializer):
class Meta:
model = SensorCatalog
fields = [
"uuid",
"name",
"description",
"customizable_fields",
"supported_power_sources",
"returned_data_fields",
"sample_payload",
"is_active",
]
read_only_fields = fields
+55
View File
@@ -0,0 +1,55 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from sensor_catalog.models import SensorCatalog
from sensor_catalog.views import SensorCatalogListView
class SensorCatalogListViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="sensor-user",
password="secret123",
email="sensor@example.com",
phone_number="09120000002",
)
SensorCatalog.objects.update_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={
"description": (
"Measures only soil moisture using electrical resistance between two metal probes. "
"Provides analog and digital outputs."
),
"customizable_fields": [],
"supported_power_sources": ["solar", "direct_power"],
"returned_data_fields": ["soil_moisture", "analog_output", "digital_output"],
"sample_payload": {"soil_moisture": 42, "analog_output": 610, "digital_output": 1},
"is_active": True,
},
)
SensorCatalog.objects.update_or_create(
name="Legacy Sensor",
defaults={
"customizable_fields": [],
"supported_power_sources": ["direct_power"],
"returned_data_fields": ["status"],
"sample_payload": {"status": "offline"},
"is_active": False,
},
)
def test_list_returns_all_existing_sensors(self):
request = self.factory.get("/api/sensor-catalog/")
force_authenticate(request, user=self.user)
response = SensorCatalogListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 2)
self.assertEqual(
{item["name"] for item in response.data["data"]},
{"Sensor 7 - Soil Moisture Sensor v1.2", "Legacy Sensor"},
)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import SensorCatalogListView
urlpatterns = [
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
]
+22
View File
@@ -0,0 +1,22 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from .models import SensorCatalog
from .serializers import SensorCatalogSerializer
class SensorCatalogListView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Sensor Catalog"],
responses={200: code_response("SensorCatalogListResponse", data=SensorCatalogSerializer(many=True))},
)
def get(self, request):
sensors = SensorCatalog.objects.order_by("name")
data = SensorCatalogSerializer(sensors, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)