UPDATE
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
@@ -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()
|
||||
@@ -161,3 +161,11 @@ SIMPLE_JWT = {
|
||||
}
|
||||
|
||||
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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -24,6 +24,17 @@ class CropArea(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)
|
||||
crop_area = models.ForeignKey(
|
||||
CropArea,
|
||||
@@ -37,6 +48,9 @@ class CropZone(models.Model):
|
||||
area_sqm = models.FloatField()
|
||||
area_hectares = models.FloatField()
|
||||
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)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -49,3 +63,157 @@ class CropZone(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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
@@ -1,12 +1,28 @@
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
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
|
||||
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
|
||||
|
||||
|
||||
def get_chunk_area_sqm():
|
||||
@@ -20,6 +36,60 @@ def get_chunk_area_sqm():
|
||||
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):
|
||||
geometry = (area_feature or {}).get("geometry", {})
|
||||
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_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",
|
||||
"coordinates": [[*zone_points, zone_points[0]]],
|
||||
},
|
||||
}
|
||||
zones.append(
|
||||
{
|
||||
@@ -142,7 +202,11 @@ def split_area_into_zones(area_feature):
|
||||
)
|
||||
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["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)
|
||||
area_data = zoning_result["area"]
|
||||
|
||||
@@ -180,8 +458,7 @@ def persist_zones(area_feature):
|
||||
chunk_area_sqm=round(area_data["chunk_area_sqm"], 2),
|
||||
zone_count=area_data["zone_count"],
|
||||
)
|
||||
|
||||
CropZone.objects.bulk_create(
|
||||
zones = CropZone.objects.bulk_create(
|
||||
[
|
||||
CropZone(
|
||||
crop_area=crop_area,
|
||||
@@ -197,6 +474,100 @@ def persist_zones(area_feature):
|
||||
]
|
||||
)
|
||||
|
||||
zoning_result["area"]["id"] = crop_area.id
|
||||
zoning_result["area"]["uuid"] = str(crop_area.uuid)
|
||||
return zoning_result
|
||||
crop_area.refresh_from_db()
|
||||
dispatch_zone_processing_tasks(crop_area.id)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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.http import Http404
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -13,271 +7,99 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from config.swagger import status_response
|
||||
from .mock_data import (
|
||||
AREA_RESPONSE_DATA,
|
||||
PRODUCTS_RESPONSE_DATA,
|
||||
ZONE_DETAILS_BY_ID,
|
||||
ZONES_CULTIVATION_RISK_RESPONSE_DATA,
|
||||
ZONES_INITIAL_RESPONSE_DATA,
|
||||
ZONES_SOIL_QUALITY_RESPONSE_DATA,
|
||||
ZONES_WATER_NEED_RESPONSE_DATA,
|
||||
from .services import (
|
||||
create_zones_and_dispatch,
|
||||
get_cultivation_risk_payload,
|
||||
get_default_area_feature,
|
||||
get_initial_zones_payload,
|
||||
get_latest_area_payload,
|
||||
get_products_payload,
|
||||
get_soil_quality_payload,
|
||||
get_water_need_payload,
|
||||
get_zone_details_payload,
|
||||
)
|
||||
from .services import persist_zones
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def get(self, request):
|
||||
return Response(
|
||||
{"status": "success", "data": AREA_RESPONSE_DATA},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response({"status": "success", "data": get_latest_area_payload()}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def get(self, request):
|
||||
return Response(
|
||||
{"status": "success", "data": PRODUCTS_RESPONSE_DATA},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
|
||||
)
|
||||
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:
|
||||
zoning_result = persist_zones(area_feature)
|
||||
crop_area, _zones = create_zones_and_dispatch(area_feature)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"status": "error", "message": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ImproperlyConfigured as exc:
|
||||
return Response(
|
||||
{"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": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def post(self, request):
|
||||
return Response(
|
||||
{"status": "success", "data": ZONES_WATER_NEED_RESPONSE_DATA},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
zone_ids = request.data.get("zoneIds")
|
||||
return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def post(self, request):
|
||||
return Response(
|
||||
{"status": "success", "data": ZONES_SOIL_QUALITY_RESPONSE_DATA},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
zone_ids = request.data.get("zoneIds")
|
||||
return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def post(self, request):
|
||||
return Response(
|
||||
{"status": "success", "data": ZONES_CULTIVATION_RISK_RESPONSE_DATA},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
zone_ids = request.data.get("zoneIds")
|
||||
return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
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(
|
||||
tags=["Crop Zoning"],
|
||||
responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def get(self, request, zone_id):
|
||||
data = ZONE_DETAILS_BY_ID.get(zone_id, ZONE_DETAILS_BY_ID["zone-0"])
|
||||
return Response(
|
||||
{"status": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
try:
|
||||
data = get_zone_details_payload(zone_id)
|
||||
except Exception as exc:
|
||||
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** را هندل کند.
|
||||
@@ -1,8 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import ConfigView, RecommendView
|
||||
from .views import ConfigView, RecommendTaskStatusView, RecommendView
|
||||
|
||||
urlpatterns = [
|
||||
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
|
||||
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
|
||||
path(
|
||||
"recommend/status/<str:task_id>/",
|
||||
RecommendTaskStatusView.as_view(),
|
||||
name="fertilization-recommendation-task-status",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import ConfigView, RecommendView
|
||||
from .views import ConfigView, RecommendTaskStatusView, RecommendView
|
||||
|
||||
urlpatterns = [
|
||||
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
|
||||
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
|
||||
path(
|
||||
"recommend/status/<str:task_id>/",
|
||||
RecommendTaskStatusView.as_view(),
|
||||
name="irrigation-recommendation-task-status",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -22,6 +22,8 @@ class SensorSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class SensorCreateSerializer(serializers.ModelSerializer):
|
||||
area_geojson = serializers.JSONField(write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Sensor
|
||||
fields = [
|
||||
@@ -29,8 +31,26 @@ class SensorCreateSerializer(serializers.ModelSerializer):
|
||||
"specifications",
|
||||
"power_source",
|
||||
"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):
|
||||
uuid_sensor = serializers.UUIDField()
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
from rest_framework import serializers, status
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -7,6 +8,7 @@ from drf_spectacular.utils import extend_schema
|
||||
from config.swagger import code_response
|
||||
from .models import Sensor
|
||||
from .serializers import SensorCreateSerializer, SensorSerializer, SensorToggleSerializer
|
||||
from .services import create_sensor_with_zoning
|
||||
|
||||
|
||||
class SensorHubBaseView(APIView):
|
||||
@@ -37,8 +39,15 @@ class SensorListCreateView(SensorHubBaseView):
|
||||
def post(self, request):
|
||||
serializer = SensorCreateSerializer(data=request.data)
|
||||
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
|
||||
if zoning_payload is not None:
|
||||
data["zoning"] = zoning_payload
|
||||
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user