UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FarmHubConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "farm_hub"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from farm_hub.seeds import seed_admin_farm
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create or update the default farm hub for the admin user."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
farm, created = seed_admin_farm()
|
||||
except ValueError as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
action = "created" if created else "updated"
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Admin farm {action}: farm_uuid={farm.farm_uuid}, name={farm.name}, owner={farm.owner.username}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-19 15:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FarmType",
|
||||
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="")),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_types",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FarmHub",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("farm_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("customization", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"farm_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="farms",
|
||||
to="farm_hub.farmtype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="farm_hubs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_hubs",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Product",
|
||||
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)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"farm_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="products",
|
||||
to="farm_hub.farmtype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "products",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FarmSensor",
|
||||
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(max_length=255)),
|
||||
("sensor_type", models.CharField(blank=True, default="", max_length=255)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("specifications", models.JSONField(blank=True, default=dict)),
|
||||
("power_source", models.JSONField(blank=True, default=dict)),
|
||||
("customization", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"farm",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sensors",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_sensors",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="farmhub",
|
||||
name="products",
|
||||
field=models.ManyToManyField(blank=True, related_name="farms", to="farm_hub.product"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="product",
|
||||
constraint=models.UniqueConstraint(fields=("farm_type", "name"), name="unique_product_per_farm_type"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
FARM_TYPES = {
|
||||
"زراعی": ["گندم", "ذرت"],
|
||||
"درختی": ["سیب", "پسته"],
|
||||
"غرقابی": ["برنج"],
|
||||
}
|
||||
|
||||
|
||||
def seed_catalog(apps, schema_editor):
|
||||
FarmType = apps.get_model("farm_hub", "FarmType")
|
||||
Product = apps.get_model("farm_hub", "Product")
|
||||
|
||||
for farm_type_name, products in FARM_TYPES.items():
|
||||
farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name)
|
||||
for product_name in products:
|
||||
Product.objects.get_or_create(farm_type=farm_type, name=product_name)
|
||||
|
||||
|
||||
def unseed_catalog(apps, schema_editor):
|
||||
FarmType = apps.get_model("farm_hub", "FarmType")
|
||||
FarmType.objects.filter(name__in=FARM_TYPES.keys()).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farm_hub", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_catalog, unseed_catalog),
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class FarmType(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="")
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_types"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm_type = models.ForeignKey(
|
||||
FarmType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="products",
|
||||
)
|
||||
name = models.CharField(max_length=255, db_index=True)
|
||||
description = models.TextField(blank=True, default="")
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "products"
|
||||
ordering = ["name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["farm_type", "name"], name="unique_product_per_farm_type"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class FarmHub(models.Model):
|
||||
farm_uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="farm_hubs",
|
||||
)
|
||||
farm_type = models.ForeignKey(
|
||||
FarmType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="farms",
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
is_active = models.BooleanField(default=True)
|
||||
customization = models.JSONField(default=dict, 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)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_hubs"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.farm_uuid})"
|
||||
|
||||
|
||||
class FarmSensor(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey(
|
||||
FarmHub,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sensors",
|
||||
)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_sensors"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.uuid})"
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Farm Hub",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"description": "Farm Hub API. GET list, GET by uuid, POST add, PATCH update, DELETE delete, POST active/deactive. Authenticated user required."
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "List farms",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/farm-hub/",
|
||||
"description": "Get farms for current user."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Success",
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": [\n {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ]\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get farm details",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/",
|
||||
"description": "Get one farm by farm uuid."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Success",
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Create farm",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"body": {"mode": "raw", "raw": "{\n \"name\": \"مزرعه شماره 1\",\n \"farm_type_uuid\": \"11111111-1111-1111-1111-111111111111\",\n \"product_uuids\": [\"22222222-2222-2222-2222-222222222222\"],\n \"customization\": {\"report_interval_sec\": 300},\n \"sensors\": [\n {\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300}\n }\n ]\n}"},
|
||||
"url": "{{baseUrl}}/api/farm-hub/",
|
||||
"description": "Create a farm with its sensors."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Update farm",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"body": {"mode": "raw", "raw": "{}"},
|
||||
"url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/",
|
||||
"description": "Update farm by farm uuid."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Delete farm",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/",
|
||||
"description": "Delete farm by farm uuid."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Activate farm",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"},
|
||||
"url": "{{baseUrl}}/api/farm-hub/active/",
|
||||
"description": "Activate one farm."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Deactivate farm",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{"key": "Content-Type", "value": "application/json"},
|
||||
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
|
||||
],
|
||||
"body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"},
|
||||
"url": "{{baseUrl}}/api/farm-hub/deactive/",
|
||||
"description": "Deactivate one farm."
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{"key": "baseUrl", "value": "http://localhost:8000"},
|
||||
{"key": "token", "value": ""},
|
||||
{"key": "farmUuid", "value": "550e8400-e29b-41d4-a716-446655440000"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from account.seeds import seed_admin_user
|
||||
|
||||
from .models import FarmHub, FarmType, Product
|
||||
from .services import dispatch_farm_zoning
|
||||
|
||||
|
||||
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": [
|
||||
{
|
||||
"name": "Station 1",
|
||||
"sensor_type": "weather_station",
|
||||
"is_active": True,
|
||||
"specifications": {
|
||||
"model": "CL-SENSE-PRO-X",
|
||||
"firmware": "2.4.1",
|
||||
"manufacturer": "CropLogic",
|
||||
},
|
||||
"power_source": {
|
||||
"type": "hybrid",
|
||||
"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},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Soil Probe 1",
|
||||
"sensor_type": "soil_probe",
|
||||
"is_active": True,
|
||||
"specifications": {
|
||||
"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},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ADMIN_FARM_AREA_GEOJSON = {
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.418934, 35.706815],
|
||||
[51.423054, 35.691062],
|
||||
[51.384258, 35.689389],
|
||||
[51.418934, 35.706815],
|
||||
]
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def seed_admin_farm():
|
||||
owner, _ = seed_admin_user()
|
||||
farm_type, products = _get_default_catalog()
|
||||
farm, created = FarmHub.objects.update_or_create(
|
||||
farm_uuid=ADMIN_FARM_UUID,
|
||||
defaults={
|
||||
"owner": owner,
|
||||
"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"]])
|
||||
if created:
|
||||
dispatch_farm_zoning(ADMIN_FARM_AREA_GEOJSON, farm)
|
||||
return farm, created
|
||||
@@ -0,0 +1,180 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import FarmHub, FarmSensor, FarmType, Product
|
||||
|
||||
|
||||
class FarmTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FarmType
|
||||
fields = ["uuid", "name", "description", "metadata"]
|
||||
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = ["uuid", "name", "description", "metadata"]
|
||||
|
||||
|
||||
class FarmSensorSerializer(serializers.ModelSerializer):
|
||||
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmSensor
|
||||
fields = [
|
||||
"uuid",
|
||||
"name",
|
||||
"sensor_type",
|
||||
"is_active",
|
||||
"specifications",
|
||||
"power_source",
|
||||
"customization",
|
||||
"last_updated",
|
||||
]
|
||||
read_only_fields = ["uuid", "last_updated"]
|
||||
|
||||
|
||||
class FarmHubSerializer(serializers.ModelSerializer):
|
||||
last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
|
||||
farm_type = FarmTypeSerializer(read_only=True)
|
||||
products = ProductSerializer(many=True, read_only=True)
|
||||
sensors = FarmSensorSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmHub
|
||||
fields = [
|
||||
"farm_uuid",
|
||||
"name",
|
||||
"is_active",
|
||||
"customization",
|
||||
"farm_type",
|
||||
"products",
|
||||
"sensors",
|
||||
"last_updated",
|
||||
]
|
||||
read_only_fields = ["farm_uuid", "last_updated"]
|
||||
|
||||
|
||||
class FarmSensorWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FarmSensor
|
||||
fields = [
|
||||
"name",
|
||||
"sensor_type",
|
||||
"is_active",
|
||||
"specifications",
|
||||
"power_source",
|
||||
"customization",
|
||||
]
|
||||
|
||||
|
||||
class FarmHubCreateSerializer(serializers.ModelSerializer):
|
||||
area_geojson = serializers.JSONField(write_only=True, required=False)
|
||||
farm_type_uuid = serializers.UUIDField(write_only=True)
|
||||
product_uuids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
write_only=True,
|
||||
allow_empty=False,
|
||||
)
|
||||
sensors = FarmSensorWriteSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = FarmHub
|
||||
fields = [
|
||||
"name",
|
||||
"is_active",
|
||||
"customization",
|
||||
"farm_type_uuid",
|
||||
"product_uuids",
|
||||
"sensors",
|
||||
"area_geojson",
|
||||
]
|
||||
|
||||
def validate_area_geojson(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("`area_geojson` must be a GeoJSON object.")
|
||||
|
||||
geometry = value.get("geometry") if value.get("type") == "Feature" else value
|
||||
if not isinstance(geometry, dict):
|
||||
raise serializers.ValidationError("`area_geojson.geometry` is required.")
|
||||
|
||||
if geometry.get("type") != "Polygon":
|
||||
raise serializers.ValidationError("`area_geojson.geometry.type` must be `Polygon`.")
|
||||
|
||||
coordinates = geometry.get("coordinates")
|
||||
if not isinstance(coordinates, list) or not coordinates or not isinstance(coordinates[0], list):
|
||||
raise serializers.ValidationError("`area_geojson.geometry.coordinates` must be a polygon ring.")
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
farm_type_uuid = attrs.get("farm_type_uuid")
|
||||
product_uuids = attrs.get("product_uuids")
|
||||
|
||||
if farm_type_uuid is None:
|
||||
if self.instance is None:
|
||||
raise serializers.ValidationError({"farm_type_uuid": ["This field is required."]})
|
||||
farm_type = self.instance.farm_type
|
||||
else:
|
||||
try:
|
||||
farm_type = FarmType.objects.get(uuid=farm_type_uuid)
|
||||
except FarmType.DoesNotExist as exc:
|
||||
raise serializers.ValidationError({"farm_type_uuid": ["Farm type not found."]}) from exc
|
||||
|
||||
if product_uuids is None:
|
||||
products = list(self.instance.products.all()) if self.instance is not None else []
|
||||
else:
|
||||
products = list(Product.objects.filter(uuid__in=product_uuids))
|
||||
if len(products) != len(product_uuids):
|
||||
raise serializers.ValidationError({"product_uuids": ["One or more products were not found."]})
|
||||
|
||||
invalid_products = [product.name for product in products if product.farm_type_id != farm_type.id]
|
||||
if invalid_products:
|
||||
raise serializers.ValidationError(
|
||||
{"product_uuids": [f"Products must belong to farm type `{farm_type.name}`."]}
|
||||
)
|
||||
|
||||
attrs["farm_type"] = farm_type
|
||||
attrs["products"] = products
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop("area_geojson", None)
|
||||
sensors_data = validated_data.pop("sensors", [])
|
||||
products = validated_data.pop("products", [])
|
||||
validated_data["farm_type"] = validated_data.pop("farm_type")
|
||||
validated_data.pop("farm_type_uuid", None)
|
||||
validated_data.pop("product_uuids", None)
|
||||
|
||||
farm = super().create(validated_data)
|
||||
if products:
|
||||
farm.products.set(products)
|
||||
if sensors_data:
|
||||
FarmSensor.objects.bulk_create([FarmSensor(farm=farm, **sensor_data) for sensor_data in sensors_data])
|
||||
return farm
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data.pop("area_geojson", None)
|
||||
sensors_data = validated_data.pop("sensors", None)
|
||||
products = validated_data.pop("products", None)
|
||||
farm_type = validated_data.pop("farm_type", None)
|
||||
validated_data.pop("farm_type_uuid", None)
|
||||
validated_data.pop("product_uuids", None)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
if farm_type is not None:
|
||||
instance.farm_type = farm_type
|
||||
instance.save()
|
||||
|
||||
if products is not None:
|
||||
instance.products.set(products)
|
||||
if sensors_data is not None:
|
||||
instance.sensors.all().delete()
|
||||
if sensors_data:
|
||||
FarmSensor.objects.bulk_create([FarmSensor(farm=instance, **sensor_data) for sensor_data in sensors_data])
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class FarmToggleSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField()
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.db import transaction
|
||||
|
||||
from crop_zoning.services import create_zones_and_dispatch, 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)
|
||||
|
||||
|
||||
def create_farm_with_zoning(serializer, owner):
|
||||
area_feature = serializer.validated_data.pop("area_geojson", None)
|
||||
|
||||
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)
|
||||
|
||||
return farm, zoning_payload
|
||||
@@ -0,0 +1,102 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
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
|
||||
|
||||
|
||||
AREA_GEOJSON = {
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[51.418934, 35.706815],
|
||||
[51.423054, 35.691062],
|
||||
[51.384258, 35.689389],
|
||||
[51.418934, 35.706815],
|
||||
]
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
USE_EXTERNAL_API_MOCK=True,
|
||||
CROP_ZONE_CHUNK_AREA_SQM=200000,
|
||||
)
|
||||
class FarmListCreateViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="farmer",
|
||||
password="secret123",
|
||||
email="farmer@example.com",
|
||||
phone_number="09120000000",
|
||||
)
|
||||
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
|
||||
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم")
|
||||
|
||||
def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self):
|
||||
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": [
|
||||
{
|
||||
"name": "zone-sensor",
|
||||
"sensor_type": "weather_station",
|
||||
"specifications": {"model": "FH-1"},
|
||||
"power_source": {"type": "battery"},
|
||||
"customization": {"report_interval_sec": 300},
|
||||
}
|
||||
],
|
||||
"area_geojson": AREA_GEOJSON,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = FarmListCreateView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["code"], 201)
|
||||
self.assertEqual(response.data["data"]["name"], "farm-1")
|
||||
self.assertIn("zoning", response.data["data"])
|
||||
self.assertEqual(len(response.data["data"]["sensors"]), 1)
|
||||
self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1)
|
||||
self.assertEqual(
|
||||
response.data["data"]["zoning"]["zone_count"],
|
||||
CropArea.objects.get().zone_count,
|
||||
)
|
||||
self.assertEqual(CropArea.objects.count(), 1)
|
||||
|
||||
|
||||
@override_settings(
|
||||
USE_EXTERNAL_API_MOCK=True,
|
||||
CROP_ZONE_CHUNK_AREA_SQM=200000,
|
||||
)
|
||||
class FarmSeedTests(TestCase):
|
||||
def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self):
|
||||
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)
|
||||
|
||||
def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self):
|
||||
first_farm, first_created = seed_admin_farm()
|
||||
second_farm, second_created = seed_admin_farm()
|
||||
|
||||
self.assertTrue(first_created)
|
||||
self.assertFalse(second_created)
|
||||
self.assertEqual(first_farm.id, second_farm.id)
|
||||
self.assertEqual(CropArea.objects.count(), 1)
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import FarmActiveView, FarmDeactiveView, FarmDetailView, FarmListCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path("active/", FarmActiveView.as_view(), name="farm-hub-active"),
|
||||
path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"),
|
||||
path("<uuid:farm_uuid>/", FarmDetailView.as_view(), name="farm-hub-detail"),
|
||||
path("", FarmListCreateView.as_view(), name="farm-hub-list"),
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from rest_framework import serializers, 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 FarmHub
|
||||
from .serializers import FarmHubCreateSerializer, FarmHubSerializer, FarmToggleSerializer
|
||||
from .services import create_farm_with_zoning
|
||||
|
||||
|
||||
class FarmHubBaseView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _get_farm(self, request, farm_uuid):
|
||||
try:
|
||||
return FarmHub.objects.prefetch_related("products", "sensors").select_related("farm_type").get(
|
||||
farm_uuid=farm_uuid,
|
||||
owner=request.user,
|
||||
)
|
||||
except FarmHub.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class FarmListCreateView(FarmHubBaseView):
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
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(
|
||||
"products",
|
||||
"sensors",
|
||||
)
|
||||
data = FarmHubSerializer(farms, many=True).data
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
request=FarmHubCreateSerializer,
|
||||
responses={201: code_response("FarmCreateResponse", data=FarmHubSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = FarmHubCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc
|
||||
except ImproperlyConfigured as exc:
|
||||
return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
data = FarmHubSerializer(farm).data
|
||||
if zoning_payload is not None:
|
||||
data["zoning"] = zoning_payload
|
||||
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FarmDetailView(FarmHubBaseView):
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
responses={
|
||||
200: code_response("FarmDetailResponse", data=FarmHubSerializer()),
|
||||
404: code_response("FarmNotFoundResponse"),
|
||||
},
|
||||
)
|
||||
def get(self, request, farm_uuid):
|
||||
farm = self._get_farm(request, farm_uuid)
|
||||
if farm is None:
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
data = FarmHubSerializer(farm).data
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
request=FarmHubCreateSerializer,
|
||||
responses={
|
||||
200: code_response("FarmUpdateResponse", data=FarmHubSerializer()),
|
||||
404: code_response("FarmUpdateNotFoundResponse"),
|
||||
},
|
||||
)
|
||||
def patch(self, request, farm_uuid):
|
||||
farm = self._get_farm(request, farm_uuid)
|
||||
if farm is None:
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
farm.refresh_from_db()
|
||||
data = FarmHubSerializer(farm).data
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
responses={
|
||||
200: code_response("FarmDeleteResponse"),
|
||||
404: code_response("FarmDeleteNotFoundResponse"),
|
||||
},
|
||||
)
|
||||
def delete(self, request, farm_uuid):
|
||||
farm = self._get_farm(request, farm_uuid)
|
||||
if farm is None:
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
farm.delete()
|
||||
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class FarmToggleView(FarmHubBaseView):
|
||||
action = None
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm Hub"],
|
||||
request=FarmToggleSerializer,
|
||||
responses={
|
||||
200: code_response("FarmToggleResponse"),
|
||||
400: code_response("FarmToggleValidationResponse"),
|
||||
404: code_response("FarmToggleNotFoundResponse"),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = FarmToggleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
|
||||
if farm is None:
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
farm.is_active = self.action == "active"
|
||||
farm.save(update_fields=["is_active", "updated_at"])
|
||||
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class FarmActiveView(FarmToggleView):
|
||||
action = "active"
|
||||
|
||||
|
||||
class FarmDeactiveView(FarmToggleView):
|
||||
action = "deactive"
|
||||
Reference in New Issue
Block a user