UPDATE
This commit is contained in:
+1
-2
@@ -30,10 +30,9 @@ COPY requirements.txt .
|
||||
|
||||
# Python mirrors
|
||||
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.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
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
@@ -5,3 +5,6 @@ class CropZoningConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,19 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from sensor_hub.models import Sensor
|
||||
|
||||
|
||||
class CropArea(models.Model):
|
||||
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)
|
||||
points = models.JSONField(default=list)
|
||||
center = models.JSONField(default=dict)
|
||||
@@ -216,4 +225,3 @@ class CropZoneAnalysis(models.Model):
|
||||
db_table = "crop_zone_analyses"
|
||||
ordering = ["crop_zone_id"]
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+498
-96
@@ -3,8 +3,10 @@ from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from kombu.exceptions import OperationalError
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from sensor_hub.models import Sensor
|
||||
|
||||
from external_api_adapter.adapter import request as external_request
|
||||
|
||||
@@ -23,17 +25,68 @@ from .models import (
|
||||
|
||||
EARTH_RADIUS_METERS = 6378137.0
|
||||
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)
|
||||
try:
|
||||
chunk_area = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
chunk_area = 0
|
||||
if chunk_area <= 0:
|
||||
raise ValueError("CROP_ZONE_CHUNK_AREA_SQM must be a positive number.")
|
||||
return chunk_area
|
||||
if chunk_area > 0:
|
||||
return math.sqrt(chunk_area) / 1000.0
|
||||
|
||||
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():
|
||||
@@ -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):
|
||||
if len(area_points) < 4:
|
||||
return area_points
|
||||
|
||||
width = math.sqrt(max(zone_area_sqm, 1))
|
||||
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
|
||||
longitude_factor = 111320.0 * math.cos(math.radians(center["latitude"]))
|
||||
if longitude_factor == 0:
|
||||
longitude_factor = 1.0
|
||||
|
||||
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)],
|
||||
]
|
||||
return build_square_points(
|
||||
center["longitude"] - delta_lng,
|
||||
center["latitude"] - delta_lat,
|
||||
center["longitude"] + delta_lng,
|
||||
center["latitude"] + delta_lat,
|
||||
)
|
||||
|
||||
|
||||
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_points = normalize_points(area_ring)
|
||||
area_center = calculate_center(area_points)
|
||||
total_area_sqm = polygon_area_sqm(area_ring)
|
||||
chunk_area_sqm = get_chunk_area_sqm()
|
||||
zone_count = max(1, math.ceil(total_area_sqm / chunk_area_sqm))
|
||||
resolved_cell_side_km = get_cell_side_km(cell_side_km)
|
||||
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 = []
|
||||
remaining_area = total_area_sqm
|
||||
base_longitude = area_center["longitude"]
|
||||
base_latitude = area_center["latitude"]
|
||||
sequence = 0
|
||||
current_lat = bbox["min_lat"]
|
||||
|
||||
for sequence in range(zone_count):
|
||||
zone_area_sqm = min(chunk_area_sqm, remaining_area) if sequence < zone_count - 1 else remaining_area
|
||||
if zone_area_sqm <= 0:
|
||||
zone_area_sqm = min(chunk_area_sqm, total_area_sqm)
|
||||
while current_lat < bbox["max_lat"] - 1e-12:
|
||||
next_lat = current_lat + latitude_step
|
||||
row_center_lat = current_lat + (latitude_step / 2.0)
|
||||
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
|
||||
zone_center = {
|
||||
"longitude": round(base_longitude + shift, 8),
|
||||
"latitude": round(base_latitude, 8),
|
||||
}
|
||||
zone_points = build_zone_square(area_points, zone_center, zone_area_sqm)
|
||||
while current_lng < bbox["max_lng"] - 1e-12:
|
||||
next_lng = current_lng + longitude_step
|
||||
zone_points = build_square_points(current_lng, current_lat, next_lng, next_lat)
|
||||
|
||||
if polygon_intersects_cell(area_points, zone_points):
|
||||
zone_geometry = {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[*zone_points, zone_points[0]]],
|
||||
}
|
||||
zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0])
|
||||
zones.append(
|
||||
{
|
||||
"zone_id": f"zone-{sequence}",
|
||||
"geometry": zone_geometry,
|
||||
"points": zone_points,
|
||||
"center": zone_center,
|
||||
"area_sqm": zone_area_sqm,
|
||||
"area_hectares": zone_area_sqm / 10000,
|
||||
"center": calculate_center(zone_points),
|
||||
"area_sqm": round(zone_area_sqm, 2),
|
||||
"area_hectares": round(zone_area_sqm / 10000, 4),
|
||||
"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 = {
|
||||
"type": "Feature",
|
||||
@@ -213,6 +403,7 @@ def split_area_into_zones(area_feature):
|
||||
"center": area_center,
|
||||
"area_sqm": round(total_area_sqm, 2),
|
||||
"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_hectares": total_area_sqm / 10000,
|
||||
"chunk_area_sqm": chunk_area_sqm,
|
||||
"cell_side_km": resolved_cell_side_km,
|
||||
"zone_count": zone_count,
|
||||
},
|
||||
"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):
|
||||
recommendation = getattr(zone, "recommendation", None)
|
||||
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):
|
||||
mappings = {
|
||||
"water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"},
|
||||
@@ -371,51 +661,7 @@ def analyze_and_store_zone_soil_data(zone_id):
|
||||
"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"]),
|
||||
},
|
||||
)
|
||||
persist_zone_analysis_metrics(zone, metrics)
|
||||
zone.processing_status = CropZone.STATUS_COMPLETED
|
||||
zone.processing_error = ""
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
task_identifier = getattr(async_result, "id", "") or str(uuid.uuid4())
|
||||
processing_error = ""
|
||||
except OperationalError as exc:
|
||||
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()
|
||||
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"]
|
||||
|
||||
with transaction.atomic():
|
||||
crop_area = CropArea.objects.create(
|
||||
sensor=sensor,
|
||||
geometry=area_data["geometry"],
|
||||
points=area_data["points"],
|
||||
center=area_data["center"],
|
||||
@@ -475,6 +823,9 @@ def create_zones_and_dispatch(area_feature):
|
||||
)
|
||||
|
||||
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)
|
||||
return crop_area, zones
|
||||
|
||||
@@ -493,11 +844,62 @@ def _zones_queryset(zone_ids=None):
|
||||
return queryset
|
||||
|
||||
|
||||
def get_latest_area_payload():
|
||||
area = CropArea.objects.order_by("-created_at", "-id").first()
|
||||
def get_latest_area_payload(area=None):
|
||||
area = area or CropArea.objects.order_by("-created_at", "-id").first()
|
||||
if area:
|
||||
return {"area": area.geometry}
|
||||
return {"area": get_default_area_feature()}
|
||||
zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error"))
|
||||
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):
|
||||
|
||||
+239
-1
@@ -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 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 = {
|
||||
@@ -45,3 +52,234 @@ class ZonesInitialViewTests(TestCase):
|
||||
response.data["data"]["zone_count"],
|
||||
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
@@ -4,11 +4,12 @@ from rest_framework import serializers, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
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 .services import (
|
||||
create_zones_and_dispatch,
|
||||
ensure_latest_area_ready_for_processing,
|
||||
get_cultivation_risk_payload,
|
||||
get_default_area_feature,
|
||||
get_initial_zones_payload,
|
||||
@@ -23,10 +24,31 @@ from .services import (
|
||||
class AreaView(APIView):
|
||||
@extend_schema(
|
||||
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):
|
||||
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):
|
||||
@@ -45,10 +67,16 @@ class ZonesInitialView(APIView):
|
||||
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
|
||||
)
|
||||
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:
|
||||
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:
|
||||
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ImproperlyConfigured as exc:
|
||||
|
||||
+79
-19
@@ -1,46 +1,106 @@
|
||||
# Development: volumes mount source so code updates apply without rebuild
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
image: docker.iranserver.com/mysql:8
|
||||
container_name: ai-db
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-croplogic}
|
||||
MYSQL_USER: ${DB_USER:-croplogic}
|
||||
MYSQL_DATABASE: ${DB_NAME:-ai}
|
||||
MYSQL_USER: ${DB_USER:-ai}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ai_mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# phpmyadmin:
|
||||
# image: docker.iranserver.com/phpmyadmin
|
||||
# environment:
|
||||
# PMA_HOST: db
|
||||
# PMA_PORT: 3306
|
||||
# UPLOAD_LIMIT: 64M
|
||||
# ports:
|
||||
# - "8081:80"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
phpmyadmin:
|
||||
image: docker-mirror.liara.ir/phpmyadmin:latest
|
||||
container_name: ai-phpmyadmin
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
UPLOAD_LIMIT: 64M
|
||||
ports:
|
||||
- "8082:80"
|
||||
depends_on:
|
||||
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:
|
||||
build: .
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
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-web
|
||||
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
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:
|
||||
db:
|
||||
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:
|
||||
mysql_data:
|
||||
ai_mysql_data:
|
||||
qdrant_data:
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.db import transaction
|
||||
from account.seeds import seed_admin_user
|
||||
|
||||
from .models import Sensor
|
||||
from .services import dispatch_sensor_zoning
|
||||
|
||||
|
||||
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
|
||||
def seed_admin_sensor():
|
||||
@@ -89,4 +106,6 @@ def seed_admin_sensor():
|
||||
"customized_sensors": ADMIN_SENSOR_DATA["customized_sensors"],
|
||||
},
|
||||
)
|
||||
if created:
|
||||
dispatch_sensor_zoning(ADMIN_SENSOR_AREA_GEOJSON)
|
||||
return sensor, created
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
from .models import Sensor
|
||||
|
||||
|
||||
@@ -51,6 +50,14 @@ class SensorCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
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):
|
||||
uuid_sensor = serializers.UUIDField()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
area_feature = serializer.validated_data.pop("area_geojson", None)
|
||||
|
||||
@@ -11,7 +16,6 @@ def create_sensor_with_zoning(serializer, 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)
|
||||
zoning_payload = dispatch_sensor_zoning(area_feature, sensor)
|
||||
|
||||
return sensor, zoning_payload
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from crop_zoning.models import CropArea
|
||||
from sensor_hub.seeds import seed_admin_sensor
|
||||
from sensor_hub.views import SensorListCreateView
|
||||
|
||||
|
||||
@@ -63,3 +64,25 @@ class SensorListCreateViewTests(TestCase):
|
||||
CropArea.objects.get().zone_count,
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user