This commit is contained in:
2026-04-02 23:25:39 +03:30
parent 881f8efa4d
commit bd0d04256c
84 changed files with 2725 additions and 856 deletions
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FarmHubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "farm_hub"
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -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}"
)
)
+125
View File
@@ -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),
]
View File
+95
View File
@@ -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})"
+117
View File
@@ -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"}
]
}
+110
View File
@@ -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
+180
View File
@@ -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()
+21
View File
@@ -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
+102
View File
@@ -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)
+10
View File
@@ -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"),
]
+139
View File
@@ -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"