diff --git a/FRONT_FARM_HUB_SENSOR_CATALOG_CHANGES.md b/FRONT_FARM_HUB_SENSOR_CATALOG_CHANGES.md new file mode 100644 index 0000000..f0ef20f --- /dev/null +++ b/FRONT_FARM_HUB_SENSOR_CATALOG_CHANGES.md @@ -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` استفاده شود diff --git a/config/settings.py b/config/settings.py index 3f6e483..f8bcfb4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -28,7 +28,7 @@ INSTALLED_APPS = [ "auth.apps.AuthConfig", "account.apps.AccountConfig", "farm_hub.apps.FarmHubConfig", - "plant.apps.PlantConfig", + "sensor_catalog.apps.SensorCatalogConfig", "dashboard", "crop_zoning", "plant_simulator", diff --git a/config/urls.py b/config/urls.py index d8e66f4..9f79a53 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("api/auth/", include("auth.urls")), path("api/account/", include("account.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/", include("dashboard.urls")), path("api/crop-zoning/", include("crop_zoning.urls")), diff --git a/farm_hub/catalog.py b/farm_hub/catalog.py new file mode 100644 index 0000000..762e945 --- /dev/null +++ b/farm_hub/catalog.py @@ -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": "بستر هیدروپونیک"}, + ], +} diff --git a/farm_hub/management/commands/seed_farm_catalog.py b/farm_hub/management/commands/seed_farm_catalog.py new file mode 100644 index 0000000..30cca88 --- /dev/null +++ b/farm_hub/management/commands/seed_farm_catalog.py @@ -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}." + ) + ) diff --git a/farm_hub/migrations/0002_seed_default_catalog.py b/farm_hub/migrations/0002_seed_default_catalog.py index 6bcf4d6..99c312b 100644 --- a/farm_hub/migrations/0002_seed_default_catalog.py +++ b/farm_hub/migrations/0002_seed_default_catalog.py @@ -2,9 +2,10 @@ from django.db import migrations FARM_TYPES = { - "زراعی": ["گندم", "ذرت"], - "درختی": ["سیب", "پسته"], + "زراعی": ["گندم", "ذرت", "جو", "کلزا", "پنبه"], + "درختی": ["سیب", "پسته", "انگور", "انار"], "غرقابی": ["برنج"], + "گلخانه ای": ["گوجه فرنگی", "خیار", "فلفل دلمه ای"], } diff --git a/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py b/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py new file mode 100644 index 0000000..603463d --- /dev/null +++ b/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py @@ -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", + ), + ), + ] diff --git a/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py b/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py new file mode 100644 index 0000000..950747e --- /dev/null +++ b/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py @@ -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", + ), + ), + ] diff --git a/farm_hub/migrations/0005_product_profiles_and_plant_migration.py b/farm_hub/migrations/0005_product_profiles_and_plant_migration.py new file mode 100644 index 0000000..9fa00c1 --- /dev/null +++ b/farm_hub/migrations/0005_product_profiles_and_plant_migration.py @@ -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), + ] diff --git a/farm_hub/migrations/0006_seed_expanded_product_catalog.py b/farm_hub/migrations/0006_seed_expanded_product_catalog.py new file mode 100644 index 0000000..5cfcd50 --- /dev/null +++ b/farm_hub/migrations/0006_seed_expanded_product_catalog.py @@ -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), + ] diff --git a/farm_hub/models.py b/farm_hub/models.py index ebfb2d6..e53e1e3 100644 --- a/farm_hub/models.py +++ b/farm_hub/models.py @@ -1,11 +1,12 @@ -import uuid +import uuid as uuid_lib from django.conf import settings from django.db import models +from sensor_catalog.models import SensorCatalog 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) description = models.TextField(blank=True, default="") metadata = models.JSONField(default=dict, blank=True) @@ -21,7 +22,7 @@ class FarmType(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( FarmType, on_delete=models.CASCADE, @@ -30,6 +31,40 @@ class Product(models.Model): name = models.CharField(max_length=255, db_index=True) description = models.TextField(blank=True, default="") 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) updated_at = models.DateTimeField(auto_now=True) @@ -45,7 +80,7 @@ class Product(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( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -58,7 +93,13 @@ class FarmHub(models.Model): ) name = models.CharField(max_length=255) 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) updated_at = models.DateTimeField(auto_now=True) products = models.ManyToManyField(Product, related_name="farms", blank=True) @@ -72,18 +113,25 @@ class FarmHub(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( FarmHub, on_delete=models.CASCADE, related_name="sensors", ) + sensor_catalog = models.ForeignKey( + SensorCatalog, + on_delete=models.PROTECT, + related_name="farm_sensors", + null=True, + blank=True, + ) + physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True) name = models.CharField(max_length=255) sensor_type = models.CharField(max_length=255, blank=True, default="") is_active = models.BooleanField(default=True) specifications = models.JSONField(default=dict, blank=True) power_source = models.JSONField(default=dict, blank=True) - customization = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/farm_hub/seeds.py b/farm_hub/seeds.py index 7ce9ec5..df2b538 100644 --- a/farm_hub/seeds.py +++ b/farm_hub/seeds.py @@ -3,7 +3,9 @@ import uuid from django.db import transaction 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 .services import dispatch_farm_zoning @@ -12,19 +14,10 @@ ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") ADMIN_FARM_DATA = { "name": "Admin Smart Farm", "is_active": True, - "customization": { - "irrigation": { - "mode": "smart", - "report_interval_sec": 300, - }, - "alerts": { - "sms": True, - "email": True, - "push": True, - }, - }, "sensors": [ { + "sensor_catalog_name": "Sensor 7 - Soil Moisture Sensor v1.2", + "physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222221"), "name": "Station 1", "sensor_type": "weather_station", "is_active": True, @@ -38,14 +31,10 @@ ADMIN_FARM_DATA = { "battery": {"capacity_mah": 12000, "voltage": 12}, "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", "sensor_type": "soil_probe", "is_active": True, @@ -53,13 +42,6 @@ ADMIN_FARM_DATA = { "capabilities": ["soil_moisture", "soil_temperature", "ph", "ec"], }, "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(): - farm_type, _ = FarmType.objects.get_or_create(name="زراعی") - wheat, _ = Product.objects.get_or_create(farm_type=farm_type, name="گندم") - corn, _ = Product.objects.get_or_create(farm_type=farm_type, name="ذرت") - return farm_type, [wheat, corn] + default_farm_type_name = "زراعی" + created_products = [] + + 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 @@ -99,12 +96,19 @@ def seed_admin_farm(): "farm_type": farm_type, "name": ADMIN_FARM_DATA["name"], "is_active": ADMIN_FARM_DATA["is_active"], - "customization": ADMIN_FARM_DATA["customization"], }, ) farm.products.set(products) 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: - 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 diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index fff348a..ba44743 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import FarmHub, FarmSensor, FarmType, Product +from sensor_catalog.models import SensorCatalog class FarmTypeSerializer(serializers.ModelSerializer): @@ -12,22 +13,40 @@ class FarmTypeSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.ModelSerializer): class Meta: 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): last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) class Meta: model = FarmSensor fields = [ "uuid", + "sensor_catalog_uuid", + "physical_device_uuid", "name", "sensor_type", "is_active", "specifications", "power_source", - "customization", "last_updated", ] read_only_fields = ["uuid", "last_updated"] @@ -38,14 +57,15 @@ class FarmHubSerializer(serializers.ModelSerializer): farm_type = FarmTypeSerializer(read_only=True) products = ProductSerializer(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: model = FarmHub fields = [ "farm_uuid", + "area_uuid", "name", "is_active", - "customization", "farm_type", "products", "sensors", @@ -55,17 +75,32 @@ class FarmHubSerializer(serializers.ModelSerializer): class FarmSensorWriteSerializer(serializers.ModelSerializer): + sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False) + class Meta: model = FarmSensor fields = [ + "sensor_catalog_uuid", + "physical_device_uuid", "name", "sensor_type", "is_active", "specifications", "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): area_geojson = serializers.JSONField(write_only=True, required=False) @@ -82,13 +117,18 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): fields = [ "name", "is_active", - "customization", "farm_type_uuid", "product_uuids", "sensors", "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): if not isinstance(value, dict): raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.") diff --git a/farm_hub/services.py b/farm_hub/services.py index 89e5d34..c64f937 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -1,21 +1,25 @@ 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): 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): - 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(): farm = serializer.save(owner=owner) - zoning_payload = None - - if area_feature is not None: - zoning_payload = dispatch_farm_zoning(area_feature, farm) + crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm) + farm.current_crop_area = crop_area + farm.save(update_fields=["current_crop_area", "updated_at"]) return farm, zoning_payload diff --git a/farm_hub/tests.py b/farm_hub/tests.py index df0e54a..18ee419 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -5,7 +5,8 @@ from rest_framework.test import APIRequestFactory, force_authenticate from crop_zoning.models import CropArea from farm_hub.models import FarmType, Product 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 = { @@ -40,22 +41,27 @@ class FarmListCreateViewTests(TestCase): ) self.farm_type, _ = FarmType.objects.get_or_create(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): + physical_device_uuid = "33333333-3333-3333-3333-333333333333" request = self.factory.post( "/api/farm-hub/", { "name": "farm-1", "farm_type_uuid": str(self.farm_type.uuid), "product_uuids": [str(self.wheat.uuid)], - "customization": {"report_interval_sec": 300}, "sensors": [ { + "sensor_catalog_uuid": str(self.weather_station.uuid), + "physical_device_uuid": physical_device_uuid, "name": "zone-sensor", "sensor_type": "weather_station", "specifications": {"model": "FH-1"}, "power_source": {"type": "battery"}, - "customization": {"report_interval_sec": 300}, } ], "area_geojson": AREA_GEOJSON, @@ -70,7 +76,10 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.data["code"], 201) self.assertEqual(response.data["data"]["name"], "farm-1") self.assertIn("zoning", response.data["data"]) + self.assertIsNotNone(response.data["data"]["area_uuid"]) 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.assertEqual( response.data["data"]["zoning"]["zone_count"], @@ -78,6 +87,48 @@ class FarmListCreateViewTests(TestCase): ) 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( USE_EXTERNAL_API_MOCK=True, @@ -85,14 +136,23 @@ class FarmListCreateViewTests(TestCase): ) class FarmSeedTests(TestCase): 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() self.assertTrue(created) self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111") self.assertEqual(CropArea.objects.count(), 1) 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): + 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() second_farm, second_created = seed_admin_farm() @@ -100,3 +160,58 @@ class FarmSeedTests(TestCase): self.assertFalse(second_created) self.assertEqual(first_farm.id, second_farm.id) 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.") diff --git a/farm_hub/urls.py b/farm_hub/urls.py index 0b9732d..6a9d697 100644 --- a/farm_hub/urls.py +++ b/farm_hub/urls.py @@ -1,10 +1,19 @@ from django.urls import path -from .views import FarmActiveView, FarmDeactiveView, FarmDetailView, FarmListCreateView +from .views import ( + FarmActiveView, + FarmDeactiveView, + FarmDetailView, + FarmListCreateView, + FarmTypeListView, + FarmTypeProductsView, +) urlpatterns = [ path("active/", FarmActiveView.as_view(), name="farm-hub-active"), path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"), + path("farm-types/", FarmTypeListView.as_view(), name="farm-type-list"), + path("farm-types//products/", FarmTypeProductsView.as_view(), name="farm-type-products"), path("/", FarmDetailView.as_view(), name="farm-hub-detail"), path("", FarmListCreateView.as_view(), name="farm-hub-list"), ] diff --git a/farm_hub/views.py b/farm_hub/views.py index ff36da8..297185a 100644 --- a/farm_hub/views.py +++ b/farm_hub/views.py @@ -6,8 +6,14 @@ from rest_framework.views import APIView from drf_spectacular.utils import extend_schema from config.swagger import code_response -from .models import FarmHub -from .serializers import FarmHubCreateSerializer, FarmHubSerializer, FarmToggleSerializer +from .models import FarmHub, FarmType, Product +from .serializers import ( + FarmHubCreateSerializer, + FarmHubSerializer, + FarmToggleSerializer, + FarmTypeSerializer, + ProductSerializer, +) from .services import create_farm_with_zoning @@ -16,7 +22,10 @@ class FarmHubBaseView(APIView): def _get_farm(self, request, farm_uuid): 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, owner=request.user, ) @@ -30,9 +39,10 @@ class FarmListCreateView(FarmHubBaseView): responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, ) 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", "sensors", + "sensors__sensor_catalog", ) data = FarmHubSerializer(farms, many=True).data 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) +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): @extend_schema( tags=["Farm Hub"], diff --git a/plant/apps.py b/plant/apps.py deleted file mode 100644 index b6db011..0000000 --- a/plant/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class PlantConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "plant" diff --git a/plant/migrations/0001_initial.py b/plant/migrations/0001_initial.py deleted file mode 100644 index 36f99a3..0000000 --- a/plant/migrations/0001_initial.py +++ /dev/null @@ -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'], - }, - ), - ] diff --git a/plant/migrations/0002_plant_health_profile.py b/plant/migrations/0002_plant_health_profile.py deleted file mode 100644 index c896745..0000000 --- a/plant/migrations/0002_plant_health_profile.py +++ /dev/null @@ -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}}' - ), - ), - ), - ] diff --git a/plant/migrations/0003_plant_irrigation_profile.py b/plant/migrations/0003_plant_irrigation_profile.py deleted file mode 100644 index 189c0ac..0000000 --- a/plant/migrations/0003_plant_irrigation_profile.py +++ /dev/null @@ -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}}' - ), - ), - ), - ] diff --git a/plant/migrations/0004_plant_growth_profile.py b/plant/migrations/0004_plant_growth_profile.py deleted file mode 100644 index 048e78e..0000000 --- a/plant/migrations/0004_plant_growth_profile.py +++ /dev/null @@ -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}' - ), - ), - ), - ] diff --git a/plant/models.py b/plant/models.py deleted file mode 100644 index 8302207..0000000 --- a/plant/models.py +++ /dev/null @@ -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 diff --git a/plant/__init__.py b/sensor_catalog/__init__.py similarity index 100% rename from plant/__init__.py rename to sensor_catalog/__init__.py diff --git a/sensor_catalog/apps.py b/sensor_catalog/apps.py new file mode 100644 index 0000000..f31c8a0 --- /dev/null +++ b/sensor_catalog/apps.py @@ -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" diff --git a/plant/migrations/__init__.py b/sensor_catalog/management/__init__.py similarity index 100% rename from plant/migrations/__init__.py rename to sensor_catalog/management/__init__.py diff --git a/sensor_catalog/management/commands/__init__.py b/sensor_catalog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_catalog/management/commands/seed_sensor_catalog.py b/sensor_catalog/management/commands/seed_sensor_catalog.py new file mode 100644 index 0000000..b2ce61f --- /dev/null +++ b/sensor_catalog/management/commands/seed_sensor_catalog.py @@ -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}" + ) + ) diff --git a/sensor_catalog/migrations/0001_initial.py b/sensor_catalog/migrations/0001_initial.py new file mode 100644 index 0000000..2635466 --- /dev/null +++ b/sensor_catalog/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py b/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py new file mode 100644 index 0000000..cc92124 --- /dev/null +++ b/sensor_catalog/migrations/0002_sensorcatalog_supported_power_sources.py @@ -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), + ), + ] diff --git a/sensor_catalog/migrations/__init__.py b/sensor_catalog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_catalog/models.py b/sensor_catalog/models.py new file mode 100644 index 0000000..f5bae9c --- /dev/null +++ b/sensor_catalog/models.py @@ -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 diff --git a/sensor_catalog/serializers.py b/sensor_catalog/serializers.py new file mode 100644 index 0000000..0ae2272 --- /dev/null +++ b/sensor_catalog/serializers.py @@ -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 diff --git a/sensor_catalog/tests.py b/sensor_catalog/tests.py new file mode 100644 index 0000000..a576919 --- /dev/null +++ b/sensor_catalog/tests.py @@ -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"}, + ) diff --git a/sensor_catalog/urls.py b/sensor_catalog/urls.py new file mode 100644 index 0000000..ae1647f --- /dev/null +++ b/sensor_catalog/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import SensorCatalogListView + +urlpatterns = [ + path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), +] diff --git a/sensor_catalog/views.py b/sensor_catalog/views.py new file mode 100644 index 0000000..d22123e --- /dev/null +++ b/sensor_catalog/views.py @@ -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)