UPDATE
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class PlantConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "plant"
|
|
||||||
@@ -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}'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import SensorCatalogListView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user