From 9323000bac1604b0921855603cd549f806420ab8 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Sun, 29 Mar 2026 15:07:14 +0330 Subject: [PATCH] UPDATE --- config/__init__.py | 3 + config/celery.py | 9 + config/settings.py | 8 + .../0002_crop_zoning_mock_schema.py | 99 +++++ .../0003_zone_processing_and_analysis.py | 49 +++ crop_zoning/models.py | 168 +++++++ crop_zoning/services.py | 411 +++++++++++++++++- crop_zoning/tasks.py | 17 + crop_zoning/tests.py | 47 ++ crop_zoning/views.py | 240 ++-------- .../recommend_task_status_frontend_backend.md | 267 ++++++++++++ fertilization_recommendation/urls.py | 7 +- irrigation_recommendation/urls.py | 7 +- sensor_hub/serializers.py | 20 + sensor_hub/services.py | 17 + sensor_hub/tests.py | 65 +++ sensor_hub/views.py | 11 +- 17 files changed, 1213 insertions(+), 232 deletions(-) create mode 100644 config/celery.py create mode 100644 crop_zoning/migrations/0002_crop_zoning_mock_schema.py create mode 100644 crop_zoning/migrations/0003_zone_processing_and_analysis.py create mode 100644 crop_zoning/tasks.py create mode 100644 crop_zoning/tests.py create mode 100644 docs/recommend_task_status_frontend_backend.md create mode 100644 sensor_hub/services.py create mode 100644 sensor_hub/tests.py diff --git a/config/__init__.py b/config/__init__.py index e69de29..53f4ccb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..3a079f1 --- /dev/null +++ b/config/celery.py @@ -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() diff --git a/config/settings.py b/config/settings.py index 5ff6d9a..6ba4a3f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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")) diff --git a/crop_zoning/migrations/0002_crop_zoning_mock_schema.py b/crop_zoning/migrations/0002_crop_zoning_mock_schema.py new file mode 100644 index 0000000..056c678 --- /dev/null +++ b/crop_zoning/migrations/0002_crop_zoning_mock_schema.py @@ -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"], + }, + ), + ] diff --git a/crop_zoning/migrations/0003_zone_processing_and_analysis.py b/crop_zoning/migrations/0003_zone_processing_and_analysis.py new file mode 100644 index 0000000..dc36a81 --- /dev/null +++ b/crop_zoning/migrations/0003_zone_processing_and_analysis.py @@ -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"], + }, + ), + ] diff --git a/crop_zoning/models.py b/crop_zoning/models.py index 107d7b5..beafc02 100644 --- a/crop_zoning/models.py +++ b/crop_zoning/models.py @@ -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"] + + diff --git a/crop_zoning/services.py b/crop_zoning/services.py index 9655f1d..84e21ca 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -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]]], - }, + "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, + } diff --git a/crop_zoning/tasks.py b/crop_zoning/tasks.py new file mode 100644 index 0000000..83af005 --- /dev/null +++ b/crop_zoning/tasks.py @@ -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"} diff --git a/crop_zoning/tests.py b/crop_zoning/tests.py new file mode 100644 index 0000000..702403d --- /dev/null +++ b/crop_zoning/tests.py @@ -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"]), + ) diff --git a/crop_zoning/views.py b/crop_zoning/views.py index 5d569b8..0801644 100644 --- a/crop_zoning/views.py +++ b/crop_zoning/views.py @@ -1,11 +1,5 @@ -""" -Crop Zoning API views. -No database. All responses are static mock data. -Response format: {"status": "success", "data": }. 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) diff --git a/docs/recommend_task_status_frontend_backend.md b/docs/recommend_task_status_frontend_backend.md new file mode 100644 index 0000000..6d9d98e --- /dev/null +++ b/docs/recommend_task_status_frontend_backend.md @@ -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** را هندل کند. diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index e52d26d..e7e8a3c 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -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//", + RecommendTaskStatusView.as_view(), + name="fertilization-recommendation-task-status", + ), ] diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index b3810bc..7a7d466 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -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//", + RecommendTaskStatusView.as_view(), + name="irrigation-recommendation-task-status", + ), ] diff --git a/sensor_hub/serializers.py b/sensor_hub/serializers.py index f332120..e3c3732 100644 --- a/sensor_hub/serializers.py +++ b/sensor_hub/serializers.py @@ -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() diff --git a/sensor_hub/services.py b/sensor_hub/services.py new file mode 100644 index 0000000..fc5bc12 --- /dev/null +++ b/sensor_hub/services.py @@ -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 diff --git a/sensor_hub/tests.py b/sensor_hub/tests.py new file mode 100644 index 0000000..0a829f3 --- /dev/null +++ b/sensor_hub/tests.py @@ -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) diff --git a/sensor_hub/views.py b/sensor_hub/views.py index c210221..6087ae1 100644 --- a/sensor_hub/views.py +++ b/sensor_hub/views.py @@ -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)