UPDATE
This commit is contained in:
@@ -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 = {
|
||||
"زراعی": ["گندم", "ذرت"],
|
||||
"درختی": ["سیب", "پسته"],
|
||||
"زراعی": ["گندم", "ذرت", "جو", "کلزا", "پنبه"],
|
||||
"درختی": ["سیب", "پسته", "انگور", "انار"],
|
||||
"غرقابی": ["برنج"],
|
||||
"گلخانه ای": ["گوجه فرنگی", "خیار", "فلفل دلمه ای"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
|
||||
+35
-31
@@ -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
|
||||
|
||||
+45
-5
@@ -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.")
|
||||
|
||||
+11
-7
@@ -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
|
||||
|
||||
+118
-3
@@ -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.")
|
||||
|
||||
+10
-1
@@ -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/<uuid:farm_type_uuid>/products/", FarmTypeProductsView.as_view(), name="farm-type-products"),
|
||||
path("<uuid:farm_uuid>/", FarmDetailView.as_view(), name="farm-hub-detail"),
|
||||
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 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"],
|
||||
|
||||
Reference in New Issue
Block a user