This commit is contained in:
2026-03-30 23:29:03 +03:30
parent 9323000bac
commit c97b90bfaf
13 changed files with 945 additions and 131 deletions
+1 -2
View File
@@ -30,10 +30,9 @@ COPY requirements.txt .
# Python mirrors # Python mirrors
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \ RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \ pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
pip config --user set global.trusted-host package-mirror.liara.ir && \ pip config --user set global.trusted-host package-mirror.liara.ir && \
pip config --user set global.trusted-host mirror.cdn.ir && \ pip config --user set global.trusted-host mirror2.chabokan.net && \
pip config --user set global.trusted-host mirror-pypi.runflare.com pip config --user set global.trusted-host mirror-pypi.runflare.com
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
+3
View File
@@ -5,3 +5,6 @@ class CropZoningConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "crop_zoning" name = "crop_zoning"
verbose_name = "Crop Zoning" verbose_name = "Crop Zoning"
def ready(self):
from . import tasks # noqa: F401
@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("sensor_hub", "0001_initial"),
("crop_zoning", "0003_zone_processing_and_analysis"),
]
operations = [
migrations.AddField(
model_name="croparea",
name="sensor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="crop_areas",
to="sensor_hub.sensor",
),
),
]
+9 -1
View File
@@ -1,10 +1,19 @@
import uuid import uuid
from django.db import models from django.db import models
from sensor_hub.models import Sensor
class CropArea(models.Model): class CropArea(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
sensor = models.ForeignKey(
Sensor,
on_delete=models.CASCADE,
related_name="crop_areas",
null=True,
blank=True,
db_index=True,
)
geometry = models.JSONField(default=dict) geometry = models.JSONField(default=dict)
points = models.JSONField(default=list) points = models.JSONField(default=list)
center = models.JSONField(default=dict) center = models.JSONField(default=dict)
@@ -216,4 +225,3 @@ class CropZoneAnalysis(models.Model):
db_table = "crop_zone_analyses" db_table = "crop_zone_analyses"
ordering = ["crop_zone_id"] ordering = ["crop_zone_id"]
File diff suppressed because one or more lines are too long
+498 -96
View File
@@ -3,8 +3,10 @@ from copy import deepcopy
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from kombu.exceptions import OperationalError
from django.db import transaction from django.db import transaction
from django.db.models import Prefetch from django.db.models import Prefetch
from sensor_hub.models import Sensor
from external_api_adapter.adapter import request as external_request from external_api_adapter.adapter import request as external_request
@@ -23,17 +25,68 @@ from .models import (
EARTH_RADIUS_METERS = 6378137.0 EARTH_RADIUS_METERS = 6378137.0
PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"] PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"]
DEFAULT_CELL_SIDE_KM = 0.15
RULE_BASED_ALGORITHM = "rule_based_v1"
RULE_BASED_PRODUCTS = {
"wheat": {
"water_need": "۴۵۰۰-۵۵۰۰ m³/ha",
"water_need_level": "medium",
"estimated_profit": "۱۵-۲۵ میلیون/هکتار",
"reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی",
},
"canola": {
"water_need": "۵۰۰۰-۶۰۰۰ m³/ha",
"water_need_level": "high",
"estimated_profit": "۲۰-۳۵ میلیون/هکتار",
"reason": "پایداری بهتر در برابر نوسان دما و پتانسیل سود اقتصادی مناسب",
},
"saffron": {
"water_need": "۳۰۰۰-۴۰۰۰ m³/ha",
"water_need_level": "low",
"estimated_profit": "۵۰-۱۵۰ میلیون/هکتار",
"reason": "اقلیم خشک‌تر و نیاز آبی کمتر این زون برای زعفران مناسب‌تر است",
},
}
RULE_BASED_CROP_IDS = tuple(RULE_BASED_PRODUCTS.keys())
def get_chunk_area_sqm(): def get_default_cell_side_km():
raw_value = getattr(settings, "CROP_ZONE_CELL_SIDE_KM", None)
try:
cell_side_km = float(raw_value)
except (TypeError, ValueError):
cell_side_km = 0
if cell_side_km > 0:
return cell_side_km
raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0) raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0)
try: try:
chunk_area = float(raw_value) chunk_area = float(raw_value)
except (TypeError, ValueError): except (TypeError, ValueError):
chunk_area = 0 chunk_area = 0
if chunk_area <= 0: if chunk_area > 0:
raise ValueError("CROP_ZONE_CHUNK_AREA_SQM must be a positive number.") return math.sqrt(chunk_area) / 1000.0
return chunk_area
return DEFAULT_CELL_SIDE_KM
def get_cell_side_km(cell_side_km=None):
if cell_side_km is None or cell_side_km == "":
resolved_value = get_default_cell_side_km()
else:
try:
resolved_value = float(cell_side_km)
except (TypeError, ValueError) as exc:
raise ValueError("cell_side_km must be a positive number.") from exc
if resolved_value <= 0:
raise ValueError("cell_side_km must be a positive number.")
return resolved_value
def get_chunk_area_sqm(cell_side_km=None):
resolved_cell_side_km = get_cell_side_km(cell_side_km)
return (resolved_cell_side_km * 1000.0) ** 2
def get_default_area_feature(): def get_default_area_feature():
@@ -138,69 +191,206 @@ def calculate_center(points):
} }
def get_bbox(points):
longitudes = [point[0] for point in points]
latitudes = [point[1] for point in points]
return {
"min_lng": min(longitudes),
"max_lng": max(longitudes),
"min_lat": min(latitudes),
"max_lat": max(latitudes),
}
def meters_to_latitude_delta(meters):
return meters / 111320.0
def meters_to_longitude_delta(meters, latitude):
longitude_factor = 111320.0 * math.cos(math.radians(latitude))
if abs(longitude_factor) < 1e-9:
longitude_factor = 1.0
return meters / longitude_factor
def point_in_polygon(point, polygon_points):
x, y = point
inside = False
point_count = len(polygon_points)
if point_count < 3:
return False
for index in range(point_count):
x1, y1 = polygon_points[index]
x2, y2 = polygon_points[(index + 1) % point_count]
intersects = ((y1 > y) != (y2 > y)) and (
x < ((x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12)) + x1
)
if intersects:
inside = not inside
return inside
def _orientation(point_a, point_b, point_c):
value = ((point_b[1] - point_a[1]) * (point_c[0] - point_b[0])) - (
(point_b[0] - point_a[0]) * (point_c[1] - point_b[1])
)
if abs(value) < 1e-12:
return 0
return 1 if value > 0 else 2
def _on_segment(point_a, point_b, point_c):
return (
min(point_a[0], point_c[0]) - 1e-12 <= point_b[0] <= max(point_a[0], point_c[0]) + 1e-12
and min(point_a[1], point_c[1]) - 1e-12 <= point_b[1] <= max(point_a[1], point_c[1]) + 1e-12
)
def segments_intersect(point_a, point_b, point_c, point_d):
orientation_1 = _orientation(point_a, point_b, point_c)
orientation_2 = _orientation(point_a, point_b, point_d)
orientation_3 = _orientation(point_c, point_d, point_a)
orientation_4 = _orientation(point_c, point_d, point_b)
if orientation_1 != orientation_2 and orientation_3 != orientation_4:
return True
if orientation_1 == 0 and _on_segment(point_a, point_c, point_b):
return True
if orientation_2 == 0 and _on_segment(point_a, point_d, point_b):
return True
if orientation_3 == 0 and _on_segment(point_c, point_a, point_d):
return True
if orientation_4 == 0 and _on_segment(point_c, point_b, point_d):
return True
return False
def rectangle_contains_point(point, cell_points):
min_lng = min(vertex[0] for vertex in cell_points)
max_lng = max(vertex[0] for vertex in cell_points)
min_lat = min(vertex[1] for vertex in cell_points)
max_lat = max(vertex[1] for vertex in cell_points)
return min_lng <= point[0] <= max_lng and min_lat <= point[1] <= max_lat
def polygon_intersects_cell(polygon_points, cell_points):
cell_center = calculate_center(cell_points)
if point_in_polygon([cell_center["longitude"], cell_center["latitude"]], polygon_points):
return True
if any(point_in_polygon(point, polygon_points) for point in cell_points):
return True
if any(rectangle_contains_point(point, cell_points) for point in polygon_points):
return True
polygon_edges = list(zip(polygon_points, polygon_points[1:] + polygon_points[:1]))
cell_edges = list(zip(cell_points, cell_points[1:] + cell_points[:1]))
return any(
segments_intersect(start_a, end_a, start_b, end_b)
for start_a, end_a in polygon_edges
for start_b, end_b in cell_edges
)
def build_square_points(left_lng, bottom_lat, right_lng, top_lat):
return [
[round(left_lng, 8), round(bottom_lat, 8)],
[round(right_lng, 8), round(bottom_lat, 8)],
[round(right_lng, 8), round(top_lat, 8)],
[round(left_lng, 8), round(top_lat, 8)],
]
def build_zone_square(area_points, center, zone_area_sqm): def build_zone_square(area_points, center, zone_area_sqm):
if len(area_points) < 4: if len(area_points) < 4:
return area_points return area_points
width = math.sqrt(max(zone_area_sqm, 1)) width = math.sqrt(max(zone_area_sqm, 1))
half_width = width / 2.0 half_width = width / 2.0
delta_lat = meters_to_latitude_delta(half_width)
delta_lng = meters_to_longitude_delta(half_width, center["latitude"])
latitude_factor = 111320.0 return build_square_points(
longitude_factor = 111320.0 * math.cos(math.radians(center["latitude"])) center["longitude"] - delta_lng,
if longitude_factor == 0: center["latitude"] - delta_lat,
longitude_factor = 1.0 center["longitude"] + delta_lng,
center["latitude"] + delta_lat,
delta_lat = half_width / latitude_factor )
delta_lng = half_width / longitude_factor
return [
[round(center["longitude"] - delta_lng, 8), round(center["latitude"] - delta_lat, 8)],
[round(center["longitude"] + delta_lng, 8), round(center["latitude"] - delta_lat, 8)],
[round(center["longitude"] + delta_lng, 8), round(center["latitude"] + delta_lat, 8)],
[round(center["longitude"] - delta_lng, 8), round(center["latitude"] + delta_lat, 8)],
]
def split_area_into_zones(area_feature): def split_area_into_zones(area_feature, cell_side_km=None):
area_ring = get_polygon_ring(area_feature) area_ring = get_polygon_ring(area_feature)
area_points = normalize_points(area_ring) area_points = normalize_points(area_ring)
area_center = calculate_center(area_points) area_center = calculate_center(area_points)
total_area_sqm = polygon_area_sqm(area_ring) total_area_sqm = polygon_area_sqm(area_ring)
chunk_area_sqm = get_chunk_area_sqm() resolved_cell_side_km = get_cell_side_km(cell_side_km)
zone_count = max(1, math.ceil(total_area_sqm / chunk_area_sqm)) chunk_area_sqm = get_chunk_area_sqm(resolved_cell_side_km)
cell_side_meters = resolved_cell_side_km * 1000.0
bbox = get_bbox(area_points)
latitude_step = meters_to_latitude_delta(cell_side_meters)
zones = [] zones = []
remaining_area = total_area_sqm sequence = 0
base_longitude = area_center["longitude"] current_lat = bbox["min_lat"]
base_latitude = area_center["latitude"]
for sequence in range(zone_count): while current_lat < bbox["max_lat"] - 1e-12:
zone_area_sqm = min(chunk_area_sqm, remaining_area) if sequence < zone_count - 1 else remaining_area next_lat = current_lat + latitude_step
if zone_area_sqm <= 0: row_center_lat = current_lat + (latitude_step / 2.0)
zone_area_sqm = min(chunk_area_sqm, total_area_sqm) longitude_step = meters_to_longitude_delta(cell_side_meters, row_center_lat)
current_lng = bbox["min_lng"]
shift = (sequence - ((zone_count - 1) / 2)) * 0.0003 while current_lng < bbox["max_lng"] - 1e-12:
zone_center = { next_lng = current_lng + longitude_step
"longitude": round(base_longitude + shift, 8), zone_points = build_square_points(current_lng, current_lat, next_lng, next_lat)
"latitude": round(base_latitude, 8),
} if polygon_intersects_cell(area_points, zone_points):
zone_points = build_zone_square(area_points, zone_center, zone_area_sqm)
zone_geometry = { zone_geometry = {
"type": "Polygon", "type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]], "coordinates": [[*zone_points, zone_points[0]]],
} }
zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0])
zones.append( zones.append(
{ {
"zone_id": f"zone-{sequence}", "zone_id": f"zone-{sequence}",
"geometry": zone_geometry, "geometry": zone_geometry,
"points": zone_points, "points": zone_points,
"center": zone_center, "center": calculate_center(zone_points),
"area_sqm": zone_area_sqm, "area_sqm": round(zone_area_sqm, 2),
"area_hectares": zone_area_sqm / 10000, "area_hectares": round(zone_area_sqm / 10000, 4),
"sequence": sequence, "sequence": sequence,
} }
) )
remaining_area = max(0.0, remaining_area - zone_area_sqm) sequence += 1
current_lng = next_lng
current_lat = next_lat
if not zones:
zone_points = build_zone_square(area_points, area_center, max(total_area_sqm, chunk_area_sqm))
zone_geometry = {
"type": "Polygon",
"coordinates": [[*zone_points, zone_points[0]]],
}
zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0])
zones.append(
{
"zone_id": "zone-0",
"geometry": zone_geometry,
"points": zone_points,
"center": area_center,
"area_sqm": round(zone_area_sqm, 2),
"area_hectares": round(zone_area_sqm / 10000, 4),
"sequence": 0,
}
)
zone_count = len(zones)
area_geometry = { area_geometry = {
"type": "Feature", "type": "Feature",
@@ -213,6 +403,7 @@ def split_area_into_zones(area_feature):
"center": area_center, "center": area_center,
"area_sqm": round(total_area_sqm, 2), "area_sqm": round(total_area_sqm, 2),
"area_hectares": round(total_area_sqm / 10000, 4), "area_hectares": round(total_area_sqm / 10000, 4),
"cell_side_km": round(resolved_cell_side_km, 4),
} }
) )
@@ -224,12 +415,50 @@ def split_area_into_zones(area_feature):
"area_sqm": total_area_sqm, "area_sqm": total_area_sqm,
"area_hectares": total_area_sqm / 10000, "area_hectares": total_area_sqm / 10000,
"chunk_area_sqm": chunk_area_sqm, "chunk_area_sqm": chunk_area_sqm,
"cell_side_km": resolved_cell_side_km,
"zone_count": zone_count, "zone_count": zone_count,
}, },
"zones": zones, "zones": zones,
} }
def build_rule_based_zone_metrics(index, coords):
if coords:
first_longitude, first_latitude = coords[0]
else:
first_longitude, first_latitude = (0.0, 0.0)
seed = int((index * 7) + math.floor(first_latitude * 100) + math.floor(first_longitude * 100))
crop_id = RULE_BASED_CROP_IDS[abs(seed) % len(RULE_BASED_CROP_IDS)]
crop_metadata = RULE_BASED_PRODUCTS[crop_id]
match_percent = 60 + (abs(seed) % 35)
criteria = [
{"name": "دما", "value": 55 + (abs(seed + 11) % 40)},
{"name": "بارش", "value": 55 + (abs(seed + 17) % 40)},
{"name": "خاک", "value": 55 + (abs(seed + 23) % 40)},
{"name": "آب", "value": 55 + (abs(seed + 29) % 40)},
]
soil_quality_score = criteria[2]["value"]
soil_level = _pick_level(soil_quality_score, 65, 85)
cultivation_risk_score = max(1, min(100, round(100 - match_percent + ((abs(seed) % 9) - 4))))
cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 60 else "high"
return {
"soil_quality_score": soil_quality_score,
"soil_level": soil_level,
"water_need_level": crop_metadata["water_need_level"],
"water_need_value": crop_metadata["water_need"],
"cultivation_risk_level": cultivation_risk_level,
"recommended_crop": crop_id,
"match_percent": match_percent,
"estimated_profit": crop_metadata["estimated_profit"],
"reason": crop_metadata["reason"],
"criteria": criteria,
"algorithm": RULE_BASED_ALGORITHM,
}
def build_initial_zone_payload(zone): def build_initial_zone_payload(zone):
recommendation = getattr(zone, "recommendation", None) recommendation = getattr(zone, "recommendation", None)
return { return {
@@ -242,6 +471,67 @@ def build_initial_zone_payload(zone):
} }
def persist_zone_analysis_metrics(zone, metrics):
ensure_products_exist()
product = CropProduct.objects.get(product_id=metrics["recommended_crop"])
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"]),
},
)
return recommendation
def ensure_rule_based_zone_data(zone, force=False):
has_recommendation = CropZoneRecommendation.objects.filter(crop_zone=zone).exists()
if has_recommendation and not force:
return zone
metrics = build_rule_based_zone_metrics(zone.sequence, zone.points)
persist_zone_analysis_metrics(zone, metrics)
return zone
def _get_level_color_map(layer_name, level): def _get_level_color_map(layer_name, level):
mappings = { mappings = {
"water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"}, "water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"},
@@ -371,51 +661,7 @@ def analyze_and_store_zone_soil_data(zone_id):
"depths": depths, "depths": depths,
}, },
) )
recommendation, _ = CropZoneRecommendation.objects.update_or_create( persist_zone_analysis_metrics(zone, metrics)
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_status = CropZone.STATUS_COMPLETED
zone.processing_error = "" zone.processing_error = ""
zone.save(update_fields=["processing_status", "processing_error", "updated_at"]) zone.save(update_fields=["processing_status", "processing_error", "updated_at"])
@@ -428,28 +674,130 @@ def analyze_and_store_zone_soil_data(zone_id):
return zone return zone
def dispatch_zone_processing_tasks(crop_area_id): def dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None):
from .tasks import process_zone_soil_data from .tasks import process_zone_soil_data
zones = list(CropZone.objects.filter(crop_area_id=crop_area_id).only("id")) queryset = CropZone.objects.select_related("crop_area").all()
if crop_area_id is not None:
queryset = queryset.filter(crop_area_id=crop_area_id)
if zone_ids is not None:
queryset = queryset.filter(id__in=zone_ids)
zones = list(queryset.only("id", "task_id", "processing_status", "crop_area__sensor_id"))
sensor_task_ids = {}
for zone in zones: for zone in zones:
task_identifier = "" sensor_id = zone.crop_area.sensor_id
existing_task_id = sensor_task_ids.get(sensor_id) or zone.task_id
if existing_task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}:
sensor_task_ids[sensor_id] = existing_task_id
if zone.task_id != existing_task_id:
CropZone.objects.filter(id=zone.id).update(task_id=existing_task_id)
continue
try: try:
async_result = process_zone_soil_data.delay(zone.id) async_result = process_zone_soil_data.delay(zone.id)
task_identifier = getattr(async_result, "id", "") or "" task_identifier = getattr(async_result, "id", "") or str(uuid.uuid4())
except Exception: processing_error = ""
analyze_and_store_zone_soil_data(zone_id=zone.id) except OperationalError as exc:
CropZone.objects.filter(id=zone.id).update(task_id=task_identifier) task_identifier = str(uuid.uuid4())
processing_error = f"Celery broker unavailable: {exc}"
except Exception as exc:
task_identifier = str(uuid.uuid4())
processing_error = f"Celery dispatch failed: {exc}"
update_fields = {"task_id": task_identifier}
if zone.processing_status == CropZone.STATUS_FAILED:
update_fields["processing_status"] = CropZone.STATUS_PENDING
if processing_error:
update_fields["processing_error"] = processing_error
elif zone.processing_status == CropZone.STATUS_FAILED:
update_fields["processing_error"] = ""
CropZone.objects.filter(id=zone.id).update(**update_fields)
if sensor_id and task_identifier:
sensor_task_ids[sensor_id] = task_identifier
def create_zones_and_dispatch(area_feature): def create_missing_zones_for_area(crop_area):
if crop_area.zones.exists():
return list(crop_area.zones.order_by("sequence", "id"))
area_feature = normalize_area_feature(crop_area.geometry)
zoning_result = split_area_into_zones(
area_feature,
cell_side_km=math.sqrt(max(crop_area.chunk_area_sqm, 1)) / 1000.0,
)
zones = CropZone.objects.bulk_create(
[
CropZone(
crop_area=crop_area,
zone_id=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),
sequence=zone["sequence"],
)
for zone in zoning_result["zones"]
]
)
crop_area.zone_count = len(zones)
crop_area.save(update_fields=["zone_count", "updated_at"])
return list(crop_area.zones.order_by("sequence", "id"))
def get_sensor_for_uuid(sensor_uuid):
if not sensor_uuid:
raise ValueError("sensor_uuid is required.")
try:
return Sensor.objects.get(uuid_sensor=sensor_uuid)
except Sensor.DoesNotExist as exc:
raise ValueError("Sensor not found.") from exc
def ensure_latest_area_ready_for_processing(sensor_uuid, area_feature=None):
sensor = get_sensor_for_uuid(sensor_uuid)
latest_area = CropArea.objects.filter(sensor=sensor).order_by("-created_at", "-id").first()
if latest_area is None:
latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), sensor=sensor)
return latest_area
zones = create_missing_zones_for_area(latest_area)
for zone in zones:
ensure_rule_based_zone_data(zone)
active_task_id = next((zone.task_id for zone in zones if zone.task_id and zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING}), "")
zones_to_dispatch = []
for zone in zones:
if zone.processing_status == CropZone.STATUS_COMPLETED:
continue
if active_task_id:
if not zone.task_id:
CropZone.objects.filter(id=zone.id).update(task_id=active_task_id)
continue
if zone.processing_status == CropZone.STATUS_PROCESSING and zone.task_id:
active_task_id = zone.task_id
continue
if zone.processing_status == CropZone.STATUS_PENDING and zone.task_id:
active_task_id = zone.task_id
continue
zones_to_dispatch.append(zone.id)
if zones_to_dispatch:
dispatch_zone_processing_tasks(zone_ids=zones_to_dispatch)
return CropArea.objects.get(id=latest_area.id)
def create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None):
ensure_products_exist() ensure_products_exist()
area_feature = normalize_area_feature(area_feature) area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature) zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km)
area_data = zoning_result["area"] area_data = zoning_result["area"]
with transaction.atomic(): with transaction.atomic():
crop_area = CropArea.objects.create( crop_area = CropArea.objects.create(
sensor=sensor,
geometry=area_data["geometry"], geometry=area_data["geometry"],
points=area_data["points"], points=area_data["points"],
center=area_data["center"], center=area_data["center"],
@@ -475,6 +823,9 @@ def create_zones_and_dispatch(area_feature):
) )
crop_area.refresh_from_db() crop_area.refresh_from_db()
zones = list(crop_area.zones.order_by("sequence", "id"))
for zone in zones:
ensure_rule_based_zone_data(zone)
dispatch_zone_processing_tasks(crop_area.id) dispatch_zone_processing_tasks(crop_area.id)
return crop_area, zones return crop_area, zones
@@ -493,11 +844,62 @@ def _zones_queryset(zone_ids=None):
return queryset return queryset
def get_latest_area_payload(): def get_latest_area_payload(area=None):
area = CropArea.objects.order_by("-created_at", "-id").first() area = area or CropArea.objects.order_by("-created_at", "-id").first()
if area: if area:
return {"area": area.geometry} zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
return {"area": get_default_area_feature()} total_zones = len(zones)
completed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_COMPLETED)
processing_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PROCESSING)
failed_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_FAILED)
pending_zones = sum(1 for zone in zones if zone.processing_status == CropZone.STATUS_PENDING)
if failed_zones:
task_status = "FAILURE"
elif total_zones and completed_zones == total_zones:
task_status = "SUCCESS"
elif processing_zones or completed_zones:
task_status = "PROCESSING"
else:
task_status = "PENDING"
return {
"task": {
"status": task_status,
"area_uuid": str(area.uuid),
"total_zones": total_zones,
"completed_zones": completed_zones,
"processing_zones": processing_zones,
"pending_zones": pending_zones,
"failed_zones": failed_zones,
"task_ids": [zone.task_id for zone in zones if zone.task_id],
"failed_zone_errors": [
{
"zoneId": zone.zone_id,
"error": zone.processing_error,
}
for zone in zones
if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error
],
"cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4),
},
"area": area.geometry,
}
return {
"task": {
"status": "IDLE",
"area_uuid": "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"task_ids": [],
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
},
"area": get_default_area_feature(),
}
def get_initial_zones_payload(crop_area): def get_initial_zones_payload(crop_area):
+239 -1
View File
@@ -1,7 +1,14 @@
from unittest.mock import patch
from kombu.exceptions import OperationalError
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from crop_zoning.views import ZonesInitialView from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import AreaView, ZonesInitialView
from sensor_hub.models import Sensor
AREA_GEOJSON = { AREA_GEOJSON = {
@@ -45,3 +52,234 @@ class ZonesInitialViewTests(TestCase):
response.data["data"]["zone_count"], response.data["data"]["zone_count"],
len(response.data["data"]["zones"]), len(response.data["data"]["zones"]),
) )
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class AreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="farmer",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.sensor = Sensor.objects.create(owner=self.user, name="sensor-1")
def _create_area(self, **kwargs):
defaults = {
"sensor": self.sensor,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 2,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _request(self):
return self.factory.get(f"/api/crop-zoning/area/?sensor_uuid={self.sensor.uuid_sensor}")
def test_get_requires_sensor_uuid(self):
request = self.factory.get("/api/crop-zoning/area/")
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "sensor_uuid is required.")
def test_get_returns_pending_task_status_until_all_zones_complete(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="celery-task-1",
)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PROCESSING,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING")
self.assertEqual(response.data["data"]["task"]["total_zones"], 2)
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
def test_get_returns_area_when_all_tasks_complete(self):
crop_area = self._create_area()
for sequence in range(2):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
mock_dispatch.assert_called_once()
@patch("crop_zoning.services.create_zones_and_dispatch")
def test_get_creates_area_when_sensor_has_no_data(self, mock_create):
created_area = self._create_area(zone_count=0)
mock_create.return_value = (created_area, [])
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_create.assert_called_once()
self.assertEqual(mock_create.call_args.kwargs["sensor"], self.sensor)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_only_one_active_task_is_created_per_sensor(self, mock_delay):
crop_area = self._create_area()
zone0 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
zone1 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=150000,
area_hectares=15,
sequence=1,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
class Result:
id = "shared-task-id"
mock_delay.return_value = Result()
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 1)
zone0.refresh_from_db()
zone1.refresh_from_db()
self.assertEqual(zone0.task_id, "shared-task-id")
self.assertEqual(zone1.task_id, "shared-task-id")
@patch("crop_zoning.tasks.process_zone_soil_data.delay", side_effect=OperationalError("redis down"))
def test_get_generates_local_task_id_when_broker_is_unavailable(self, mock_delay):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
zone.refresh_from_db()
self.assertTrue(zone.task_id)
self.assertEqual(response.data["data"]["task"]["task_ids"], [zone.task_id])
self.assertEqual(response.data["data"]["task"]["status"], "PENDING")
self.assertIn("Celery broker unavailable", zone.processing_error)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_get_stores_task_id_and_reuses_it_on_next_request(self, mock_delay):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
class Result:
id = "persisted-task-id"
mock_delay.return_value = Result()
first_response = AreaView.as_view()(self._request())
self.assertEqual(first_response.status_code, 200)
zone.refresh_from_db()
self.assertEqual(zone.task_id, "persisted-task-id")
self.assertEqual(first_response.data["data"]["task"]["task_ids"], ["persisted-task-id"])
self.assertEqual(mock_delay.call_count, 1)
second_response = AreaView.as_view()(self._request())
self.assertEqual(second_response.status_code, 200)
self.assertEqual(second_response.data["data"]["task"]["task_ids"], ["persisted-task-id"])
self.assertEqual(second_response.data["data"]["task"]["status"], "PENDING")
self.assertEqual(mock_delay.call_count, 1)
+33 -5
View File
@@ -4,11 +4,12 @@ from rest_framework import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response from config.swagger import status_response
from .services import ( from .services import (
create_zones_and_dispatch, create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_cultivation_risk_payload, get_cultivation_risk_payload,
get_default_area_feature, get_default_area_feature,
get_initial_zones_payload, get_initial_zones_payload,
@@ -23,10 +24,31 @@ from .services import (
class AreaView(APIView): class AreaView(APIView):
@extend_schema( @extend_schema(
tags=["Crop Zoning"], tags=["Crop Zoning"],
responses={200: status_response("CropZoningAreaResponse", data=serializers.JSONField())}, parameters=[
OpenApiParameter(
name="sensor_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
description="UUID سنسور ارسالی کاربر برای گرفتن یا ساخت task فعال همان سنسور.",
)
],
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
) )
def get(self, request): def get(self, request):
return Response({"status": "success", "data": get_latest_area_payload()}, status=status.HTTP_200_OK) sensor_uuid = request.query_params.get("sensor_uuid")
try:
crop_area = ensure_latest_area_ready_for_processing(sensor_uuid=sensor_uuid)
except ValueError as exc:
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)
return Response({"status": "success", "data": get_latest_area_payload(crop_area)}, status=status.HTTP_200_OK)
class ProductsView(APIView): class ProductsView(APIView):
@@ -45,10 +67,16 @@ class ZonesInitialView(APIView):
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())}, responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
area_feature = request.data.get("area") or request.data.get("area_geojson") or get_default_area_feature() area_feature = (
request.data.get("area")
or request.data.get("area_geojson")
or request.data.get("boundary")
or get_default_area_feature()
)
cell_side_km = request.data.get("cell_side_km")
try: try:
crop_area, _zones = create_zones_and_dispatch(area_feature) crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
except ValueError as exc: 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: except ImproperlyConfigured as exc:
+79 -19
View File
@@ -1,46 +1,106 @@
# Development: volumes mount source so code updates apply without rebuild
services: services:
db: db:
image: mysql:8.0 image: docker.iranserver.com/mysql:8
container_name: ai-db
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-croplogic} MYSQL_DATABASE: ${DB_NAME:-ai}
MYSQL_USER: ${DB_USER:-croplogic} MYSQL_USER: ${DB_USER:-ai}
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
volumes: volumes:
- mysql_data:/var/lib/mysql - ai_mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# phpmyadmin: phpmyadmin:
# image: docker.iranserver.com/phpmyadmin image: docker-mirror.liara.ir/phpmyadmin:latest
# environment: container_name: ai-phpmyadmin
# PMA_HOST: db environment:
# PMA_PORT: 3306 PMA_HOST: db
# UPLOAD_LIMIT: 64M PMA_PORT: 3306
# ports: UPLOAD_LIMIT: 64M
# - "8081:80" ports:
# depends_on: - "8082:80"
# db: depends_on:
# condition: service_healthy db:
condition: service_healthy
redis:
image: redis:7-alpine
container_name: ai-redis
ports:
- "6380:6379"
qdrant:
image: qdrant/qdrant:latest
container_name: ai-qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
web: web:
build: . build:
command: python manage.py runserver 0.0.0.0:8000 context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-web
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
volumes: volumes:
- .:/app - .:/app
- ./logs:/app/logs
ports: ports:
- "8000:8000" - "8000:8000"
env_file: env_file:
- .env - .env
environment: environment:
DB_HOST: db DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
QDRANT_HOST: qdrant
QDRANT_PORT: 6333
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_started
qdrant:
condition: service_started
celery:
build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple
PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple
PYTHON_MIRROR: mirror2.chabokan.net
container_name: ai-celery
command: celery -A config worker -l info
volumes:
- .:/app
- ./logs:/app/logs
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
SKIP_MIGRATE: "1"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes: volumes:
mysql_data: ai_mysql_data:
qdrant_data:
+19
View File
@@ -5,6 +5,7 @@ from django.db import transaction
from account.seeds import seed_admin_user from account.seeds import seed_admin_user
from .models import Sensor from .models import Sensor
from .services import dispatch_sensor_zoning
ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") ADMIN_SENSOR_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
@@ -74,6 +75,22 @@ ADMIN_SENSOR_DATA = {
}, },
} }
ADMIN_SENSOR_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],
]
],
},
}
@transaction.atomic @transaction.atomic
def seed_admin_sensor(): def seed_admin_sensor():
@@ -89,4 +106,6 @@ def seed_admin_sensor():
"customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"], "customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"],
}, },
) )
if created:
dispatch_sensor_zoning(ADMIN_SENSOR_AREA_GEOJSON)
return sensor, created return sensor, created
+8 -1
View File
@@ -1,6 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Sensor from .models import Sensor
@@ -51,6 +50,14 @@ class SensorCreateSerializer(serializers.ModelSerializer):
return value return value
def create(self, validated_data):
validated_data.pop("area_geojson", None)
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data.pop("area_geojson", None)
return super().update(instance, validated_data)
class SensorToggleSerializer(serializers.Serializer): class SensorToggleSerializer(serializers.Serializer):
uuid_sensor = serializers.UUIDField() uuid_sensor = serializers.UUIDField()
+6 -2
View File
@@ -3,6 +3,11 @@ from django.db import transaction
from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature from crop_zoning.services import create_zones_and_dispatch, get_initial_zones_payload, normalize_area_feature
def dispatch_sensor_zoning(area_feature, sensor):
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), sensor=sensor)
return get_initial_zones_payload(crop_area)
def create_sensor_with_zoning(serializer, owner): def create_sensor_with_zoning(serializer, owner):
area_feature = serializer.validated_data.pop("area_geojson", None) area_feature = serializer.validated_data.pop("area_geojson", None)
@@ -11,7 +16,6 @@ def create_sensor_with_zoning(serializer, owner):
zoning_payload = None zoning_payload = None
if area_feature is not None: if area_feature is not None:
crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature)) zoning_payload = dispatch_sensor_zoning(area_feature, sensor)
zoning_payload = get_initial_zones_payload(crop_area)
return sensor, zoning_payload return sensor, zoning_payload
+23
View File
@@ -3,6 +3,7 @@ from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea from crop_zoning.models import CropArea
from sensor_hub.seeds import seed_admin_sensor
from sensor_hub.views import SensorListCreateView from sensor_hub.views import SensorListCreateView
@@ -63,3 +64,25 @@ class SensorListCreateViewTests(TestCase):
CropArea.objects.get().zone_count, CropArea.objects.get().zone_count,
) )
self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(CropArea.objects.count(), 1)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class SensorSeedTests(TestCase):
def test_seed_admin_sensor_dispatches_crop_logic_flow_on_create(self):
sensor, created = seed_admin_sensor()
self.assertTrue(created)
self.assertEqual(sensor.uuid_sensor.hex, "11111111111111111111111111111111")
self.assertEqual(CropArea.objects.count(), 1)
def test_seed_admin_sensor_does_not_dispatch_twice_for_existing_seed(self):
first_sensor, first_created = seed_admin_sensor()
second_sensor, second_created = seed_admin_sensor()
self.assertTrue(first_created)
self.assertFalse(second_created)
self.assertEqual(first_sensor.id, second_sensor.id)
self.assertEqual(CropArea.objects.count(), 1)