This commit is contained in:
2026-03-29 15:07:14 +03:30
parent 24cb87d94e
commit 9323000bac
17 changed files with 1213 additions and 232 deletions
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)
+9
View File
@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
+8
View File
@@ -161,3 +161,11 @@ SIMPLE_JWT = {
} }
CROP_ZONE_CHUNK_AREA_SQM = float(os.getenv("CROP_ZONE_CHUNK_AREA_SQM", "10000")) CROP_ZONE_CHUNK_AREA_SQM = float(os.getenv("CROP_ZONE_CHUNK_AREA_SQM", "10000"))
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "120"))
CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "90"))
@@ -0,0 +1,99 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("crop_zoning", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="CropProduct",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("product_id", models.CharField(max_length=64, unique=True)),
("label", models.CharField(max_length=255)),
("color", models.CharField(max_length=32)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "crop_products",
"ordering": ["id"],
},
),
migrations.CreateModel(
name="CropZoneCultivationRiskLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="cultivation_risk_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_cultivation_risk_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneRecommendation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("match_percent", models.PositiveIntegerField()),
("water_need", models.CharField(max_length=128)),
("estimated_profit", models.CharField(max_length=128)),
("reason", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="recommendation", to="crop_zoning.cropzone")),
("product", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="zone_recommendations", to="crop_zoning.cropproduct")),
],
options={
"db_table": "crop_zone_recommendations",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneSoilQualityLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("score", models.PositiveIntegerField()),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="soil_quality_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_soil_quality_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneWaterNeedLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("value", models.CharField(max_length=128)),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="water_need_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_water_need_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneCriteria",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=128)),
("value", models.PositiveIntegerField()),
("sequence", models.PositiveIntegerField(default=0)),
("recommendation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="criteria", to="crop_zoning.cropzonerecommendation")),
],
options={
"db_table": "crop_zone_criteria",
"ordering": ["sequence", "id"],
},
),
]
@@ -0,0 +1,49 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("crop_zoning", "0002_crop_zoning_mock_schema"),
]
operations = [
migrations.AddField(
model_name="cropzone",
name="processing_error",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="cropzone",
name="processing_status",
field=models.CharField(
choices=[("pending", "Pending"), ("processing", "Processing"), ("completed", "Completed"), ("failed", "Failed")],
default="pending",
max_length=16,
),
),
migrations.AddField(
model_name="cropzone",
name="task_id",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.CreateModel(
name="CropZoneAnalysis",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("source", models.CharField(blank=True, default="", max_length=64)),
("external_record_id", models.CharField(blank=True, default="", max_length=64)),
("latitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
("longitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
("raw_response", models.JSONField(blank=True, default=dict)),
("depths", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="analysis", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_analyses",
"ordering": ["crop_zone_id"],
},
),
]
+168
View File
@@ -24,6 +24,17 @@ class CropArea(models.Model):
class CropZone(models.Model): class CropZone(models.Model):
STATUS_PENDING = "pending"
STATUS_PROCESSING = "processing"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CHOICES = (
(STATUS_PENDING, "Pending"),
(STATUS_PROCESSING, "Processing"),
(STATUS_COMPLETED, "Completed"),
(STATUS_FAILED, "Failed"),
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
crop_area = models.ForeignKey( crop_area = models.ForeignKey(
CropArea, CropArea,
@@ -37,6 +48,9 @@ class CropZone(models.Model):
area_sqm = models.FloatField() area_sqm = models.FloatField()
area_hectares = models.FloatField() area_hectares = models.FloatField()
sequence = models.PositiveIntegerField() sequence = models.PositiveIntegerField()
processing_status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
processing_error = models.TextField(blank=True, default="")
task_id = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -49,3 +63,157 @@ class CropZone(models.Model):
def __str__(self): def __str__(self):
return self.zone_id return self.zone_id
class CropProduct(models.Model):
product_id = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=255)
color = models.CharField(max_length=32)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_products"
ordering = ["id"]
def __str__(self):
return self.label
class CropZoneRecommendation(models.Model):
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="recommendation",
)
product = models.ForeignKey(
CropProduct,
on_delete=models.PROTECT,
related_name="zone_recommendations",
)
match_percent = models.PositiveIntegerField()
water_need = models.CharField(max_length=128)
estimated_profit = models.CharField(max_length=128)
reason = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zone_recommendations"
ordering = ["crop_zone_id"]
def __str__(self):
return f"{self.crop_zone.zone_id} -> {self.product.product_id}"
class CropZoneCriteria(models.Model):
recommendation = models.ForeignKey(
CropZoneRecommendation,
on_delete=models.CASCADE,
related_name="criteria",
)
name = models.CharField(max_length=128)
value = models.PositiveIntegerField()
sequence = models.PositiveIntegerField(default=0)
class Meta:
db_table = "crop_zone_criteria"
ordering = ["sequence", "id"]
def __str__(self):
return f"{self.name}: {self.value}"
class CropZoneWaterNeedLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="water_need_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
value = models.CharField(max_length=128)
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_water_need_layers"
ordering = ["crop_zone_id"]
class CropZoneSoilQualityLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="soil_quality_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
score = models.PositiveIntegerField()
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_soil_quality_layers"
ordering = ["crop_zone_id"]
class CropZoneCultivationRiskLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="cultivation_risk_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_cultivation_risk_layers"
ordering = ["crop_zone_id"]
class CropZoneAnalysis(models.Model):
source = models.CharField(max_length=64, blank=True, default="")
external_record_id = models.CharField(max_length=64, blank=True, default="")
latitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)
raw_response = models.JSONField(default=dict, blank=True)
depths = models.JSONField(default=list, blank=True)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="analysis",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zone_analyses"
ordering = ["crop_zone_id"]
+389 -18
View File
@@ -1,12 +1,28 @@
import math import math
from copy import deepcopy from copy import deepcopy
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Prefetch
from .models import CropArea, CropZone from external_api_adapter.adapter import request as external_request
from .mock_data import AREA_RESPONSE_DATA, PRODUCTS_RESPONSE_DATA
from .models import (
CropArea,
CropProduct,
CropZone,
CropZoneAnalysis,
CropZoneCriteria,
CropZoneCultivationRiskLayer,
CropZoneRecommendation,
CropZoneSoilQualityLayer,
CropZoneWaterNeedLayer,
)
EARTH_RADIUS_METERS = 6378137.0 EARTH_RADIUS_METERS = 6378137.0
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
def get_chunk_area_sqm(): def get_chunk_area_sqm():
@@ -20,6 +36,60 @@ def get_chunk_area_sqm():
return chunk_area return chunk_area
def get_default_area_feature():
return deepcopy(AREA_RESPONSE_DATA["area"])
def normalize_area_feature(area_feature):
if area_feature is None:
raise ValueError("Area polygon coordinates are required.")
if not isinstance(area_feature, dict):
raise ValueError("Area GeoJSON must be an object.")
if area_feature.get("type") == "Feature":
geometry = deepcopy(area_feature.get("geometry") or {})
normalized_feature = {
"type": "Feature",
"properties": deepcopy(area_feature.get("properties") or {}),
"geometry": geometry,
}
else:
normalized_feature = {
"type": "Feature",
"properties": {},
"geometry": deepcopy(area_feature),
}
geometry = normalized_feature.get("geometry") or {}
if geometry.get("type") != "Polygon":
raise ValueError("Area GeoJSON geometry type must be Polygon.")
ring = get_polygon_ring(normalized_feature)
if len(ring) < 4:
raise ValueError("Area polygon must contain at least four coordinates.")
return normalized_feature
def ensure_products_exist():
for product in PRODUCT_DEFAULTS:
CropProduct.objects.update_or_create(
product_id=product["id"],
defaults={"label": product["label"], "color": product["color"]},
)
def get_products_payload():
ensure_products_exist()
products = CropProduct.objects.order_by("id")
return {
"products": [
{"id": product.product_id, "label": product.label, "color": product.color}
for product in products
]
}
def get_polygon_ring(area_feature): def get_polygon_ring(area_feature):
geometry = (area_feature or {}).get("geometry", {}) geometry = (area_feature or {}).get("geometry", {})
coordinates = geometry.get("coordinates", []) coordinates = geometry.get("coordinates", [])
@@ -116,18 +186,8 @@ def split_area_into_zones(area_feature):
} }
zone_points = build_zone_square(area_points, zone_center, zone_area_sqm) zone_points = build_zone_square(area_points, zone_center, zone_area_sqm)
zone_geometry = { zone_geometry = {
"type": "Feature",
"properties": {
"zone_id": f"zone-{sequence}",
"sequence": sequence,
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"center": zone_center,
},
"geometry": {
"type": "Polygon", "type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]], "coordinates": [[*zone_points, zone_points[0]]],
},
} }
zones.append( zones.append(
{ {
@@ -142,7 +202,11 @@ def split_area_into_zones(area_feature):
) )
remaining_area = max(0.0, remaining_area - zone_area_sqm) remaining_area = max(0.0, remaining_area - zone_area_sqm)
area_geometry = deepcopy(area_feature) area_geometry = {
"type": "Feature",
"properties": {},
"geometry": deepcopy(area_feature.get("geometry", {})),
}
area_geometry.setdefault("properties", {}) area_geometry.setdefault("properties", {})
area_geometry["properties"].update( area_geometry["properties"].update(
{ {
@@ -166,7 +230,221 @@ def split_area_into_zones(area_feature):
} }
def persist_zones(area_feature): def build_initial_zone_payload(zone):
recommendation = getattr(zone, "recommendation", None)
return {
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
"estimatedProfit": recommendation.estimated_profit if recommendation else "",
}
def _get_level_color_map(layer_name, level):
mappings = {
"water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"},
"soil": {"low": "#ef4444", "medium": "#eab308", "high": "#22c55e"},
"risk": {"low": "#22c55e", "medium": "#f59e0b", "high": "#ef4444"},
}
return mappings[layer_name][level]
def _pick_level(score, low_threshold, high_threshold):
if score >= high_threshold:
return "high"
if score >= low_threshold:
return "medium"
return "low"
def _format_range(start, end, suffix):
return f"{start}-{end} {suffix}"
def _derive_analysis_metrics(depths):
if not depths:
return {
"soil_quality_score": 0,
"soil_level": "low",
"water_need_level": "high",
"water_need_value": "0-0 m³/ha",
"cultivation_risk_level": "high",
"recommended_crop": PRODUCT_DEFAULTS[0]["id"],
"match_percent": 0,
"estimated_profit": "0-0 میلیون/هکتار",
"reason": "داده تحلیل خاک موجود نیست",
"criteria": [],
}
avg_ph = sum(item.get("phh2o", 0) for item in depths) / len(depths)
avg_soc = sum(item.get("soc", 0) for item in depths) / len(depths)
avg_clay = sum(item.get("clay", 0) for item in depths) / len(depths)
avg_nitrogen = sum(item.get("nitrogen", 0) for item in depths) / len(depths)
avg_wv0033 = sum(item.get("wv0033", 0) for item in depths) / len(depths)
soil_quality_score = max(0, min(100, round((avg_soc * 20) + (avg_nitrogen * 120) + (avg_wv0033 * 120) + (20 - abs(avg_ph - 7) * 10))))
soil_level = _pick_level(soil_quality_score, 50, 80)
water_base = round(3000 + (avg_clay * 70))
water_need_value = _format_range(water_base, water_base + 1000, "m³/ha")
water_need_level = "low" if water_base < 4000 else "medium" if water_base < 5000 else "high"
cultivation_risk_score = max(0, min(100, round(100 - soil_quality_score + abs(avg_ph - 7) * 8)))
cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 55 else "high"
if water_need_level == "low" and soil_quality_score >= 85:
recommended_crop = "saffron"
estimated_profit = "۵۰-۱۵۰ میلیون/هکتار"
elif soil_quality_score >= 70:
recommended_crop = "wheat"
estimated_profit = "۱۵-۲۵ میلیون/هکتار"
else:
recommended_crop = "canola"
estimated_profit = "۲۰-۳۵ میلیون/هکتار"
match_percent = max(1, min(100, round((soil_quality_score * 0.55) + ((100 - cultivation_risk_score) * 0.45))))
reason = "خاک و شرایط رطوبتی این زون برای محصول پیشنهادی مناسب ارزیابی شده است"
criteria = [
{"name": "دما", "value": max(1, min(100, round(70 + (avg_ph - 6.5) * 10)))},
{"name": "بارش", "value": max(1, min(100, round(60 + avg_wv0033 * 100)))},
{"name": "خاک", "value": soil_quality_score},
{"name": "آب", "value": max(1, min(100, round(100 - ((water_base - 3000) / 30))))},
]
return {
"soil_quality_score": soil_quality_score,
"soil_level": soil_level,
"water_need_level": water_need_level,
"water_need_value": water_need_value,
"cultivation_risk_level": cultivation_risk_level,
"recommended_crop": recommended_crop,
"match_percent": match_percent,
"estimated_profit": estimated_profit,
"reason": reason,
"criteria": criteria,
}
def fetch_soil_data_for_zone(zone):
center = zone.center or calculate_center(zone.points)
payload = {
"lon": center["longitude"],
"lat": center["latitude"],
"zone": {
"id": zone.zone_id,
"geometry": zone.geometry,
"center": center,
"area_sqm": zone.area_sqm,
"area_hectares": zone.area_hectares,
},
}
return external_request("ai", "/soil-data", method="POST", payload=payload).data
def analyze_and_store_zone_soil_data(zone_id):
ensure_products_exist()
zone = CropZone.objects.select_related("crop_area").get(id=zone_id)
if zone.processing_status == CropZone.STATUS_COMPLETED:
return zone
zone.processing_status = CropZone.STATUS_PROCESSING
zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
try:
adapter_data = fetch_soil_data_for_zone(zone)
soil_data = adapter_data.get("data", {}) if isinstance(adapter_data, dict) else {}
depths = soil_data.get("depths", [])
metrics = _derive_analysis_metrics(depths)
product = CropProduct.objects.get(product_id=metrics["recommended_crop"])
CropZoneAnalysis.objects.update_or_create(
crop_zone=zone,
defaults={
"source": soil_data.get("source", ""),
"external_record_id": str(soil_data.get("id", "")),
"longitude": Decimal(str(soil_data.get("lon", zone.center.get("longitude", 0)))),
"latitude": Decimal(str(soil_data.get("lat", zone.center.get("latitude", 0)))),
"raw_response": adapter_data if isinstance(adapter_data, dict) else {},
"depths": depths,
},
)
recommendation, _ = CropZoneRecommendation.objects.update_or_create(
crop_zone=zone,
defaults={
"product": product,
"match_percent": metrics["match_percent"],
"water_need": metrics["water_need_value"],
"estimated_profit": metrics["estimated_profit"],
"reason": metrics["reason"],
},
)
CropZoneCriteria.objects.filter(recommendation=recommendation).delete()
CropZoneCriteria.objects.bulk_create(
[
CropZoneCriteria(
recommendation=recommendation,
name=item["name"],
value=item["value"],
sequence=index,
)
for index, item in enumerate(metrics["criteria"])
]
)
CropZoneWaterNeedLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["water_need_level"],
"value": metrics["water_need_value"],
"color": _get_level_color_map("water", metrics["water_need_level"]),
},
)
CropZoneSoilQualityLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["soil_level"],
"score": metrics["soil_quality_score"],
"color": _get_level_color_map("soil", metrics["soil_level"]),
},
)
CropZoneCultivationRiskLayer.objects.update_or_create(
crop_zone=zone,
defaults={
"level": metrics["cultivation_risk_level"],
"color": _get_level_color_map("risk", metrics["cultivation_risk_level"]),
},
)
zone.processing_status = CropZone.STATUS_COMPLETED
zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
except Exception as exc:
zone.processing_status = CropZone.STATUS_FAILED
zone.processing_error = str(exc)
zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
raise
return zone
def dispatch_zone_processing_tasks(crop_area_id):
from .tasks import process_zone_soil_data
zones = list(CropZone.objects.filter(crop_area_id=crop_area_id).only("id"))
for zone in zones:
task_identifier = ""
try:
async_result = process_zone_soil_data.delay(zone.id)
task_identifier = getattr(async_result, "id", "") or ""
except Exception:
analyze_and_store_zone_soil_data(zone_id=zone.id)
CropZone.objects.filter(id=zone.id).update(task_id=task_identifier)
def create_zones_and_dispatch(area_feature):
ensure_products_exist()
area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature) zoning_result = split_area_into_zones(area_feature)
area_data = zoning_result["area"] area_data = zoning_result["area"]
@@ -180,8 +458,7 @@ def persist_zones(area_feature):
chunk_area_sqm=round(area_data["chunk_area_sqm"], 2), chunk_area_sqm=round(area_data["chunk_area_sqm"], 2),
zone_count=area_data["zone_count"], zone_count=area_data["zone_count"],
) )
zones = CropZone.objects.bulk_create(
CropZone.objects.bulk_create(
[ [
CropZone( CropZone(
crop_area=crop_area, crop_area=crop_area,
@@ -197,6 +474,100 @@ def persist_zones(area_feature):
] ]
) )
zoning_result["area"]["id"] = crop_area.id crop_area.refresh_from_db()
zoning_result["area"]["uuid"] = str(crop_area.uuid) dispatch_zone_processing_tasks(crop_area.id)
return zoning_result return crop_area, zones
def _zones_queryset(zone_ids=None):
queryset = CropZone.objects.select_related(
"recommendation__product",
"water_need_layer",
"soil_quality_layer",
"cultivation_risk_layer",
).prefetch_related(
Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id"))
).order_by("sequence", "id")
if zone_ids:
queryset = queryset.filter(zone_id__in=zone_ids)
return queryset
def get_latest_area_payload():
area = CropArea.objects.order_by("-created_at", "-id").first()
if area:
return {"area": area.geometry}
return {"area": get_default_area_feature()}
def get_initial_zones_payload(crop_area):
zones = _zones_queryset().filter(crop_area=crop_area)
return {
"total_area_hectares": crop_area.area_hectares,
"total_area_sqm": crop_area.area_sqm,
"zone_count": crop_area.zone_count,
"zones": [build_initial_zone_payload(zone) for zone in zones],
}
def get_water_need_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.water_need_layer, "level", ""),
"value": getattr(zone.water_need_layer, "value", ""),
"color": getattr(zone.water_need_layer, "color", ""),
}
for zone in zones
]
}
def get_soil_quality_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.soil_quality_layer, "level", ""),
"score": getattr(zone.soil_quality_layer, "score", 0),
"color": getattr(zone.soil_quality_layer, "color", ""),
}
for zone in zones
]
}
def get_cultivation_risk_payload(zone_ids=None):
zones = _zones_queryset(zone_ids)
return {
"zones": [
{
"zoneId": zone.zone_id,
"geometry": zone.geometry,
"level": getattr(zone.cultivation_risk_layer, "level", ""),
"color": getattr(zone.cultivation_risk_layer, "color", ""),
}
for zone in zones
]
}
def get_zone_details_payload(zone_id):
zone = _zones_queryset().get(zone_id=zone_id)
recommendation = getattr(zone, "recommendation", None)
criteria = recommendation.criteria.all() if recommendation else []
return {
"zoneId": zone.zone_id,
"crop": recommendation.product.product_id if recommendation else "",
"matchPercent": recommendation.match_percent if recommendation else 0,
"waterNeed": recommendation.water_need if recommendation else "",
"estimatedProfit": recommendation.estimated_profit if recommendation else "",
"reason": recommendation.reason if recommendation else "",
"criteria": [{"name": item.name, "value": item.value} for item in criteria],
"area_hectares": zone.area_hectares,
}
+17
View File
@@ -0,0 +1,17 @@
from celery import shared_task
from django.db import transaction
from .services import analyze_and_store_zone_soil_data
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
)
def process_zone_soil_data(self, zone_id):
with transaction.atomic():
analyze_and_store_zone_soil_data(zone_id=zone_id)
return {"zone_id": zone_id, "status": "processed"}
+47
View File
@@ -0,0 +1,47 @@
from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory
from crop_zoning.views import ZonesInitialView
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 ZonesInitialViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
def test_post_accepts_area_geojson_alias(self):
request = self.factory.post(
"/api/crop-zoning/zones/initial/",
{"area_geojson": AREA_GEOJSON},
format="json",
)
response = ZonesInitialView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertGreater(response.data["data"]["zone_count"], 1)
self.assertEqual(
response.data["data"]["zone_count"],
len(response.data["data"]["zones"]),
)
+31 -209
View File
@@ -1,11 +1,5 @@
"""
Crop Zoning API views.
No database. All responses are static mock data.
Response format: {"status": "success", "data": <payload>}. HTTP 200 only.
No processing, validation, or use of input parameters in responses.
"""
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -13,271 +7,99 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from config.swagger import status_response from config.swagger import status_response
from .mock_data import ( from .services import (
AREA_RESPONSE_DATA, create_zones_and_dispatch,
PRODUCTS_RESPONSE_DATA, get_cultivation_risk_payload,
ZONE_DETAILS_BY_ID, get_default_area_feature,
ZONES_CULTIVATION_RISK_RESPONSE_DATA, get_initial_zones_payload,
ZONES_INITIAL_RESPONSE_DATA, get_latest_area_payload,
ZONES_SOIL_QUALITY_RESPONSE_DATA, get_products_payload,
ZONES_WATER_NEED_RESPONSE_DATA, get_soil_quality_payload,
get_water_need_payload,
get_zone_details_payload,
) )
from .services import persist_zones
class AreaView(APIView): class AreaView(APIView):
"""
GET endpoint for fixed land area (GeoJSON polygon).
Purpose:
Returns static land area polygon for display on map. User cannot
draw or edit the region; it is loaded from backend.
Input parameters:
None.
Response structure:
- status: string, always "success".
- data: object with key "area" (GeoJSON Feature with Polygon geometry).
No processing or validation is performed on inputs.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
return Response( return Response({"status": "success", "data": get_latest_area_payload()}, status=status.HTTP_200_OK)
{"status": "success", "data": AREA_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
class ProductsView(APIView): class ProductsView(APIView):
"""
GET endpoint for list of crop products and colors.
Purpose:
Returns static list of cultivable products with display color and
Persian label for the Crop Zoning page (Legend and zone detail panel).
Used when loading the crop-zoning page.
Input parameters:
- locale: string, optional. Location: query. Language code (e.g. fa, en).
Not read or used in response.
Response structure:
- status: string, always "success".
- data: object with key "products" (array of { id, label, color }).
No processing or validation is performed on inputs.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
) )
def get(self, request): def get(self, request):
return Response( return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
{"status": "success", "data": PRODUCTS_RESPONSE_DATA},
status=status.HTTP_200_OK,
)
class ZonesInitialView(APIView): class ZonesInitialView(APIView):
"""
POST endpoint for initial zone data (map + hover/tooltip).
Purpose:
Accepts the main area polygon and creates zones based on configured
area chunk size. Stores generated zones in database.
Input parameters (body, JSON):
- area: GeoJSON Feature. Location: body. Main land polygon.
If omitted, the static area from mock data is used.
Response structure:
- status: string.
- data: object with total_area_hectares, total_area_sqm, zone_count,
chunk_area_sqm and zones.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT, request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
area_feature = request.data.get("area") or AREA_RESPONSE_DATA.get("area") area_feature = request.data.get("area") or request.data.get("area_geojson") or get_default_area_feature()
try: try:
zoning_result = persist_zones(area_feature) crop_area, _zones = create_zones_and_dispatch(area_feature)
except ValueError as exc: except ValueError as exc:
return Response( return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
{"status": "error", "message": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
except ImproperlyConfigured as exc: except ImproperlyConfigured as exc:
return Response( return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
{"status": "error", "message": str(exc)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
area_data = zoning_result["area"]
response_data = {
"area": {
"id": area_data["id"],
"uuid": area_data["uuid"],
"geometry": area_data["geometry"],
"points": area_data["points"],
"center": area_data["center"],
"area_sqm": round(area_data["area_sqm"], 2),
"area_hectares": round(area_data["area_hectares"], 4),
"chunk_area_sqm": round(area_data["chunk_area_sqm"], 2),
"zone_count": area_data["zone_count"],
},
"zones": [
{
"zoneId": zone["zone_id"],
"geometry": zone["geometry"],
"points": zone["points"],
"center": zone["center"],
"area_sqm": round(zone["area_sqm"], 2),
"area_hectares": round(zone["area_hectares"], 4),
}
for zone in zoning_result["zones"]
],
}
return Response(
{"status": "success", "data": response_data},
status=status.HTTP_200_OK,
)
return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
class ZonesWaterNeedView(APIView): class ZonesWaterNeedView(APIView):
"""
POST endpoint for water need per zone (water need layer).
Purpose:
Accepts zones (FeatureCollection) and returns static water need
per zone for the water need map layer (level, value, color).
Input parameters (body, JSON):
- zones: GeoJSON FeatureCollection. Location: body.
- products: array of strings, optional. Location: body. Not used.
Response structure:
- status: string, always "success".
- data: object with zones (array of { zoneId, geometry, level, value, color }).
No processing or validation is performed on inputs.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT, request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
return Response( zone_ids = request.data.get("zoneIds")
{"status": "success", "data": ZONES_WATER_NEED_RESPONSE_DATA}, return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
status=status.HTTP_200_OK,
)
class ZonesSoilQualityView(APIView): class ZonesSoilQualityView(APIView):
"""
POST endpoint for soil quality per zone (soil quality layer).
Purpose:
Accepts zones (FeatureCollection) and returns static soil quality
per zone for the soil quality map layer (level, score, color).
Input parameters (body, JSON):
- zones: GeoJSON FeatureCollection. Location: body.
- products: array of strings, optional. Location: body. Not used.
Response structure:
- status: string, always "success".
- data: object with zones (array of { zoneId, geometry, level, score, color }).
No processing or validation is performed on inputs.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT, request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
return Response( zone_ids = request.data.get("zoneIds")
{"status": "success", "data": ZONES_SOIL_QUALITY_RESPONSE_DATA}, return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
status=status.HTTP_200_OK,
)
class ZonesCultivationRiskView(APIView): class ZonesCultivationRiskView(APIView):
"""
POST endpoint for cultivation risk per zone (cultivation risk layer).
Purpose:
Accepts zones (FeatureCollection) and returns static cultivation
risk per zone for the risk map layer (level, color).
Input parameters (body, JSON):
- zones: GeoJSON FeatureCollection. Location: body.
- products: array of strings, optional. Location: body. Not used.
Response structure:
- status: string, always "success".
- data: object with zones (array of { zoneId, geometry, level, color }).
No processing or validation is performed on inputs.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT, request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
return Response( zone_ids = request.data.get("zoneIds")
{"status": "success", "data": ZONES_CULTIVATION_RISK_RESPONSE_DATA}, return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
status=status.HTTP_200_OK,
)
class ZoneDetailsView(APIView): class ZoneDetailsView(APIView):
"""
GET endpoint for zone detail data (detail panel after click).
Purpose:
Returns static detail data for a single zone: reason, criteria,
area_hectares for the detail panel and radar chart.
Input parameters:
- zoneId: string. Location: path. Zone identifier (e.g. zone-0).
Not read or used in response.
Response structure:
- status: string, always "success".
- data: object with zoneId, crop, matchPercent, waterNeed,
estimatedProfit, reason, criteria (array), area_hectares.
No processing or validation is performed on inputs. Input values are
not used in the response.
"""
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
) )
def get(self, request, zone_id): def get(self, request, zone_id):
data = ZONE_DETAILS_BY_ID.get(zone_id, ZONE_DETAILS_BY_ID["zone-0"]) try:
return Response( data = get_zone_details_payload(zone_id)
{"status": "success", "data": data}, except Exception as exc:
status=status.HTTP_200_OK, if exc.__class__.__name__ == "DoesNotExist":
) raise Http404("Zone not found")
raise
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
@@ -0,0 +1,267 @@
# Recommend Task Status API Guide
این فایل برای تیم فرانت‌اند توضیح می‌دهد که برای ماژول‌های `fertilization_recommendation` و `irrigation_recommendation` چه درخواست‌هایی باید به بک‌اند ارسال شود و چه پاسخ‌هایی باید دریافت شود.
## Fertilization Recommendation
### 1) ثبت درخواست پیشنهاد
**Endpoint**
`POST /api/fertilization-recommendation/recommend/`
**Request Body**
```json
{
"crop_id": "wheat",
"growth_stage": "tillering",
"farm_data": {
"soilType": "loam",
"organicMatter": "medium",
"waterEC": "1.2"
},
"soilType": "loam",
"organicMatter": "medium",
"waterEC": "1.2"
}
```
**Field Description**
- `crop_id`: شناسه محصول
- `growth_stage`: مرحله رشد محصول
- `farm_data.soilType`: نوع خاک
- `farm_data.organicMatter`: مقدار ماده آلی
- `farm_data.waterEC`: EC آب
- `soilType`, `organicMatter`, `waterEC`: همین داده‌ها اگر فرانت بخواهد به صورت flat هم ارسال کند
**Success Response**
اگر سرویس خارجی مستقیم نتیجه را برگرداند:
```json
{
"status": "success",
"data": {
"plan": {
"npkRatio": "20-20-20",
"amountPerHectare": "150 kg/ha",
"applicationMethod": "drip",
"applicationInterval": "every 14 days",
"reasoning": "balanced nutrition for current growth stage"
}
}
}
```
اگر سرویس خارجی async باشد، معمولاً `task_id` برمی‌گرداند:
```json
{
"status": "success",
"data": {
"task_id": "fert-task-123",
"status": "pending"
}
}
```
### 2) دریافت وضعیت تسک
**Endpoint**
`GET /api/fertilization-recommendation/recommend/status/{task_id}/`
**Path Param**
- `task_id`: شناسه تسکی که از مرحله قبل گرفته شده
**Success Response**
```json
{
"status": "success",
"data": {
"task_id": "fert-task-123",
"status": "processing",
"progress": {
"message": "analyzing farm data"
},
"result": {
"plan": {
"npkRatio": "20-20-20",
"amountPerHectare": "150 kg/ha",
"applicationMethod": "drip",
"applicationInterval": "every 14 days",
"reasoning": "balanced nutrition for current growth stage"
}
}
}
}
```
**Possible status values**
- `pending`
- `processing`
- `completed`
- `failed`
---
## Irrigation Recommendation
### 1) ثبت درخواست پیشنهاد
**Endpoint**
`POST /api/irrigation-recommendation/recommend/`
**Request Body**
```json
{
"crop_id": "wheat",
"farm_data": {
"soilType": "loam",
"waterQuality": "good",
"climateZone": "semi-arid"
},
"soilType": "loam",
"waterQuality": "good",
"climateZone": "semi-arid"
}
```
**Field Description**
- `crop_id`: شناسه محصول
- `farm_data.soilType`: نوع خاک
- `farm_data.waterQuality`: کیفیت آب
- `farm_data.climateZone`: اقلیم
- `soilType`, `waterQuality`, `climateZone`: همین داده‌ها در حالت flat
**Success Response**
حالت نتیجه مستقیم:
```json
{
"status": "success",
"data": {
"plan": {
"frequencyPerWeek": "3",
"durationMinutes": "45",
"bestTimeOfDay": "early morning",
"moistureLevel": "optimal",
"warning": "avoid irrigation during strong wind"
},
"raw_response": "...",
"water_balance": {
"daily": [
{
"forecast_date": "2025-03-28",
"et0_mm": 4.1,
"etc_mm": 3.8,
"effective_rainfall_mm": 0.0,
"gross_irrigation_mm": 3.8,
"irrigation_timing": "06:00"
}
],
"crop_profile": {
"kc_initial": 0.7,
"kc_mid": 1.05,
"kc_end": 0.85
},
"active_kc": 1.05
},
"status": "completed"
}
}
```
حالت async:
```json
{
"status": "success",
"data": {
"task_id": "irr-task-123",
"status": "pending"
}
}
```
### 2) دریافت وضعیت تسک
**Endpoint**
`GET /api/irrigation-recommendation/recommend/status/{task_id}/`
**Path Param**
- `task_id`: شناسه تسک
**Success Response**
```json
{
"status": "success",
"data": {
"task_id": "irr-task-123",
"status": "completed",
"result": {
"plan": {
"frequencyPerWeek": "3",
"durationMinutes": "45",
"bestTimeOfDay": "early morning",
"moistureLevel": "optimal",
"warning": "avoid irrigation during strong wind"
},
"raw_response": "...",
"water_balance": {
"daily": [],
"crop_profile": {
"kc_initial": 0.7,
"kc_mid": 1.05,
"kc_end": 0.85
},
"active_kc": 1.05
},
"status": "completed"
}
}
}
```
---
## پیشنهاد پیاده‌سازی در فرانت
### Fertilization
1. با `POST /recommend/` درخواست را ارسال کنید.
2. اگر `data.task_id` برگشت، polling را با `GET /recommend/status/{task_id}/` شروع کنید.
3. وقتی `data.status` به `completed` رسید، `data.result` را نمایش دهید.
4. اگر `failed` شد، پیام خطا را به کاربر نشان دهید.
### Irrigation
1. با `POST /recommend/` درخواست را ارسال کنید.
2. اگر `task_id` برگشت، هر چند ثانیه وضعیت را چک کنید.
3. وقتی `completed` شد، `result.plan` و `result.water_balance` را نمایش دهید.
## نکات
- همه پاسخ‌ها در این پروژه معمولاً با ساختار زیر برمی‌گردند:
```json
{
"status": "success",
"data": {}
}
```
- در صورت خطا ممکن است `status` مقدار دیگری داشته باشد یا سرویس خارجی خطای مستقیم برگرداند.
- فرانت باید هر دو حالت **direct result** و **task-based result** را هندل کند.
+6 -1
View File
@@ -1,8 +1,13 @@
from django.urls import path from django.urls import path
from .views import ConfigView, RecommendView from .views import ConfigView, RecommendTaskStatusView, RecommendView
urlpatterns = [ urlpatterns = [
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
path(
"recommend/status/<str:task_id>/",
RecommendTaskStatusView.as_view(),
name="fertilization-recommendation-task-status",
),
] ]
+6 -1
View File
@@ -1,8 +1,13 @@
from django.urls import path from django.urls import path
from .views import ConfigView, RecommendView from .views import ConfigView, RecommendTaskStatusView, RecommendView
urlpatterns = [ urlpatterns = [
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
path(
"recommend/status/<str:task_id>/",
RecommendTaskStatusView.as_view(),
name="irrigation-recommendation-task-status",
),
] ]
+20
View File
@@ -22,6 +22,8 @@ class SensorSerializer(serializers.ModelSerializer):
class SensorCreateSerializer(serializers.ModelSerializer): class SensorCreateSerializer(serializers.ModelSerializer):
area_geojson = serializers.JSONField(write_only=True, required=False)
class Meta: class Meta:
model = Sensor model = Sensor
fields = [ fields = [
@@ -29,8 +31,26 @@ class SensorCreateSerializer(serializers.ModelSerializer):
"specifications", "specifications",
"power_source", "power_source",
"customized_sensors", "customized_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
class SensorToggleSerializer(serializers.Serializer): class SensorToggleSerializer(serializers.Serializer):
uuid_sensor = serializers.UUIDField() uuid_sensor = serializers.UUIDField()
+17
View File
@@ -0,0 +1,17 @@
from django.db import transaction
from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature
def create_sensor_with_zoning(serializer, owner):
area_feature = serializer.validated_data.pop("area_geojson", None)
with transaction.atomic():
sensor = serializer.save(owner=owner)
zoning_payload = None
if area_feature is not None:
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature))
zoning_payload = get_initial_zones_payload(crop_area)
return sensor, zoning_payload
+65
View File
@@ -0,0 +1,65 @@
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 sensor_hub.views import SensorListCreateView
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 SensorListCreateViewTests(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",
)
def test_create_sensor_with_area_geojson_creates_crop_zoning_payload(self):
request = self.factory.post(
"/api/sensor-hub/",
{
"name": "zone-sensor",
"specifications": {"model": "SH-1"},
"power_source": {"type": "battery"},
"customized_sensors": {"report_interval_sec": 300},
"area_geojson": AREA_GEOJSON,
},
format="json",
)
force_authenticate(request, user=self.user)
response = SensorListCreateView.as_view()(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["code"], 201)
self.assertEqual(response.data["data"]["name"], "zone-sensor")
self.assertIn("zoning", response.data["data"])
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)
+10 -1
View File
@@ -1,4 +1,5 @@
from rest_framework import serializers, status from rest_framework import serializers, status
from django.core.exceptions import ImproperlyConfigured
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -7,6 +8,7 @@ from drf_spectacular.utils import extend_schema
from config.swagger import code_response from config.swagger import code_response
from .models import Sensor from .models import Sensor
from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer
from .services import create_sensor_with_zoning
class SensorHubBaseView(APIView): class SensorHubBaseView(APIView):
@@ -37,8 +39,15 @@ class SensorListCreateView(SensorHubBaseView):
def post(self, request): def post(self, request):
serializer = SensorCreateSerializer(data=request.data) serializer = SensorCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
sensor = serializer.save(owner=request.user) try:
sensor, zoning_payload = create_sensor_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 = SensorSerializer(sensor).data data = SensorSerializer(sensor).data
if zoning_payload is not None:
data["zoning"] = zoning_payload
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)