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"))
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):
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
View File
@@ -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,
}
+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.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** را هندل کند.
+6 -1
View File
@@ -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",
),
]
+6 -1
View File
@@ -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",
),
]
+20
View File
@@ -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()
+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 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)