5 Commits

Author SHA1 Message Date
arvinbehbahani562@gmail.com cb0ff19355 cleaned urls 2026-05-13 17:36:11 +03:30
arvinbehbahani562@gmail.com 2d5311702d finished the addresses app 2026-05-13 16:46:50 +03:30
arvinbehbahani562@gmail.com bb4186f3b5 cleaning code a bit 2026-05-13 05:11:59 +03:30
arvinbehbahani562@gmail.com 73cefcdeb8 almost finished with the addresses 2026-05-13 05:07:13 +03:30
arvinbehbahani562@gmail.com cf99039c8d added venv to .gitignore and initiated the app 2026-05-13 00:41:18 +03:30
46 changed files with 3811 additions and 4377 deletions
+1
View File
@@ -28,6 +28,7 @@ wheels/
# Virtual environments # Virtual environments
.venv/ .venv/
venvArvin/
venv/ venv/
ENV/ ENV/
env/ env/
View File
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from .models import Address, Province, City
class ProvinceAdmin(admin.ModelAdmin):
model = Province
list_display = (
"province_name",
"province_id",
)
admin.site.register(Province, ProvinceAdmin)
class CityAdmin(admin.ModelAdmin):
model = City
list_display = (
"city_name",
"city_local_id",
"province",
)
admin.site.register(City, CityAdmin)
class AddressAdmin(admin.ModelAdmin):
model = Address
list_display = [
"address_detail",
"province",
"city",
"user__email",
]
admin.site.register(Address, AddressAdmin)
View File
+45
View File
@@ -0,0 +1,45 @@
from rest_framework import serializers
from ..models import Address, City, Province
class ProvinceSerializer(serializers.ModelSerializer):
class Meta:
model = Province
fields = ["province_id", "province_name"]
class CitySerializer(serializers.ModelSerializer):
province_id = serializers.CharField(source="province.province_id")
class Meta:
model = City
fields = ["city_local_id", "city_name", "province_id"]
class AddressSerializer(serializers.ModelSerializer):
province_name = serializers.CharField(source="province.province_name", read_only=True)
city_name = serializers.CharField(source="city.city_name", read_only=True)
user_email = serializers.EmailField(source="user.email", read_only=True)
absolute_url = serializers.SerializerMethodField(method_name="get_absolute_url", read_only=True)
relative_url = serializers.URLField(source="get_absolute_relative_url")
class Meta:
model = Address
fields = ["province", "province_name", "city", "city_name", "postal_code", "address_detail", "relative_url", "absolute_url", "user_email", "created_at", "updated_at"]
read_only_fields = ["user_email", "absolute_url", "province_name", "city_name", "created_at", "updated_at"]
def to_representation(self, instance):
rep = super().to_representation(instance)
request = self.context.get("request")
if not request.parser_context.get("kwargs"):
rep.pop("province_name")
rep.pop("city_name")
rep.pop("created_at")
rep.pop("updated_at")
else:
rep.pop("absolute_url")
rep.pop("relative_url")
return rep
def get_absolute_url(self, obj):
request = self.context.get("request")
return request.build_absolute_uri(obj.pk)
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from . import views
app_name = "address-api-urls"
router = DefaultRouter()
router.register("info", views.AddressViewSet, basename="address-viewset")
urlpatterns = [
path("province/", views.ProvinceListAPIView.as_view(), name="get-provinces"),
path("province/<int:province_pk>/cities/", views.CityListAPIView.as_view(), name="get-cities")
]
urlpatterns += router.urls
+35
View File
@@ -0,0 +1,35 @@
from rest_framework.generics import ListAPIView
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from . import serializers
from ..models import Province, City, Address
class ProvinceListAPIView(ListAPIView):
serializer_class = serializers.ProvinceSerializer
queryset = Province.objects.all()
# def get(self, request, *args, **kwargs):
# return Response()
class CityListAPIView(ListAPIView):
serializer_class = serializers.CitySerializer
def get_queryset(self):
province_id = self.kwargs["province_pk"]
return City.objects.filter(province_id=province_id)
class AddressViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated,]
serializer_class = serializers.AddressSerializer
def get_queryset(self):
user = self.request.user
return Address.objects.filter(user=user)
def perform_create(self, serializer):
serializer.save(
user=self.request.user
)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AddressesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'addresses'
View File
@@ -0,0 +1,76 @@
from django.core.management.base import BaseCommand
from faker import Faker
from django.contrib.auth import get_user_model
from random import choice, randint
from django.db.models import Q
from ...models import Address, Province, City
user = get_user_model()
class Command(BaseCommand):
help = "fake address creator"
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
super().__init__(stdout, stderr, no_color, force_color)
self.faker = Faker()
def handle(self, *args, **options):
print("creating fake users...")
user1 = user.objects.create_user(
phone_number=self.faker.phone_number(),
email=self.faker.email(),
username=self.faker.user_name(),
password="string123",
)
user2 = user.objects.create_user(
phone_number=self.faker.phone_number(),
email=self.faker.email(),
username=self.faker.user_name(),
password="string123",
)
print("operation successful...")
# print(City.objects.values_list("city_local_id", flat=True)[:10])
print("creating fake addresses")
for _ in range(10):
province = Province.objects.get(pk=randint(0, 30))
city_list = list(City.objects.filter(province=province).values_list(
"city_local_id",
flat=True,))
city = City.objects.get(Q(province=province) & Q(city_local_id=choice(city_list)))
address = Address.objects.create(
user=user1,
province=province,
city=city,
postal_code=self.faker.postalcode(),
address_detail=self.faker.address(),
)
for _ in range(5):
province = Province.objects.get(pk=randint(0, 30))
city_list = City.objects.filter(province=province).values_list(
"city_local_id",
flat=True,
)
city = City.objects.get(Q(province=province) & Q(city_local_id=choice(city_list)))
address = Address.objects.create(
user=user2,
province=province,
city=city,
postal_code=self.faker.postalcode(),
address_detail=self.faker.address(),
)
print("operation successful...")
self.stdout.write(
self.style.SUCCESS("fake addresses and accounts created successfully...")
)
@@ -0,0 +1,41 @@
from django.core.management.base import BaseCommand #, CommandError
import json
from pathlib import Path
from ...models import Province, City
class Command(BaseCommand):
help = "push all provinces and cities to database"
def handle(self, *args, **options):
json_file_path = Path("/app/addresses/provinces_cities.json")
with open(json_file_path, "r", encoding="utf-8") as json_file:
all_data = json.load(json_file)
print("injecting all provinces to the database...")
for province_data in all_data:
province = Province.objects.get_or_create(
province_id=int(province_data.get("provinceId")),
province_name=str(province_data.get("provinceName")),
)
print("task complete :)")
print("injecting all the cities to the database ")
for city_data in all_data:
city = City.objects.get_or_create(
city_local_id=str(city_data.get("cityId")),
city_name=str(city_data.get("cityName")),
province_id=int(city_data.get("provinceId"))
)
print("task complete :)")
self.stdout.write(
self.style.SUCCESS(r"all the data successfully injected in the database :>")
)
+49
View File
@@ -0,0 +1,49 @@
# Generated by Django 5.1.15 on 2026-05-12 23:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Province',
fields=[
('province_id', models.IntegerField(primary_key=True, serialize=False)),
('province_name', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='City',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('city_local_id', models.CharField(blank=True, max_length=3, null=True)),
('city_name', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('province', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='city', to='addresses.province')),
],
),
migrations.CreateModel(
name='Address',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('address_detail', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='city', to='addresses.city')),
('province', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='province', to='addresses.province')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-05-13 00:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('addresses', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='address',
name='postal_code',
field=models.CharField(max_length=15, null=True),
),
]
View File
+43
View File
@@ -0,0 +1,43 @@
from django.db import models
from config.settings import AUTH_USER_MODEL
from django.urls import reverse
class Province(models.Model):
province_id = models.IntegerField(primary_key=True)
province_name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f"{self.province_id}: {self.province_name}"
class City(models.Model):
province = models.ForeignKey("Province", on_delete=models.CASCADE, related_name="city")
city_local_id = models.CharField(max_length=3, null=True, blank=True)
city_name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f"{self.city_local_id}: {self.city_name}"
class Address(models.Model):
user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE)
province = models.ForeignKey("Province", on_delete=models.CASCADE, related_name="province")
city = models.ForeignKey("City", on_delete=models.CASCADE, related_name="city")
postal_code = models.CharField(max_length=15, null=True)
address_detail = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f"{self.address_detail[:10]}..."
def get_absolute_relative_url(self):
return reverse("addresses:address-api-urls:address-viewset-detail", kwargs={"pk": self.pk})
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path, include
app_name = "addresses"
urlpatterns = [
path("", include("addresses.api.urls"), name="address-api")
]
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
Binary file not shown.
+1
View File
@@ -63,6 +63,7 @@ INSTALLED_APPS = [
"drf_spectacular", "drf_spectacular",
"drf_spectacular_sidecar", "drf_spectacular_sidecar",
"corsheaders", "corsheaders",
"addresses",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+5 -1
View File
@@ -3,6 +3,7 @@ from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [ urlpatterns = [
path("api-auth/", include("rest_framework.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
@@ -18,12 +19,14 @@ urlpatterns = [
path("api/crop-health/", include("crop_health.urls")), path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")), path("api/soil/", include("soil.urls")),
path("api/location-data/", include("crop_zoning.urls")), path("api/crop-zoning/", include("crop_zoning.urls")),
# path("api/yield-harvest/", include("yield_harvest.urls")), # path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")), path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")),
path("api/pest-detection/", include("pest_detection.urls")), path("api/pest-detection/", include("pest_detection.urls")),
path("api/pest-disease/", include("pest_detection.pest_disease_urls")), path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
path("api/sensors/", include("device_hub.comparison_urls")),
path("api/irrigation/", include("irrigation.urls")), path("api/irrigation/", include("irrigation.urls")),
path("api/weather/", include("water.weather_urls")), path("api/weather/", include("water.weather_urls")),
@@ -39,4 +42,5 @@ urlpatterns = [
path("api/farmer-todos/", include("farmer_todos.urls")), path("api/farmer-todos/", include("farmer_todos.urls")),
path("api/sensor-external-api/", include("device_hub.sensor_external_api_urls")), path("api/sensor-external-api/", include("device_hub.sensor_external_api_urls")),
path("api/address/", include("addresses.urls"))
] ]
-37
View File
@@ -1,37 +0,0 @@
from rest_framework import serializers
class LocationDataQuerySerializer(serializers.Serializer):
lat = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
lon = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
farm_uuid = serializers.UUIDField(required=False)
class LocationDataUpsertSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=False)
lat = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
lon = serializers.DecimalField(max_digits=18, decimal_places=12, required=False)
farm_boundary = serializers.JSONField(required=False)
block_layout = serializers.JSONField(required=False)
class FarmUUIDRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
class RemoteSensingQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True)
page = serializers.IntegerField(required=False, min_value=1)
page_size = serializers.IntegerField(required=False, min_value=1)
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
class ClusterBlockLiveQuerySerializer(serializers.Serializer):
start_date = serializers.DateField(required=False)
end_date = serializers.DateField(required=False)
farm_uuid = serializers.UUIDField(required=False)
class KOptionActivateSerializer(serializers.Serializer):
requested_k = serializers.IntegerField(min_value=1)
+78 -619
View File
@@ -1,11 +1,9 @@
import math import math
import hashlib
from copy import deepcopy from copy import deepcopy
from decimal import Decimal from decimal import Decimal
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from celery.result import AsyncResult from celery.result import AsyncResult
from kombu.exceptions import OperationalError from kombu.exceptions import OperationalError
from django.db import transaction from django.db import transaction
@@ -60,9 +58,6 @@ TASK_STATE_RETRY = "RETRY"
TASK_STATE_SUCCESS = "SUCCESS" TASK_STATE_SUCCESS = "SUCCESS"
TASK_STATE_FAILURE = "FAILURE" TASK_STATE_FAILURE = "FAILURE"
TASK_STATE_REVOKED = "REVOKED" TASK_STATE_REVOKED = "REVOKED"
AI_LOCATION_DATA_PATH = "/api/location-data/"
AI_REMOTE_SENSING_PATH = "/api/location-data/remote-sensing/"
AI_CLUSTER_RECOMMENDATIONS_PATH = "/api/location-data/remote-sensing/cluster-recommendations/"
def get_default_cell_side_km(): def get_default_cell_side_km():
@@ -549,101 +544,8 @@ def build_area_zone_payload(zone):
return base_payload return base_payload
def _serialize_cluster_candidate(candidate_payload):
if not isinstance(candidate_payload, dict):
return None
return {
"plantId": candidate_payload.get("plant_id"),
"plantName": str(candidate_payload.get("plant_name") or ""),
"position": candidate_payload.get("position"),
"stage": str(candidate_payload.get("stage") or ""),
"score": candidate_payload.get("score"),
"predictedYield": candidate_payload.get("predicted_yield"),
"predictedYieldTons": candidate_payload.get("predicted_yield_tons"),
"biomass": candidate_payload.get("biomass"),
"maxLai": candidate_payload.get("max_lai"),
"simulationEngine": candidate_payload.get("simulation_engine"),
"simulationModelName": candidate_payload.get("simulation_model_name"),
"simulationWarning": str(candidate_payload.get("simulation_warning") or ""),
"supportingMetrics": deepcopy(candidate_payload.get("supporting_metrics") or {}),
}
def _get_zone_ai_cluster_payload(zone):
analysis = getattr(zone, "analysis", None)
raw_response = getattr(analysis, "raw_response", None)
if not isinstance(raw_response, dict):
return {}
cluster_payload = raw_response.get("cluster_recommendation") or {}
if isinstance(cluster_payload, dict):
return cluster_payload
return {}
def _build_zone_cluster_info(zone, cluster_payload):
cluster_block = cluster_payload.get("cluster_block") or {}
return {
"blockCode": str(cluster_payload.get("block_code") or ""),
"clusterUuid": str(cluster_payload.get("cluster_uuid") or cluster_block.get("uuid") or zone.zone_id),
"subBlockCode": str(cluster_payload.get("sub_block_code") or cluster_block.get("sub_block_code") or zone.zone_id),
"clusterLabel": cluster_payload.get("cluster_label"),
"cellCount": cluster_block.get("cell_count"),
"cellCodes": deepcopy(cluster_block.get("cell_codes") or []),
"centerCellCode": cluster_block.get("center_cell_code"),
"centerCellLat": cluster_block.get("center_cell_lat"),
"centerCellLon": cluster_block.get("center_cell_lon"),
"sourceMetadata": deepcopy(cluster_payload.get("source_metadata") or {}),
}
def _build_zone_cluster_metrics(cluster_payload):
if not cluster_payload:
return {
"satelliteMetrics": {},
"sensorMetrics": {},
"resolvedMetrics": {},
"criteria": [],
}
suggested_plant = cluster_payload.get("suggested_plant")
return {
"satelliteMetrics": deepcopy(cluster_payload.get("satellite_metrics") or {}),
"sensorMetrics": deepcopy(cluster_payload.get("sensor_metrics") or {}),
"resolvedMetrics": deepcopy(cluster_payload.get("resolved_metrics") or {}),
"criteria": _build_metric_criteria(cluster_payload, suggested_plant),
}
def _build_zone_crop_prediction(cluster_payload):
if not cluster_payload:
return {"suggestedPlant": None, "candidatePlants": []}
return {
"suggestedPlant": _serialize_cluster_candidate(cluster_payload.get("suggested_plant")),
"candidatePlants": [
item
for item in (
_serialize_cluster_candidate(candidate_payload)
for candidate_payload in (cluster_payload.get("candidate_plants") or [])
)
if item is not None
],
}
def _attach_ai_zone_payload(base_payload, zone):
cluster_payload = _get_zone_ai_cluster_payload(zone)
base_payload["clusterInfo"] = _build_zone_cluster_info(zone, cluster_payload)
base_payload["clusterMetrics"] = _build_zone_cluster_metrics(cluster_payload)
base_payload["cropPrediction"] = _build_zone_crop_prediction(cluster_payload)
return base_payload
def _build_area_layer_zone_base_payload(zone): def _build_area_layer_zone_base_payload(zone):
return _attach_ai_zone_payload( return {
{
"zoneId": zone.zone_id, "zoneId": zone.zone_id,
"zoneUuid": str(zone.uuid), "zoneUuid": str(zone.uuid),
"geometry": zone.geometry, "geometry": zone.geometry,
@@ -653,9 +555,7 @@ def _build_area_layer_zone_base_payload(zone):
"sequence": zone.sequence, "sequence": zone.sequence,
"processing_status": zone.processing_status, "processing_status": zone.processing_status,
"processing_error": zone.processing_error, "processing_error": zone.processing_error,
}, }
zone,
)
def build_water_need_area_zone_payload(zone): def build_water_need_area_zone_payload(zone):
@@ -1007,478 +907,73 @@ def get_farm_for_uuid(farm_uuid, owner=None):
raise ValueError("Farm not found.") from exc raise ValueError("Farm not found.") from exc
def _raise_ai_response_error(response, default_message):
payload = response.data if isinstance(response.data, dict) else {}
message = payload.get("msg") or payload.get("message") or default_message
if response.status_code >= 500:
raise ImproperlyConfigured(message)
raise ValueError(message)
def _unwrap_ai_response(response, *, expected_statuses):
if response.status_code not in expected_statuses:
_raise_ai_response_error(response, f"AI location_data API returned status {response.status_code}.")
payload = response.data if isinstance(response.data, dict) else {}
if "data" in payload:
return payload["data"]
return payload
def _request_ai_location_data(path, *, method="GET", payload=None, query=None):
return external_request(
"ai",
path,
method=method,
payload=payload,
query=query,
)
def _feature_from_geometry(geometry):
if not isinstance(geometry, dict):
return get_default_area_feature()
if geometry.get("type") == "Feature":
return normalize_area_feature(geometry)
return normalize_area_feature(
{
"type": "Feature",
"properties": {},
"geometry": geometry,
}
)
def _upsert_crop_area_snapshot(farm, area_feature):
normalized_feature = normalize_area_feature(area_feature)
ring = get_polygon_ring(normalized_feature)
points = normalize_points(ring)
area_sqm = round(polygon_area_sqm(ring), 2)
area_hectares = round(area_sqm / 10000.0, 4)
defaults = {
"geometry": normalized_feature,
"points": points,
"center": calculate_center(points),
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"chunk_area_sqm": round(get_chunk_area_sqm(), 2),
}
crop_area = farm.current_crop_area
if crop_area is None:
crop_area = CropArea.objects.create(
farm=farm,
zone_count=0,
**defaults,
)
farm.current_crop_area = crop_area
farm.save(update_fields=["current_crop_area", "updated_at"])
return crop_area
for field_name, value in defaults.items():
setattr(crop_area, field_name, value)
crop_area.save(update_fields=[*defaults.keys(), "updated_at"])
return crop_area
def _get_farm_area_feature(farm, fallback=None):
if fallback is not None:
return normalize_area_feature(fallback)
crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first()
if crop_area is not None and crop_area.geometry:
return normalize_area_feature(crop_area.geometry)
return get_default_area_feature()
def _build_processing_layer_payload(farm, remote_payload, *, page, page_size):
area_feature = _get_farm_area_feature(
farm,
fallback=((remote_payload.get("location") or {}).get("farm_boundary")),
)
location = remote_payload.get("location") or {}
run = remote_payload.get("run") or {}
status_value = str(remote_payload.get("status") or "").lower()
task_status = "PROCESSING" if status_value == "processing" else "PENDING"
return {
"task": {
"status": task_status,
"stage": status_value or "queued",
"stage_label": "در حال دریافت تقسیم بندی و متریک ها از AI",
"area_uuid": str(getattr(farm.current_crop_area, "uuid", "")) if farm.current_crop_area_id else "",
"total_zones": 0,
"completed_zones": 0,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"remaining_zones": 0,
"progress_percent": 0,
"summary": {
"done": 0,
"in_progress": 0,
"remaining": 0,
"failed": 0,
},
"message": "تقسیم بندی و متریک های کشت در AI در حال آماده سازی است.",
"failed_zone_errors": [],
"cell_side_km": round(get_default_cell_side_km(), 4),
"task_id": remote_payload.get("task_id") or (run.get("metadata") or {}).get("task_id"),
},
"area": area_feature,
"zones": [],
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": [],
"pagination": {
"page": page,
"page_size": page_size,
"total_pages": 0,
"total_zones": 0,
"returned_zones": 0,
"has_next": False,
"has_previous": False,
},
}
def _hash_color(value):
digest = hashlib.md5(str(value).encode("utf-8")).hexdigest()
return f"#{digest[:6]}"
def _clamp_percent(value, *, default=0):
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0, min(100, round(numeric)))
def _extract_zone_points(geometry):
coordinates = (geometry or {}).get("coordinates") or []
if not coordinates or not coordinates[0]:
return []
ring = coordinates[0]
return ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
def _build_metric_criteria(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = []
ndvi_score = _clamp_percent((resolved_metrics.get("ndvi") or 0) * 100)
criteria.append({"name": "NDVI", "value": ndvi_score})
ndwi_raw = resolved_metrics.get("ndwi")
ndwi_score = _clamp_percent(((float(ndwi_raw) + 1.0) / 2.0) * 100) if ndwi_raw is not None else 0
criteria.append({"name": "NDWI", "value": ndwi_score})
soil_moisture = resolved_metrics.get("soil_moisture")
if soil_moisture is not None:
criteria.append({"name": "رطوبت خاک", "value": _clamp_percent(soil_moisture)})
nitrogen = resolved_metrics.get("nitrogen")
if nitrogen is not None:
criteria.append({"name": "نیتروژن", "value": _clamp_percent(float(nitrogen) * 4)})
if suggested_plant is not None:
criteria.append({"name": "امتیاز AI", "value": _clamp_percent(suggested_plant.get("score"))})
return criteria[:4]
def _derive_layer_bundle(cluster_payload, suggested_plant):
resolved_metrics = cluster_payload.get("resolved_metrics") or {}
criteria = _build_metric_criteria(cluster_payload, suggested_plant)
soil_score = next((item["value"] for item in criteria if item["name"] in {"NDVI", "نیتروژن"}), 0)
if soil_score >= 75:
soil_level = "high"
elif soil_score >= 45:
soil_level = "medium"
else:
soil_level = "low"
moisture_value = resolved_metrics.get("soil_moisture")
ndwi_raw = resolved_metrics.get("ndwi")
if moisture_value is not None:
water_score = _clamp_percent(100 - float(moisture_value))
water_value_text = f"{round(float(moisture_value), 2)}% soil moisture"
elif ndwi_raw is not None:
water_score = _clamp_percent(100 - (((float(ndwi_raw) + 1.0) / 2.0) * 100))
water_value_text = f"NDWI {round(float(ndwi_raw), 3)}"
else:
water_score = 0
water_value_text = ""
if water_score >= 65:
water_level = "high"
elif water_score >= 35:
water_level = "medium"
else:
water_level = "low"
ai_score = _clamp_percent((suggested_plant or {}).get("score"))
risk_score = max(0, min(100, round((100 - soil_score) * 0.6 + (100 - ai_score) * 0.4)))
if risk_score >= 65:
risk_level = "high"
elif risk_score >= 35:
risk_level = "medium"
else:
risk_level = "low"
return {
"criteria": criteria,
"soil": {
"score": soil_score,
"level": soil_level,
"color": _get_level_color_map("soil", soil_level),
},
"water": {
"level": water_level,
"value": water_value_text,
"color": _get_level_color_map("water", water_level),
},
"risk": {
"level": risk_level,
"color": _get_level_color_map("risk", risk_level),
},
}
def _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload=None):
location_payload = remote_payload.get("location") or {}
area_feature = _get_farm_area_feature(
farm,
fallback=location_payload.get("farm_boundary"),
)
crop_area = _upsert_crop_area_snapshot(farm, area_feature)
subdivision_result = remote_payload.get("subdivision_result") or {}
cluster_blocks = subdivision_result.get("cluster_blocks") or []
recommendation_map = {}
for cluster in (recommendation_payload or {}).get("clusters", []):
cluster_uuid = str(cluster.get("cluster_uuid") or ((cluster.get("cluster_block") or {}).get("uuid") or ""))
if cluster_uuid:
recommendation_map[cluster_uuid] = cluster
existing_zones = {zone.zone_id: zone for zone in crop_area.zones.all()}
retained_zone_ids = []
with transaction.atomic():
for sequence, cluster_block in enumerate(
sorted(cluster_blocks, key=lambda item: (item.get("cluster_label") is None, item.get("cluster_label"), item.get("sub_block_code") or ""))
):
zone_id = str(cluster_block.get("uuid") or cluster_block.get("sub_block_code") or f"cluster-{sequence}")
geometry = cluster_block.get("geometry") or {}
points = _extract_zone_points(geometry)
area_sqm = round(polygon_area_sqm((geometry.get("coordinates") or [[points]])[0]), 2) if geometry.get("coordinates") else 0.0
area_hectares = round(area_sqm / 10000.0, 4)
zone_defaults = {
"geometry": geometry,
"points": points,
"center": {
"longitude": float(cluster_block.get("centroid_lon") or 0),
"latitude": float(cluster_block.get("centroid_lat") or 0),
},
"area_sqm": area_sqm,
"area_hectares": area_hectares,
"sequence": sequence,
"processing_status": CropZone.STATUS_COMPLETED,
"processing_error": "",
"task_id": str(((remote_payload.get("run") or {}).get("metadata") or {}).get("task_id") or ""),
}
zone = existing_zones.get(zone_id)
if zone is None:
zone = CropZone.objects.create(crop_area=crop_area, zone_id=zone_id, **zone_defaults)
else:
for field_name, value in zone_defaults.items():
setattr(zone, field_name, value)
zone.save(update_fields=[*zone_defaults.keys(), "updated_at"])
retained_zone_ids.append(zone.zone_id)
cluster_payload = recommendation_map.get(zone_id, {})
suggested_plant = cluster_payload.get("suggested_plant")
layer_bundle = _derive_layer_bundle(cluster_payload, suggested_plant)
product_id = str((suggested_plant or {}).get("plant_name") or (suggested_plant or {}).get("plant_id") or "")
if product_id:
product, _ = CropProduct.objects.update_or_create(
product_id=product_id,
defaults={
"label": str((suggested_plant or {}).get("plant_name") or product_id),
"color": _hash_color(product_id),
},
)
recommendation, _ = CropZoneRecommendation.objects.update_or_create(
crop_zone=zone,
defaults={
"product": product,
"match_percent": _clamp_percent((suggested_plant or {}).get("score")),
"water_need": layer_bundle["water"]["value"],
"estimated_profit": (
f"{round(float((suggested_plant or {}).get('predicted_yield_tons')), 2)} ton/ha"
if (suggested_plant or {}).get("predicted_yield_tons") is not None
else ""
),
"reason": "پیشنهاد محصول بر اساس متریک های سنجش از دور و تحلیل کلاستر AI تولید شده است.",
},
)
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(layer_bundle["criteria"])
]
)
else:
CropZoneRecommendation.objects.filter(crop_zone=zone).delete()
CropZoneWaterNeedLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["water"],
)
CropZoneSoilQualityLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["soil"],
)
CropZoneCultivationRiskLayer.objects.update_or_create(
crop_zone=zone,
defaults=layer_bundle["risk"],
)
CropZoneAnalysis.objects.update_or_create(
crop_zone=zone,
defaults={
"source": "ai_location_data",
"external_record_id": zone_id,
"latitude": zone.center.get("latitude"),
"longitude": zone.center.get("longitude"),
"raw_response": {
"remote_sensing": remote_payload,
"cluster_recommendation": cluster_payload,
},
"depths": [],
},
)
CropZone.objects.filter(crop_area=crop_area).exclude(zone_id__in=retained_zone_ids).delete()
crop_area.zone_count = len(retained_zone_ids)
crop_area.chunk_area_sqm = subdivision_result.get("chunk_size_sqm") or crop_area.chunk_area_sqm
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"])
return crop_area
def _get_ai_remote_sensing_payload(*, farm_uuid, page, page_size):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="GET",
query={
"farm_uuid": str(farm_uuid),
"page": page,
"page_size": page_size,
},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _start_ai_remote_sensing(*, farm_uuid):
response = _request_ai_location_data(
AI_REMOTE_SENSING_PATH,
method="POST",
payload={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={202})
def _get_ai_cluster_recommendations(*, farm_uuid):
response = _request_ai_location_data(
AI_CLUSTER_RECOMMENDATIONS_PATH,
method="GET",
query={"farm_uuid": str(farm_uuid)},
)
return _unwrap_ai_response(response, expected_statuses={200})
def _build_ai_layer_context(remote_payload, recommendation_payload=None):
location = deepcopy(remote_payload.get("location") or {})
subdivision_result = deepcopy(remote_payload.get("subdivision_result") or {})
run = deepcopy(remote_payload.get("run") or {})
return {
"source": {
"type": "ai_location_data",
"service": "ai",
"status": str(remote_payload.get("status") or ""),
},
"location": location,
"farmerBlocks": deepcopy(((location.get("block_layout") or {}).get("blocks") or [])),
"clusterBlocks": deepcopy(subdivision_result.get("cluster_blocks") or []),
"subdivisionSummary": {
"clusterCount": subdivision_result.get("cluster_count")
or len(subdivision_result.get("cluster_blocks") or []),
"chunkSizeSqm": subdivision_result.get("chunk_size_sqm") or remote_payload.get("chunk_size_sqm"),
"selectedFeatures": deepcopy(
subdivision_result.get("selected_features")
or run.get("selected_features")
or []
),
"temporalExtent": deepcopy(remote_payload.get("temporal_extent") or {}),
"summary": deepcopy(remote_payload.get("summary") or {}),
},
"registeredPlants": deepcopy((recommendation_payload or {}).get("registered_plants") or []),
"evaluatedPlantCount": (recommendation_payload or {}).get("evaluated_plant_count"),
}
def _get_latest_layer_payload_from_ai(zone_builder, *, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
farm = get_farm_for_uuid(farm_uuid, owner=owner)
remote_payload = _get_ai_remote_sensing_payload(
farm_uuid=farm_uuid,
page=page,
page_size=page_size,
)
remote_status = str(remote_payload.get("status") or "").lower()
if remote_status == "not_found":
remote_payload = _start_ai_remote_sensing(farm_uuid=farm_uuid)
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
if remote_status != "success":
return _build_processing_layer_payload(farm, remote_payload, page=page, page_size=page_size)
recommendation_payload = None
try:
recommendation_payload = _get_ai_cluster_recommendations(farm_uuid=farm_uuid)
except ValueError:
recommendation_payload = None
crop_area = _sync_crop_area_from_ai(farm, remote_payload, recommendation_payload)
return _build_latest_area_layer_payload(
zone_builder,
area=crop_area,
page=page,
page_size=page_size,
extra_payload=_build_ai_layer_context(remote_payload, recommendation_payload),
)
def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None): def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None):
farm = get_farm_for_uuid(farm_uuid, owner=owner) farm = get_farm_for_uuid(farm_uuid, owner=owner)
return _upsert_crop_area_snapshot(farm, _get_farm_area_feature(farm, fallback=area_feature)) latest_area = CropArea.objects.filter(farm=farm).order_by("-created_at", "-id").first()
if latest_area is None:
latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), farm=farm)
return latest_area
zones = create_missing_zones_for_area(latest_area)
for zone in zones:
ensure_rule_based_zone_data(zone)
stale_zone_ids = _get_stale_zone_ids(zones)
zones_to_dispatch = [
zone.id
for zone in zones
if zone.processing_status != CropZone.STATUS_COMPLETED
and zone.id not in stale_zone_ids
and not (zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING} and zone.task_id)
]
if stale_zone_ids:
dispatch_zone_processing_tasks(zone_ids=stale_zone_ids, force=True)
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, farm=None): def create_zones_and_dispatch(area_feature, cell_side_km=None, farm=None):
if farm is None: ensure_products_exist()
raise ValueError("farm is required.") area_feature = normalize_area_feature(area_feature)
zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km)
area_data = zoning_result["area"]
crop_area = _upsert_crop_area_snapshot(farm, area_feature) with transaction.atomic():
CropZone.objects.filter(crop_area=crop_area).delete() crop_area = CropArea.objects.create(
crop_area.zone_count = 0 farm=farm,
crop_area.chunk_area_sqm = round(get_chunk_area_sqm(cell_side_km), 2) geometry=area_data["geometry"],
crop_area.save(update_fields=["zone_count", "chunk_area_sqm", "updated_at"]) points=area_data["points"],
return crop_area, [] center=area_data["center"],
area_sqm=round(area_data["area_sqm"], 2),
area_hectares=round(area_data["area_hectares"], 4),
chunk_area_sqm=round(area_data["chunk_area_sqm"], 2),
zone_count=area_data["zone_count"],
)
zones = 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.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
def _zones_queryset(zone_ids=None): def _zones_queryset(zone_ids=None):
@@ -1487,7 +982,6 @@ def _zones_queryset(zone_ids=None):
"water_need_layer", "water_need_layer",
"soil_quality_layer", "soil_quality_layer",
"cultivation_risk_layer", "cultivation_risk_layer",
"analysis",
).prefetch_related( ).prefetch_related(
Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id")) Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id"))
).order_by("sequence", "id") ).order_by("sequence", "id")
@@ -1523,13 +1017,7 @@ def _get_idle_area_payload(page, page_size):
} }
def _build_latest_area_layer_payload( def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
zone_builder,
area=None,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
extra_payload=None,
):
area = area or CropArea.objects.order_by("-created_at", "-id").first() area = area or CropArea.objects.order_by("-created_at", "-id").first()
if not area: if not area:
return _get_idle_area_payload(page, page_size) return _get_idle_area_payload(page, page_size)
@@ -1570,7 +1058,7 @@ def _build_latest_area_layer_payload(
if total_zones: if total_zones:
progress_percent = round((completed_zones / total_zones) * 100, 2) progress_percent = round((completed_zones / total_zones) * 100, 2)
payload = { return {
"task": { "task": {
"status": task_status, "status": task_status,
"stage": current_stage, "stage": current_stage,
@@ -1619,46 +1107,34 @@ def _build_latest_area_layer_payload(
"has_previous": page > 1 and total_pages > 0, "has_previous": page > 1 and total_pages > 0,
}, },
} }
if extra_payload:
payload.update(extra_payload)
return payload
def get_latest_area_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai( return _build_latest_area_layer_payload(build_area_zone_payload, area=area, page=page, page_size=page_size)
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=page,
page_size=page_size,
)
def get_latest_water_need_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): def get_latest_water_need_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai( return _build_latest_area_layer_payload(
build_water_need_area_zone_payload, build_water_need_area_zone_payload,
farm_uuid=farm_uuid, area=area,
owner=owner,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
def get_latest_soil_quality_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): def get_latest_soil_quality_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai( return _build_latest_area_layer_payload(
build_soil_quality_area_zone_payload, build_soil_quality_area_zone_payload,
farm_uuid=farm_uuid, area=area,
owner=owner,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
def get_latest_cultivation_risk_payload(*, farm_uuid, owner=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): def get_latest_cultivation_risk_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE):
return _get_latest_layer_payload_from_ai( return _build_latest_area_layer_payload(
build_cultivation_risk_area_zone_payload, build_cultivation_risk_area_zone_payload,
farm_uuid=farm_uuid, area=area,
owner=owner,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
@@ -1721,24 +1197,10 @@ def get_cultivation_risk_payload(zone_ids=None):
} }
def get_zone_details_payload(zone_id, *, farm_uuid=None, owner=None): def get_zone_details_payload(zone_id):
zone_filters = {"zone_id": zone_id} zone = _zones_queryset().get(zone_id=zone_id)
if farm_uuid:
_get_latest_layer_payload_from_ai(
build_area_zone_payload,
farm_uuid=farm_uuid,
owner=owner,
page=1,
page_size=DEFAULT_ZONE_PAGE_SIZE,
)
zone_filters["crop_area__farm__farm_uuid"] = farm_uuid
if owner is not None:
zone_filters["crop_area__farm__owner"] = owner
zone = _zones_queryset().get(**zone_filters)
recommendation = getattr(zone, "recommendation", None) recommendation = getattr(zone, "recommendation", None)
criteria = recommendation.criteria.all() if recommendation else [] criteria = recommendation.criteria.all() if recommendation else []
cluster_payload = _get_zone_ai_cluster_payload(zone)
return { return {
"zoneId": zone.zone_id, "zoneId": zone.zone_id,
"crop": recommendation.product.product_id if recommendation else "", "crop": recommendation.product.product_id if recommendation else "",
@@ -1748,7 +1210,4 @@ def get_zone_details_payload(zone_id, *, farm_uuid=None, owner=None):
"reason": recommendation.reason if recommendation else "", "reason": recommendation.reason if recommendation else "",
"criteria": [{"name": item.name, "value": item.value} for item in criteria], "criteria": [{"name": item.name, "value": item.value} for item in criteria],
"area_hectares": zone.area_hectares, "area_hectares": zone.area_hectares,
"clusterInfo": _build_zone_cluster_info(zone, cluster_payload),
"clusterMetrics": _build_zone_cluster_metrics(cluster_payload),
"cropPrediction": _build_zone_crop_prediction(cluster_payload),
} }
+372 -159
View File
@@ -1,206 +1,419 @@
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase, override_settings
from django.urls import Resolver404, resolve from django.utils import timezone
from kombu.exceptions import OperationalError
from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.test import APIRequestFactory, force_authenticate
from external_api_adapter.adapter import AdapterResponse from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import (
AreaView,
CultivationRiskView,
SoilQualityView,
WaterNeedView,
ZonesInitialView,
)
from farm_hub.models import FarmHub, FarmType from farm_hub.models import FarmHub, FarmType
from .views import (
ClusterBlockLiveView, AREA_GEOJSON = {
ClusterRecommendationsView, "type": "Feature",
KOptionsActivateView, "properties": {},
KOptionsView, "geometry": {
LocationDataNdviHealthView, "type": "Polygon",
LocationDataRemoteSensingView, "coordinates": [
LocationDataView, [
RunStatusView, [51.418934, 35.706815],
[51.423054, 35.691062],
[51.384258, 35.689389],
[51.418934, 35.706815],
]
],
},
}
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
) )
class ZonesInitialViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
def test_post_accepts_area_geojson_alias(self):
request = self.factory.post(
"/api/crop-zoning/zones/initial/",
{"area_geojson": AREA_GEOJSON},
format="json",
)
response = ZonesInitialView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertGreater(response.data["data"]["zone_count"], 1)
self.assertEqual(
response.data["data"]["zone_count"],
len(response.data["data"]["zones"]),
)
CLUSTER_UUID = "11111111-2222-3333-4444-555555555555" @override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
class LocationDataProxyViewTests(TestCase): )
class AreaViewTests(TestCase):
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
username="location-user", username="farmer",
password="secret123", password="secret123",
email="location@example.com", email="farmer@example.com",
phone_number="09120000030", phone_number="09120000000",
) )
self.other_user = get_user_model().objects.create_user( self.other_user = get_user_model().objects.create_user(
username="location-other-user", username="other-farmer",
password="secret123", password="secret123",
email="location-other@example.com", email="other@example.com",
phone_number="09120000031", phone_number="09120000001",
) )
self.farm_type = FarmType.objects.create(name="Location Farm Type") self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") self.farm = FarmHub.objects.create(owner=self.user, name="farm-1", farm_type=self.farm_type)
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") self.other_farm = FarmHub.objects.create(owner=self.other_user, name="farm-2", farm_type=self.farm_type)
def _get(self, path): def _create_area(self, **kwargs):
request = self.factory.get(path) defaults = {
"farm": self.farm,
"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):
request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user) force_authenticate(request, user=self.user)
return request return request
def _post(self, path, data): def _request_with_pagination(self, page=1, page_size=10):
request = self.factory.post(path, data, format="json") request = self.factory.get(
f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}&page={page}&page_size={page_size}"
)
force_authenticate(request, user=self.user) force_authenticate(request, user=self.user)
return request return request
@patch("crop_zoning.views.external_api_request") def test_get_requires_farm_uuid(self):
def test_get_location_data_proxies_query_params_to_ai(self, mock_external_api_request): request = self.factory.get("/api/crop-zoning/area/")
mock_external_api_request.return_value = AdapterResponse( force_authenticate(request, user=self.user)
status_code=200, response = AreaView.as_view()(request)
data={ self.assertEqual(response.status_code, 400)
"code": 200, self.assertEqual(response.data["message"], "farm_uuid is required.")
"msg": "success",
"data": {"source": "database", "id": 12, "lon": "51.389000", "lat": "35.689200"}, def test_get_rejects_foreign_farm_uuid(self):
}, request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.other_farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "Farm not found.")
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 = LocationDataView.as_view()(self._get("/api/location-data/?lat=35.6892&lon=51.389")) response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["id"], 12) self.assertEqual(response.data["status"], "success")
mock_external_api_request.assert_called_once_with( self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING")
"ai", self.assertEqual(response.data["data"]["task"]["total_zones"], 2)
"/api/location-data/", self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
method="GET", self.assertEqual(len(response.data["data"]["zones"]), 2)
payload=None, self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-0")
query={"lat": "35.6892", "lon": "51.389"}, self.assertIn("processing_status", response.data["data"]["zones"][0])
)
def test_post_location_data_rejects_foreign_farm_uuid(self): def test_get_returns_area_when_all_tasks_complete(self):
response = LocationDataView.as_view()( crop_area = self._create_area()
self._post("/api/location-data/", {"farm_uuid": str(self.other_farm.farm_uuid), "lat": "35.6892", "lon": "51.389"}) 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",
)
self.assertEqual(response.status_code, 404) response = AreaView.as_view()(self._request())
self.assertEqual(response.data, {"code": 404, "msg": "location پیدا نشد.", "data": None})
@patch("crop_zoning.views.external_api_request")
def test_post_ndvi_health_proxies_owned_farm_to_ai(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"code": 200,
"msg": "success",
"data": {"ndviIndex": 0.63, "vegetation_health_class": "healthy"},
},
)
response = LocationDataNdviHealthView.as_view()(
self._post("/api/location-data/ndvi-health/", {"farm_uuid": str(self.farm.farm_uuid)})
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["ndviIndex"], 0.63) self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
mock_external_api_request.assert_called_once_with( self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
"ai", self.assertEqual(len(response.data["data"]["zones"]), 2)
"/api/location-data/ndvi-health/", self.assertEqual(response.data["data"]["zones"][1]["zoneId"], "zone-1")
method="POST", self.assertIn("crop", response.data["data"]["zones"][0])
payload={"farm_uuid": str(self.farm.farm_uuid)}, self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
query=None,
def test_get_returns_paginated_zones(self):
crop_area = self._create_area(zone_count=3, area_sqm=300000, area_hectares=30)
for sequence in range(3):
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=100000,
area_hectares=10,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id=f"celery-task-{sequence}",
)
response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-1")
self.assertEqual(response.data["data"]["pagination"]["page"], 2)
self.assertEqual(response.data["data"]["pagination"]["page_size"], 1)
self.assertEqual(response.data["data"]["pagination"]["total_pages"], 3)
self.assertTrue(response.data["data"]["pagination"]["has_next"])
self.assertTrue(response.data["data"]["pagination"]["has_previous"])
def test_get_rejects_invalid_pagination_params(self):
response = AreaView.as_view()(self._request_with_pagination(page=0, page_size=10))
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "page must be a positive integer.")
@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="",
) )
def test_get_remote_sensing_rejects_foreign_farm_uuid(self): response = AreaView.as_view()(self._request())
response = LocationDataRemoteSensingView.as_view()(
self._get(f"/api/location-data/remote-sensing/?farm_uuid={self.other_farm.farm_uuid}") 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_farm_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["farm"], self.farm)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_each_zone_gets_its_own_task(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=200000,
area_hectares=20,
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=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PENDING,
task_id="",
) )
self.assertEqual(response.status_code, 404) response = AreaView.as_view()(self._request())
self.assertEqual(response.data, {"code": 404, "msg": "مزرعه پیدا نشد.", "data": None})
@patch("crop_zoning.views.external_api_request") self.assertEqual(response.status_code, 200)
def test_post_remote_sensing_passes_through_202_response(self, mock_external_api_request): self.assertEqual(mock_delay.call_count, 2)
mock_external_api_request.return_value = AdapterResponse( zone0.refresh_from_db()
status_code=202, zone1.refresh_from_db()
data={ self.assertTrue(zone0.task_id)
"code": 202, self.assertTrue(zone1.task_id)
"msg": "queued", self.assertNotEqual(zone0.task_id, zone1.task_id)
"data": {"status": "processing", "task_id": "task-123"},
},
)
response = LocationDataRemoteSensingView.as_view()( @patch("crop_zoning.services.AsyncResult")
self._post("/api/location-data/remote-sensing/", {"farm_uuid": str(self.farm.farm_uuid)}) def test_stale_tasks_are_redispatched(self, mock_async_result):
crop_area = self._create_area()
stale_time = timezone.now() - timedelta(minutes=10)
stale_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_PROCESSING,
task_id="stale-task",
) )
CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time)
self.assertEqual(response.status_code, 202) mock_async_result.side_effect = OperationalError("broker down")
self.assertEqual(response.data["data"]["task_id"], "task-123")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/location-data/remote-sensing/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
query=None,
)
@patch("crop_zoning.views.external_api_request") with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch:
def test_auxiliary_location_data_endpoints_proxy_to_ai(self, mock_external_api_request): response = AreaView.as_view()(self._request())
mock_external_api_request.side_effect = [
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"status": "success"}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"cluster_count": 2}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"result_id": 5}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"activated_requested_k": 4}}),
AdapterResponse(status_code=200, data={"code": 200, "msg": "success", "data": {"status": "running"}}),
]
cluster_response = ClusterBlockLiveView.as_view()( self.assertEqual(response.status_code, 200)
self._get(f"/api/location-data/remote-sensing/cluster-blocks/{CLUSTER_UUID}/live/"), mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True)
cluster_uuid=CLUSTER_UUID,
)
recommendation_response = ClusterRecommendationsView.as_view()(
self._get(f"/api/location-data/remote-sensing/cluster-recommendations/?farm_uuid={self.farm.farm_uuid}")
)
k_options_response = KOptionsView.as_view()(
self._get("/api/location-data/remote-sensing/results/5/k-options/"),
result_id=5,
)
activate_response = KOptionsActivateView.as_view()(
self._post("/api/location-data/remote-sensing/results/5/k-options/activate/", {"requested_k": 4}),
result_id=5,
)
run_status_response = RunStatusView.as_view()(
self._get("/api/location-data/remote-sensing/runs/9/status/"),
run_id=9,
)
self.assertEqual(cluster_response.status_code, 200)
self.assertEqual(recommendation_response.data["data"]["cluster_count"], 2)
self.assertEqual(k_options_response.data["data"]["result_id"], 5)
self.assertEqual(activate_response.data["data"]["activated_requested_k"], 4)
self.assertEqual(run_status_response.data["data"]["status"], "running")
def test_new_routes_exist_and_old_crop_zoning_routes_are_removed(self): @override_settings(
self.assertIs(resolve("/api/location-data/").func.view_class, LocationDataView) USE_EXTERNAL_API_MOCK=True,
self.assertIs(resolve("/api/location-data/ndvi-health/").func.view_class, LocationDataNdviHealthView) CROP_ZONE_CHUNK_AREA_SQM=200000,
self.assertIs(resolve("/api/location-data/remote-sensing/").func.view_class, LocationDataRemoteSensingView) )
self.assertIs( class LayerAreaViewTests(TestCase):
resolve(f"/api/location-data/remote-sensing/cluster-blocks/{CLUSTER_UUID}/live/").func.view_class, def setUp(self):
ClusterBlockLiveView, self.factory = APIRequestFactory()
) self.user = get_user_model().objects.create_user(
self.assertIs( username="layer-farmer",
resolve("/api/location-data/remote-sensing/cluster-recommendations/").func.view_class, password="secret123",
ClusterRecommendationsView, email="layer@example.com",
) phone_number="09120000002",
self.assertIs(
resolve("/api/location-data/remote-sensing/results/5/k-options/").func.view_class,
KOptionsView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/results/5/k-options/activate/").func.view_class,
KOptionsActivateView,
)
self.assertIs(
resolve("/api/location-data/remote-sensing/runs/9/status/").func.view_class,
RunStatusView,
) )
self.farm_type = FarmType.objects.create(name="باغی")
self.farm = FarmHub.objects.create(owner=self.user, name="layer-farm", farm_type=self.farm_type)
with self.assertRaises(Resolver404): def _create_area(self, **kwargs):
resolve("/api/crop-zoning/area/") defaults = {
"farm": self.farm,
"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": 1,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _create_completed_zone(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=300000,
area_hectares=30,
sequence=0,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
return crop_area
def _request(self, path):
request = self.factory.get(f"{path}?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
return request
def test_water_need_view_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/water-need/")
force_authenticate(request, user=self.user)
response = WaterNeedView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
def test_water_need_view_returns_area_style_payload(self):
self._create_completed_zone()
response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_soil_quality_view_returns_area_style_payload(self):
self._create_completed_zone()
response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_cultivation_risk_view_returns_area_style_payload(self):
self._create_completed_zone()
response = CultivationRiskView.as_view()(self._request("/api/crop-zoning/cultivation-risk/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("cultivationRiskLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
+34 -34
View File
@@ -1,43 +1,43 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
ClusterBlockLiveView, AreaView,
ClusterRecommendationsView, CultivationRiskView,
KOptionsActivateView, ProductsView,
KOptionsView, SoilQualityView,
LocationDataNdviHealthView, WaterNeedView,
LocationDataRemoteSensingView, ZoneDetailsView,
LocationDataView, ZonesCultivationRiskView,
RunStatusView, ZonesInitialView,
ZonesSoilQualityView,
ZonesWaterNeedView,
) )
urlpatterns = [ urlpatterns = [
path("", LocationDataView.as_view(), name="location-data"), path("area/", AreaView.as_view(), name="crop-zoning-area"),
path("ndvi-health/", LocationDataNdviHealthView.as_view(), name="location-data-ndvi-health"), path("water-need/", WaterNeedView.as_view(), name="crop-zoning-water-need"),
path("remote-sensing/", LocationDataRemoteSensingView.as_view(), name="location-data-remote-sensing"), path("soil-quality/", SoilQualityView.as_view(), name="crop-zoning-soil-quality"),
path("cultivation-risk/", CultivationRiskView.as_view(), name="crop-zoning-cultivation-risk"),
path("products/", ProductsView.as_view(), name="crop-zoning-products"),
# path("zones/initial/", ZonesInitialView.as_view(), name="crop-zoning-zones-initial"),
# path(
# "zones/water-need/",
# ZonesWaterNeedView.as_view(),
# name="crop-zoning-zones-water-need",
# ),
# path(
# "zones/soil-quality/",
# ZonesSoilQualityView.as_view(),
# name="crop-zoning-zones-soil-quality",
# ),
# path(
# "zones/cultivation-risk/",
# ZonesCultivationRiskView.as_view(),
# name="crop-zoning-zones-cultivation-risk",
# ),
path( path(
"remote-sensing/cluster-blocks/<uuid:cluster_uuid>/live/", "zones/<str:zone_id>/details/",
ClusterBlockLiveView.as_view(), ZoneDetailsView.as_view(),
name="location-data-cluster-block-live", name="crop-zoning-zone-details",
),
path(
"remote-sensing/cluster-recommendations/",
ClusterRecommendationsView.as_view(),
name="location-data-cluster-recommendations",
),
path(
"remote-sensing/results/<int:result_id>/k-options/",
KOptionsView.as_view(),
name="location-data-k-options",
),
path(
"remote-sensing/results/<int:result_id>/k-options/activate/",
KOptionsActivateView.as_view(),
name="location-data-k-options-activate",
),
path(
"remote-sensing/runs/<int:run_id>/status/",
RunStatusView.as_view(),
name="location-data-run-status",
), ),
] ]
+174 -221
View File
@@ -1,262 +1,215 @@
from copy import deepcopy
from uuid import UUID
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from drf_spectacular.types import OpenApiTypes from django.http import Http404
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import serializers, status 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.utils import OpenApiParameter, extend_schema
from config.swagger import code_response from config.swagger import status_response
from external_api_adapter.adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError
from farm_hub.models import FarmHub
from .serializers import (
FarmUUIDRequestSerializer,
KOptionActivateSerializer,
LocationDataUpsertSerializer,
)
from .services import ( from .services import (
AI_CLUSTER_RECOMMENDATIONS_PATH, create_zones_and_dispatch,
AI_LOCATION_DATA_PATH, ensure_latest_area_ready_for_processing,
AI_REMOTE_SENSING_PATH, get_latest_cultivation_risk_payload,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
get_latest_area_payload,
get_latest_soil_quality_payload,
get_latest_water_need_payload,
get_products_payload,
get_soil_quality_payload,
get_water_need_payload,
get_zone_details_payload,
get_zone_page_request_params,
) )
AI_PROXY_ERROR_MESSAGE = "ارتباط با سرویس AI ناموفق بود." AREA_QUERY_PARAMETERS = [
FARM_NOT_FOUND_MESSAGE = "مزرعه پیدا نشد." OpenApiParameter(
QUERY_FARM_NOT_FOUND_MESSAGE = "location پیدا نشد." name="farm_uuid",
SUCCESS_RESPONSE = code_response("LocationDataGenericSuccess", data=serializers.JSONField()) type=OpenApiTypes.UUID,
ERROR_RESPONSE = code_response("LocationDataGenericError", data=serializers.JSONField()) location=OpenApiParameter.QUERY,
required=True,
LOCATION_DATA_QUERY_PARAMETERS = [ description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.",
OpenApiParameter("lat", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False), default="11111111-1111-1111-1111-111111111111"),
OpenApiParameter("lon", OpenApiTypes.NUMBER, OpenApiParameter.QUERY, required=False), OpenApiParameter(
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False), name="page",
] type=OpenApiTypes.INT,
REMOTE_SENSING_QUERY_PARAMETERS = [ location=OpenApiParameter.QUERY,
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=True), required=False,
OpenApiParameter("page", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), description="شماره صفحه زون ها. مقدار پيش فرض 1 است.",
OpenApiParameter("page_size", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), ),
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), OpenApiParameter(
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), name="page_size",
] type=OpenApiTypes.INT,
CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS = [ location=OpenApiParameter.QUERY,
OpenApiParameter("start_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), required=False,
OpenApiParameter("end_date", OpenApiTypes.DATE, OpenApiParameter.QUERY, required=False), description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False), ),
]
OPTIONAL_FARM_UUID_QUERY_PARAMETER = [
OpenApiParameter("farm_uuid", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False),
] ]
class AILocationDataProxyView(APIView): class BaseAreaDataView(APIView):
ai_path = AI_LOCATION_DATA_PATH payload_getter = None
farm_uuid_locations = ()
farm_not_found_message = FARM_NOT_FOUND_MESSAGE
def _build_path(self, **kwargs): def get(self, request):
return self.ai_path.format(**kwargs) farm_uuid = request.query_params.get("farm_uuid")
def _get_payload(self, request):
if not request.data:
return None
if isinstance(request.data, dict):
return deepcopy(request.data)
return request.data
def _get_query(self, request):
if not request.query_params:
return None
query = {}
for key, values in request.query_params.lists():
query[key] = values if len(values) > 1 else values[0]
return query
def _parse_uuid(self, value):
if not value:
return None
try: try:
return UUID(str(value)) page, page_size = get_zone_page_request_params(request.query_params)
except (TypeError, ValueError, AttributeError): crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user)
return None 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)
def _extract_farm_uuid(self, request, payload, query):
for location in self.farm_uuid_locations:
if location == "body" and isinstance(payload, dict) and payload.get("farm_uuid"):
parsed = self._parse_uuid(payload.get("farm_uuid"))
if parsed is not None:
return parsed
if location == "query" and isinstance(query, dict) and query.get("farm_uuid"):
parsed = self._parse_uuid(query.get("farm_uuid"))
if parsed is not None:
return parsed
return None
def _ensure_farm_access(self, request, farm_uuid):
if farm_uuid is None:
return None
if FarmHub.objects.filter(farm_uuid=farm_uuid, owner=request.user).exists():
return None
return Response( return Response(
{"code": 404, "msg": self.farm_not_found_message, "data": None}, {"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_200_OK,
) )
def _build_proxy_error(self, exc):
return Response(
{"code": 502, "msg": AI_PROXY_ERROR_MESSAGE, "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
def _proxy(self, request, *, method, **path_kwargs): class AreaView(BaseAreaDataView):
payload = self._get_payload(request) payload_getter = staticmethod(get_latest_area_payload)
query = self._get_query(request)
farm_uuid = self._extract_farm_uuid(request, payload, query) @extend_schema(
farm_error = self._ensure_farm_access(request, farm_uuid) tags=["Crop Zoning"],
if farm_error is not None: parameters=AREA_QUERY_PARAMETERS,
return farm_error 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 super().get(request)
class WaterNeedView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_water_need_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()),
400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()),
500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class SoilQualityView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_soil_quality_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()),
400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()),
500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class CultivationRiskView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_cultivation_risk_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()),
400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()),
500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class ProductsView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
class ZonesInitialView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = (
request.data.get("area")
or 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:
adapter_response = external_api_request( crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
"ai", except ValueError as exc:
self._build_path(**path_kwargs), return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
method=method, except ImproperlyConfigured as exc:
payload=payload, return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
query=query,
)
except (ExternalAPIRequestError, ImproperlyConfigured) as exc:
return self._build_proxy_error(exc)
response_payload = adapter_response.data return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
if not isinstance(response_payload, dict):
response_payload = {
"code": adapter_response.status_code,
"msg": "success" if adapter_response.status_code < 400 else "error",
"data": response_payload,
}
return Response(response_payload, status=adapter_response.status_code)
class LocationDataView(AILocationDataProxyView): class ZonesWaterNeedView(APIView):
farm_uuid_locations = ("query", "body")
farm_not_found_message = QUERY_FARM_NOT_FOUND_MESSAGE
@extend_schema( @extend_schema(
tags=["Location Data"], tags=["Crop Zoning"],
parameters=LOCATION_DATA_QUERY_PARAMETERS, request=OpenApiTypes.OBJECT,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE}, responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=LocationDataUpsertSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
) )
def post(self, request): def post(self, request):
return self._proxy(request, method="POST") zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
class LocationDataNdviHealthView(AILocationDataProxyView): class ZonesSoilQualityView(APIView):
ai_path = f"{AI_LOCATION_DATA_PATH}ndvi-health/"
farm_uuid_locations = ("body",)
@extend_schema( @extend_schema(
tags=["Location Data"], tags=["Crop Zoning"],
request=FarmUUIDRequestSerializer, request=OpenApiTypes.OBJECT,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE}, responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
) )
def post(self, request): def post(self, request):
return self._proxy(request, method="POST") zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
class LocationDataRemoteSensingView(AILocationDataProxyView): class ZonesCultivationRiskView(APIView):
ai_path = AI_REMOTE_SENSING_PATH
farm_uuid_locations = ("query", "body")
@extend_schema( @extend_schema(
tags=["Location Data"], tags=["Crop Zoning"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS, request=OpenApiTypes.OBJECT,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE}, responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
)
def get(self, request):
return self._proxy(request, method="GET")
@extend_schema(
tags=["Location Data"],
request=FarmUUIDRequestSerializer,
responses={202: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
) )
def post(self, request): def post(self, request):
return self._proxy(request, method="POST") zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
class ClusterBlockLiveView(AILocationDataProxyView): class ZoneDetailsView(APIView):
ai_path = f"{AI_REMOTE_SENSING_PATH}cluster-blocks/{{cluster_uuid}}/live/"
farm_uuid_locations = ("query",)
@extend_schema( @extend_schema(
tags=["Location Data"], tags=["Crop Zoning"],
parameters=CLUSTER_BLOCK_LIVE_QUERY_PARAMETERS, responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE, 502: ERROR_RESPONSE},
) )
def get(self, request, cluster_uuid): def get(self, request, zone_id):
return self._proxy(request, method="GET", cluster_uuid=cluster_uuid) try:
data = get_zone_details_payload(zone_id)
except Exception as exc:
class ClusterRecommendationsView(AILocationDataProxyView): if exc.__class__.__name__ == "DoesNotExist":
ai_path = AI_CLUSTER_RECOMMENDATIONS_PATH raise Http404("Zone not found")
farm_uuid_locations = ("query",) raise
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
@extend_schema(
tags=["Location Data"],
parameters=REMOTE_SENSING_QUERY_PARAMETERS[:1],
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request):
return self._proxy(request, method="GET")
class KOptionsView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, result_id):
return self._proxy(request, method="GET", result_id=result_id)
class KOptionsActivateView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}results/{{result_id}}/k-options/activate/"
farm_uuid_locations = ("query", "body")
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
request=KOptionActivateSerializer,
responses={200: SUCCESS_RESPONSE, 400: ERROR_RESPONSE, 404: ERROR_RESPONSE},
)
def post(self, request, result_id):
return self._proxy(request, method="POST", result_id=result_id)
class RunStatusView(AILocationDataProxyView):
ai_path = f"{AI_REMOTE_SENSING_PATH}runs/{{run_id}}/status/"
farm_uuid_locations = ("query",)
@extend_schema(
tags=["Location Data"],
parameters=OPTIONAL_FARM_UUID_QUERY_PARAMETER,
responses={200: SUCCESS_RESPONSE, 404: ERROR_RESPONSE},
)
def get(self, request, run_id):
return self._proxy(request, method="GET", run_id=run_id)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView
urlpatterns = [
path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"),
path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"),
path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"),
]
@@ -1,31 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("device_hub", "0009_sync_devicecatalog_schema"),
]
operations = [
migrations.AddField(
model_name="farmdevice",
name="cluster_uuid",
field=models.UUIDField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="farmdevice",
name="location_metadata",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="sensorexternalrequestlog",
name="cluster_uuid",
field=models.UUIDField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="sensorexternalrequestlog",
name="location_metadata",
field=models.JSONField(blank=True, default=dict),
),
]
-12
View File
@@ -60,8 +60,6 @@ class FarmDevice(models.Model):
physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True) physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sensor_type = models.CharField(max_length=255, blank=True, default="") sensor_type = models.CharField(max_length=255, blank=True, default="")
cluster_uuid = models.UUIDField(null=True, blank=True, db_index=True)
location_metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
specifications = models.JSONField(default=dict, blank=True) specifications = models.JSONField(default=dict, blank=True)
power_source = models.JSONField(default=dict, blank=True) power_source = models.JSONField(default=dict, blank=True)
@@ -92,21 +90,11 @@ class FarmDevice(models.Model):
return catalog return catalog
return None return None
def get_sensor_key(self):
if self.sensor_catalog and self.sensor_catalog.code:
return self.sensor_catalog.code
return "sensor-7-1"
def get_ai_device_key(self):
return f"device:{self.physical_device_uuid}"
class SensorExternalRequestLog(models.Model): class SensorExternalRequestLog(models.Model):
farm_uuid = models.UUIDField(db_index=True) farm_uuid = models.UUIDField(db_index=True)
sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True)
physical_device_uuid = models.UUIDField(db_index=True) physical_device_uuid = models.UUIDField(db_index=True)
cluster_uuid = models.UUIDField(null=True, blank=True, db_index=True)
location_metadata = models.JSONField(default=dict, blank=True)
payload = models.JSONField(default=dict, blank=True) payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView
urlpatterns = [
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"),
path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"),
]
-6
View File
@@ -61,8 +61,6 @@ class FarmDeviceLogSerializer(serializers.ModelSerializer):
"physical_device_uuid", "physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"cluster_uuid",
"location_metadata",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
@@ -107,8 +105,6 @@ class DeviceDetailSerializer(serializers.ModelSerializer):
"physical_device_uuid", "physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"cluster_uuid",
"location_metadata",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
@@ -175,8 +171,6 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer):
"farm_uuid", "farm_uuid",
"sensor_catalog_uuid", "sensor_catalog_uuid",
"physical_device_uuid", "physical_device_uuid",
"cluster_uuid",
"location_metadata",
"farm_device", "farm_device",
"sensor_catalog", "sensor_catalog",
"payload", "payload",
+6 -166
View File
@@ -1,7 +1,6 @@
from copy import deepcopy from copy import deepcopy
from datetime import timedelta from datetime import timedelta
import logging import logging
import uuid
from django.conf import settings from django.conf import settings
from django.db import OperationalError, ProgrammingError, transaction from django.db import OperationalError, ProgrammingError, transaction
@@ -100,23 +99,14 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph
def create_sensor_external_notification(*, physical_device_uuid, payload=None): def create_sensor_external_notification(*, physical_device_uuid, payload=None):
payload = payload or {} payload = payload or {}
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
return create_sensor_external_notification_for_sensor(sensor=sensor, payload=payload, runtime_context=runtime_context)
def create_sensor_external_notification_for_sensor(*, sensor, payload=None, runtime_context=None):
payload = payload or {}
if sensor is None: if sensor is None:
raise ValueError("Physical device not found.") raise ValueError("Physical device not found.")
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=payload)
try: try:
with transaction.atomic(): with transaction.atomic():
SensorExternalRequestLog.objects.create( SensorExternalRequestLog.objects.create(
farm_uuid=sensor.farm.farm_uuid, farm_uuid=sensor.farm.farm_uuid,
sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None,
physical_device_uuid=sensor.physical_device_uuid, physical_device_uuid=sensor.physical_device_uuid,
cluster_uuid=runtime_context["cluster_uuid"],
location_metadata=runtime_context["location_metadata"],
payload=payload, payload=payload,
) )
return create_notification_for_farm_uuid( return create_notification_for_farm_uuid(
@@ -124,14 +114,7 @@ def create_sensor_external_notification_for_sensor(*, sensor, payload=None, runt
title="Sensor external API request", title="Sensor external API request",
message=f"Payload received from device {sensor.physical_device_uuid}.", message=f"Payload received from device {sensor.physical_device_uuid}.",
level="info", level="info",
metadata={ metadata={"farm_uuid": str(sensor.farm.farm_uuid), "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, "physical_device_uuid": str(sensor.physical_device_uuid), "payload": payload},
"farm_uuid": str(sensor.farm.farm_uuid),
"sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None,
"physical_device_uuid": str(sensor.physical_device_uuid),
"cluster_uuid": str(runtime_context["cluster_uuid"]) if runtime_context["cluster_uuid"] else None,
"location_metadata": runtime_context["location_metadata"],
"payload": payload,
},
) )
except (ProgrammingError, OperationalError) as exc: except (ProgrammingError, OperationalError) as exc:
raise ValueError("Sensor external API tables are not migrated.") from exc raise ValueError("Sensor external API tables are not migrated.") from exc
@@ -140,31 +123,15 @@ def create_sensor_external_notification_for_sensor(*, sensor, payload=None, runt
def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None):
payload = payload or {} payload = payload or {}
sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first()
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
return forward_sensor_payload_to_farm_data_for_sensor(sensor=sensor, payload=payload, runtime_context=runtime_context)
def forward_sensor_payload_to_farm_data_for_sensor(*, sensor, payload=None, runtime_context=None):
payload = payload or {}
if sensor is None: if sensor is None:
raise ValueError("Physical device not found.") raise ValueError("Physical device not found.")
farm_boundary = _get_farm_boundary(sensor=sensor) farm_boundary = _get_farm_boundary(sensor=sensor)
api_key = getattr(settings, "FARM_DATA_API_KEY", "") api_key = getattr(settings, "FARM_DATA_API_KEY", "")
if not api_key: if not api_key:
raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.")
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=payload)
sensor_key = _get_sensor_key(sensor=sensor) sensor_key = _get_sensor_key(sensor=sensor)
request_payload = { normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload)
"farm_uuid": str(sensor.farm.farm_uuid), request_payload = {"farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, "sensor_key": sensor_key, "sensor_payload": normalized_sensor_payload}
"farm_boundary": farm_boundary,
"sensor_key": sensor_key,
"sensor_payload": _build_ai_sensor_payload(
sensor=sensor,
sensor_key=sensor_key,
sensor_payload=payload,
runtime_context=runtime_context,
),
}
try: try:
response = external_api_request( response = external_api_request(
"ai", "ai",
@@ -202,137 +169,10 @@ def _normalize_sensor_payload(*, sensor_key, sensor_payload):
return {sensor_key: sensor_payload} return {sensor_key: sensor_payload}
def _build_ai_sensor_payload(*, sensor, sensor_key, sensor_payload, runtime_context=None):
if sensor_payload and not isinstance(sensor_payload, dict):
raise FarmDataForwardError("`payload` must be a JSON object.")
raw_payload = _extract_payload(sensor_payload)
runtime_context = runtime_context or build_sensor_runtime_context(sensor=sensor, payload=sensor_payload)
device_payload = {
"sensor_key": sensor_key,
"physical_device_uuid": str(sensor.physical_device_uuid),
"recorded_at": timezone.now().isoformat(),
"metrics": raw_payload or {},
"metadata": {
"source_service": "backend_device_hub",
"farm_device_uuid": str(sensor.uuid),
"sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None,
"sensor_type": sensor.sensor_type or "",
"device_name": sensor.name or "",
"cluster_uuid": str(runtime_context["cluster_uuid"]) if runtime_context["cluster_uuid"] else None,
"location": runtime_context["location_metadata"],
},
}
if runtime_context["cluster_uuid"] is not None:
device_payload["cluster_uuid"] = str(runtime_context["cluster_uuid"])
if runtime_context["location_metadata"].get("zone") is not None:
device_payload["zone"] = runtime_context["location_metadata"]["zone"]
if runtime_context["location_metadata"].get("depth_cm") is not None:
device_payload["depth_cm"] = runtime_context["location_metadata"]["depth_cm"]
return {
sensor.get_ai_device_key(): device_payload
}
def build_sensor_runtime_context(*, sensor, payload=None):
payload = payload or {}
payload_location = _extract_payload_location_metadata(payload)
payload_cluster_uuid = _extract_cluster_uuid(payload)
location_metadata = dict(sensor.location_metadata or {})
location_metadata.update(payload_location)
return {
"cluster_uuid": payload_cluster_uuid or sensor.cluster_uuid,
"location_metadata": location_metadata,
}
def sync_sensor_runtime_context(*, sensor, payload=None):
if sensor is None:
raise ValueError("Physical device not found.")
runtime_context = build_sensor_runtime_context(sensor=sensor, payload=payload)
update_fields = []
if runtime_context["cluster_uuid"] != sensor.cluster_uuid:
sensor.cluster_uuid = runtime_context["cluster_uuid"]
update_fields.append("cluster_uuid")
if runtime_context["location_metadata"] != (sensor.location_metadata or {}):
sensor.location_metadata = runtime_context["location_metadata"]
update_fields.append("location_metadata")
if update_fields:
update_fields.append("updated_at")
sensor.save(update_fields=update_fields)
return runtime_context
def _extract_cluster_uuid(payload):
if not isinstance(payload, dict):
return None
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
candidates = [
payload.get("cluster_uuid"),
payload.get("clusterId"),
metadata.get("cluster_uuid"),
metadata.get("clusterId"),
]
for candidate in candidates:
parsed = _parse_uuid(candidate)
if parsed is not None:
return parsed
return None
def _extract_payload_location_metadata(payload):
if not isinstance(payload, dict):
return {}
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
location = payload.get("location") if isinstance(payload.get("location"), dict) else {}
coordinates = payload.get("coordinates") if isinstance(payload.get("coordinates"), dict) else {}
lat = payload.get("lat", payload.get("latitude"))
lon = payload.get("lon", payload.get("lng", payload.get("longitude")))
if lat is None:
lat = location.get("lat", location.get("latitude"))
if lon is None:
lon = location.get("lon", location.get("lng", location.get("longitude")))
if lat is None:
lat = coordinates.get("lat", coordinates.get("latitude"))
if lon is None:
lon = coordinates.get("lon", coordinates.get("lng", coordinates.get("longitude")))
result = {}
if lat is not None:
result["lat"] = lat
if lon is not None:
result["lon"] = lon
for key in ("zone", "depth_cm", "cluster_code", "cluster_label"):
value = payload.get(key, metadata.get(key))
if value not in (None, ""):
result[key] = value
if location:
result["location"] = location
elif coordinates:
result["location"] = coordinates
return result
def _parse_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError, AttributeError):
return None
def _get_sensor_key(*, sensor): def _get_sensor_key(*, sensor):
return sensor.get_sensor_key() if sensor.sensor_catalog and sensor.sensor_catalog.code:
return sensor.sensor_catalog.code
return "sensor-7-1"
def _get_farm_data_path(): def _get_farm_data_path():
+2 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from .views import DeviceCatalogListView, DeviceCodeListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, SensorExternalAPIView, SensorExternalRequestLogListAPIView from .views import DeviceCatalogListView, DeviceCodeListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, Sensor7In1SummaryView, SensorExternalAPIView, SensorExternalRequestLogListAPIView
urlpatterns = [ urlpatterns = [
path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"), path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"),
@@ -13,6 +13,7 @@ urlpatterns = [
path("devices/<uuid:physical_device_uuid>/radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), path("devices/<uuid:physical_device_uuid>/radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"),
path("devices/<uuid:physical_device_uuid>/logs/", DeviceLogListView.as_view(), name="device-log-list"), path("devices/<uuid:physical_device_uuid>/logs/", DeviceLogListView.as_view(), name="device-log-list"),
path("devices/<uuid:physical_device_uuid>/commands/", DeviceCommandView.as_view(), name="device-command"), path("devices/<uuid:physical_device_uuid>/commands/", DeviceCommandView.as_view(), name="device-command"),
path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"),
path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"), path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"),
path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"),
path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"),
+3 -20
View File
@@ -13,7 +13,7 @@ from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerial
from .authentication import SensorExternalAPIKeyAuthentication from .authentication import SensorExternalAPIKeyAuthentication
from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer
from .serializers import DeviceCatalogSerializer, DeviceCodeListResponseSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer from .serializers import DeviceCatalogSerializer, DeviceCodeListResponseSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer
from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification_for_sensor, execute_device_command, forward_sensor_payload_to_farm_data_for_sensor, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, sync_sensor_runtime_context, validate_output_device_catalog from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog
class DeviceCatalogListView(APIView): class DeviceCatalogListView(APIView):
@@ -297,26 +297,9 @@ class SensorExternalAPIView(APIView):
def post(self, request): def post(self, request):
serializer = SensorExternalRequestSerializer(data=request.data) serializer = SensorExternalRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
sensor = get_farm_device_by_physical_uuid(
physical_device_uuid=serializer.validated_data["uuid"]
)
if sensor is None:
return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND)
try: try:
runtime_context = sync_sensor_runtime_context( notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
sensor=sensor, forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload"))
payload=serializer.validated_data.get("payload"),
)
forward_sensor_payload_to_farm_data_for_sensor(
sensor=sensor,
payload=serializer.validated_data.get("payload"),
runtime_context=runtime_context,
)
notification = create_sensor_external_notification_for_sensor(
sensor=sensor,
payload=serializer.validated_data.get("payload"),
runtime_context=runtime_context,
)
except ValueError as exc: except ValueError as exc:
if "not migrated" in str(exc): if "not migrated" in str(exc):
return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE) return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
+3 -3
View File
@@ -66,7 +66,7 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
env_file: env_file:
- .env - .env.example
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop} DOCKER_VERSION: ${DOCKER_VERSION:-develop}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
@@ -101,7 +101,7 @@ services:
- .:/app - .:/app
- ./logs:/app/logs - ./logs:/app/logs
env_file: env_file:
- .env - .env.example
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop} DOCKER_VERSION: ${DOCKER_VERSION:-develop}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
@@ -135,7 +135,7 @@ services:
- .:/app - .:/app
- ./logs:/app/logs - ./logs:/app/logs
env_file: env_file:
- .env - .env.example
environment: environment:
DOCKER_VERSION: ${DOCKER_VERSION:-develop} DOCKER_VERSION: ${DOCKER_VERSION:-develop}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
-772
View File
@@ -1,772 +0,0 @@
# مستند کامل سیستم Access Control
این سند معماری، اجزای اصلی، جریان اجرا، مدل داده، تنظیمات، APIها و محدودیت های فعلی سیستم `access_control` را در بک اند CropLogic توضیح می دهد.
## هدف سیستم
سیستم `access_control` برای پاسخ به این سوال طراحی شده است:
- آیا یک کاربر روی یک مزرعه مشخص به یک قابلیت خاص دسترسی دارد یا نه؟
- این تصمیم بر چه اساسی گرفته می شود: پلن اشتراک، نوع مزرعه، محصول، سنسور، یا قوانین اختصاصی؟
- آیا این تصمیم باید در سطح کل route اعمال شود یا در سطح یک feature مشخص داخل view؟
این سیستم دو لایه اصلی دارد:
1. کنترل دسترسی در سطح route با `RouteFeatureAccessMiddleware`
2. کنترل دسترسی در سطح feature/view با `FeatureAccessPermission`
هسته تصمیم گیری نهایی هم از طریق سرویس OPA انجام می شود و بک اند نقش جمع آوری context، ارسال درخواست، cache کردن نتیجه و اعمال پاسخ را دارد.
---
## محل های اصلی پیاده سازی
فایل های مهم این سیستم:
- `access_control/models.py`
- `access_control/services.py`
- `access_control/permissions.py`
- `access_control/middleware.py`
- `access_control/views.py`
- `access_control/serializers.py`
- `access_control/urls.py`
- `config/feature.json`
- `config/settings.py`
فعال سازی سراسری در تنظیمات:
- `config/settings.py:75` -> اضافه شدن `access_control.middleware.RouteFeatureAccessMiddleware`
- `config/settings.py:145` -> اضافه شدن `access_control.permissions.FeatureAccessPermission` به `DEFAULT_PERMISSION_CLASSES`
این یعنی به صورت پیش فرض، تمام endpointهای DRF که anonymous نیستند، هم از نظر authentication و هم از نظر access control بررسی می شوند.
---
## اجزای دامنه داده
### 1) SubscriptionPlan
مدل `SubscriptionPlan` در `access_control/models.py` پلن اشتراک را نگه می دارد.
فیلدهای مهم:
- `code`: کد یکتا مثل `gold` یا `starter`
- `name`: نام نمایشی پلن
- `metadata`: داده های تکمیلی؛ مثلا تعیین پلن پیش فرض با `{"is_default": true}`
- `is_active`: فعال یا غیرفعال بودن پلن
کاربرد اصلی:
- اتصال مستقیم به `FarmHub.subscription_plan`
- استفاده در match شدن ruleها
- fallback برای مزارعی که پلن ندارند
### 2) AccessFeature
مدل `AccessFeature` قابلیت هایی را تعریف می کند که باید درباره آن ها تصمیم گیری شود.
فیلدهای مهم:
- `code`: شناسه یکتای feature مثل `farm_dashboard`
- `name`: نام خوانا
- `feature_type`: یکی از `page`، `widget` یا `action`
- `default_enabled`: وضعیت پیش فرض قبل از اعمال ruleها
- `metadata`: داده های توسعه پذیر
نکته مهم:
`default_enabled` نقطه شروع محاسبه است. بعد از آن ruleها می توانند وضعیت هر feature را تغییر دهند.
### 3) AccessRule
مدل `AccessRule` قانون های دسترسی را نگه می دارد.
فیلدهای مهم:
- `code`: شناسه یکتا برای rule
- `priority`: اولویت اجرا؛ عدد کمتر یعنی زودتر پردازش می شود
- `effect`: یکی از `allow` یا `deny`
- `features`: featureهایی که rule روی آن ها اثر می گذارد
- `subscription_plans`: محدودیت بر اساس پلن
- `farm_types`: محدودیت بر اساس نوع مزرعه
- `products`: محدودیت بر اساس محصول
- `sensor_catalogs`: محدودیت بر اساس کاتالوگ دستگاه/سنسور
- `metadata`: برای شرط های تکمیلی مثل `sensor_catalog_codes`
نکته رفتاری:
در `build_farm_access_profile` ruleها بر اساس `priority` و سپس `id` مرتب می شوند. اگر چند rule روی یک feature اثر بگذارند، ruleهای بعدی می توانند نتیجه قبلی را override کنند.
### 4) FarmAccessProfile
مدل `FarmAccessProfile` snapshot یا خروجی resolved شده دسترسی های یک مزرعه است.
فیلدهای مهم:
- `farm`: ارتباط one-to-one با `FarmHub`
- `subscription_plan`: پلنی که در نهایت برای resolve استفاده شده
- `profile_data`: خروجی نهایی شامل featureها و matched ruleها
- `resolved_from_profile`: فلگ کمکی
این مدل بیشتر برای materialize کردن وضعیت دسترسی مزرعه استفاده می شود تا بتوان نتیجه محاسبه را ذخیره و بازاستفاده کرد.
---
## ارتباط با FarmHub
در `farm_hub/models.py`، هر مزرعه (`FarmHub`) این contextها را دارد:
- `owner`
- `farm_type`
- `subscription_plan`
- `products`
- `sensors`
سیستم access control از همین context برای تصمیم گیری استفاده می کند. بنابراین دسترسی صرفا به user وابسته نیست؛ بلکه به ترکیب user + farm + ویژگی های مزرعه وابسته است.
---
## منطق پلن پیش فرض
تابع های مرتبط:
- `get_default_subscription_plan`
- `get_effective_subscription_plan`
منطق به این صورت است:
1. اگر خود مزرعه `subscription_plan` داشته باشد، همان استفاده می شود.
2. اگر نداشته باشد، اولین پلن فعال با `metadata.is_default=True` انتخاب می شود.
3. اگر چنین پلنی هم نبود، پلن `gold` از `access_control/catalog.py` fallback می شود.
این رفتار باعث می شود مزرعه بدون پلن صریح هم قابل authorize باشد.
---
## ساخت Access Profile داخل بک اند
تابع اصلی: `build_farm_access_profile` در `access_control/services.py`
### ورودی
یک آبجکت `FarmHub`
### مراحل اجرا
1. مزرعه با `select_related` و `prefetch_related` کامل reload می شود.
2. پلن موثر مزرعه با `get_effective_subscription_plan` تعیین می شود.
3. محصول های مزرعه و device catalogهای سنسورهای مزرعه جمع آوری می شوند.
4. تمام `AccessFeature`های فعال خوانده می شوند.
5. وضعیت اولیه هر feature از `default_enabled` ساخته می شود.
6. تمام `AccessRule`های فعال، به ترتیب `priority` بررسی می شوند.
7. هر rule با تابع `_match_rule` روی مزرعه match می شود.
8. اگر rule match شود:
- به `matched_rules` اضافه می شود
- featureهای مرتبط را `allow` یا `deny` می کند
- `source` آن feature برابر کد rule می شود
9. نتیجه نهایی در `FarmAccessProfile` ذخیره می شود.
10. خروجی profile به caller برگردانده می شود.
### شرط های match شدن rule
تابع `_match_rule` این موارد را بررسی می کند:
- فعال بودن rule
- سازگاری پلن اشتراک
- سازگاری نوع مزرعه
- وجود حداقل یک product منطبق
- وجود حداقل یک sensor catalog منطبق
- وجود تقاطع با `metadata["sensor_catalog_codes"]`
### ساختار تقریبی خروجی profile
```json
{
"farm_uuid": "...",
"subscription_plan": {
"uuid": "...",
"code": "gold",
"name": "Gold"
},
"features": {
"farm_dashboard": {
"name": "Farm Dashboard",
"type": "page",
"enabled": true,
"source": "starter-dashboard-rule"
}
},
"matched_rules": [
{
"code": "starter-dashboard-rule",
"name": "Starter Dashboard Rule",
"effect": "allow",
"priority": 10
}
],
"resolved_from_profile": true
}
```
این خروجی بیشتر برای نمایش، debugging، یا ساخت viewهای profile-based مناسب است.
---
## کنترل دسترسی runtime با OPA
### ایده کلی
در زمان درخواست API، بک اند خودش ruleها را برای همه routeها نهایی نمی کند؛ بلکه context را به سرویس OPA می فرستد و جواب allow/deny می گیرد.
### چرا OPA؟
مزایا:
- جدا شدن policy از کد بک اند
- امکان تغییر ruleها بدون بازنویسی منطق اصلی viewها
- مناسب برای تصمیم گیری policy-based و context-aware
### تنظیمات OPA
در `config/settings.py`:
- `ACCESS_CONTROL_AUTHZ_ENABLED`
- `ACCESS_CONTROL_AUTHZ_BASE_URL`
- `ACCESS_CONTROL_AUTHZ_BATCH_PATH`
- `ACCESS_CONTROL_AUTHZ_TIMEOUT`
- `ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT`
مقدار پیش فرض base URL:
- `http://croplogic-accsess-opa:8181`
مسیر پیش فرض batch authorization:
- `/v1/data/croplogic/authz/batch_decision`
---
## ساخت payload برای OPA
تابع های مهم:
- `build_opa_user`
- `build_opa_resource`
- `build_authorization_input`
### build_opa_user
اطلاعات user را به فرم policy-friendly تبدیل می کند:
- `id`
- `username`
- `email`
- `phone_number`
- `is_staff`
- `is_superuser`
- `role` که فعلا ثابت و برابر `farmer` است
### build_opa_resource
اطلاعات مزرعه را به فرم resource برمی گرداند. اگر مزرعه وجود نداشته باشد، فیلدها با مقدارهای خالی برگردانده می شوند.
فیلدهای مهم resource:
- `farm_id`
- `subscription_plan_codes`
- `farm_types`
- `crop_types`
- `sensor_codes`
- `power_sensor`
- `customization`
نکته:
`power_sensor` از `sensor.power_source.type` استخراج می شود، اگر این ساختار به صورت dict ذخیره شده باشد.
### build_authorization_input
payload نهایی برای OPA را می سازد:
```json
{
"user": {"...": "..."},
"resource": {"...": "..."},
"features": ["farm_dashboard", "notifications"],
"action": "view",
"route": "/api/dashboard/"
}
```
---
## درخواست batch به OPA
تابع اصلی: `request_opa_batch_authorization`
### رفتار
1. اگر `ACCESS_CONTROL_AUTHZ_ENABLED=false` باشد، برای همه featureها مقدار `true` برمی گرداند.
2. اگر لیست feature خالی باشد، پاسخ خالی برمی گرداند.
3. در غیر این صورت با `requests.post` به OPA درخواست می فرستد.
4. payload داخل کلید `input` ارسال می شود.
5. نتیجه از `response.json().get("result", {})` خوانده می شود.
### Error handling
اگر ارتباط با OPA fail شود:
- exception از نوع `requests.RequestException` گرفته می شود
- event لاگ می شود
- metric ثبت می شود
- خطای `AccessControlServiceUnavailable` بالا انداخته می شود
اگر OPA JSON نامعتبر برگرداند:
- metric `access_control.opa.invalid_json` ثبت می شود
- `AccessControlServiceUnavailable` برگردانده می شود
اگر `result` خالی باشد:
- metric `access_control.opa.empty_result` ثبت می شود
- warning لاگ می شود
---
## فرمت های پاسخ قابل قبول از OPA
تابع `normalize_opa_batch_result` چند نوع payload را پشتیبانی می کند:
### حالت 1: decisions
```json
{
"decisions": {
"farm_dashboard": true,
"notifications": false
}
}
```
### حالت 2: features با ساختار nested
```json
{
"features": {
"farm_dashboard": {"allow": true},
"notifications": {"allow": false}
}
}
```
### حالت 3: allowed_features
```json
{
"allowed_features": ["farm_dashboard"]
}
```
### حالت 4: map ساده از booleanها
```json
{
"farm_dashboard": true,
"notifications": false
}
```
اگر payload خارج از این الگوها باشد، سیستم آن را unsupported تلقی می کند و `AccessControlServiceUnavailable` می دهد.
---
## لایه cache
تابع اصلی: `batch_authorize_features`
### چرا cache داریم؟
برای جلوگیری از درخواست تکراری به OPA در routeهای پرترافیک.
### کلید cache چگونه ساخته می شود؟
تابع `_get_authorization_cache_key` این داده ها را hash می کند:
- `farm_uuid`
- `user_id`
- `features` به صورت sorted
- `action`
- `route`
سپس خروجی SHA-256 با prefix زیر ذخیره می شود:
- `access-control:authz:<sha256>`
### زمان انقضا
از `ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT` می آید که پیش فرض آن 300 ثانیه است.
### رفتار خطا در cache
اگر خواندن یا نوشتن cache fail شود:
- خطا swallow می شود
- warning و metric observability ثبت می شود
- authorization ادامه پیدا می کند
پس cache optimization است، نه dependency حیاتی.
---
## نگاشت method به action
تابع `get_authorization_action` از `ACTION_MAP` استفاده می کند:
- `GET`, `HEAD`, `OPTIONS` -> `view`
- `POST` -> `create`
- `PUT`, `PATCH` -> `edit`
- `DELETE` -> `delete`
این action در payload ارسالی به OPA قرار می گیرد.
---
## کنترل دسترسی در سطح Route
پیاده سازی در `access_control/middleware.py`
کلاس: `RouteFeatureAccessMiddleware`
### هدف
قبل از رسیدن request به view، بر اساس app مربوط به route، یک `feature_code` سراسری پیدا کند و اجازه دسترسی را چک کند.
### منبع feature_code برای route
از فایل `config/feature.json` استفاده می شود.
مثلا:
- `account` -> `account_management`
- `farm_hub` -> `farm_management`
- `dashboard` -> `farm_dashboard`
- `notifications` -> `notifications`
### جریان اجرا
1. اگر `view_class` وجود نداشته باشد، middleware کاری نمی کند.
2. اگر view، `AllowAny` داشته باشد، middleware عبور می دهد.
3. اگر user هنوز authenticate نشده باشد، با JWT تلاش به authenticate می کند.
4. از روی نام app در ماژول view، `feature_code` route پیدا می شود.
5. اگر mapping وجود نداشته باشد، request بدون این check عبور می کند.
6. اگر `farm_uuid` در path/query/body باشد، مزرعه متعلق به user لود می شود.
7. تابع `authorize_feature` با context فعلی صدا زده می شود.
8. اگر deny شود، پاسخ `403` برمی گرداند.
9. اگر OPA unavailable باشد، پاسخ `503` برمی گرداند.
10. اگر مجاز باشد، مقدار `request.route_feature_code` ست می شود.
### استخراج farm_uuid
middleware به این ترتیب `farm_uuid` را پیدا می کند:
- `view_kwargs`
- `request.GET`
- `get_request_data(request)` برای body JSON
### نکته مهم
در route-level check، اگر route به مزرعه خاصی وابسته نباشد، `farm=None` به OPA فرستاده می شود. بنابراین policy باید بتواند هر دو حالت farm-aware و farm-less را مدیریت کند.
---
## کنترل دسترسی در سطح View/Feature
پیاده سازی در `access_control/permissions.py`
کلاس: `FeatureAccessPermission`
### هدف
برای viewهایی که یک feature خاص لازم دارند، علاوه بر route-level feature، یک feature جزئی تر هم بررسی شود.
### نحوه فعال شدن
هر view می تواند property زیر را تعریف کند:
```python
required_feature_code = "some_feature_code"
```
اگر این property وجود نداشته باشد، این permission به صورت خودکار `True` برمی گرداند.
### جریان اجرا
1. `required_feature_code` از view خوانده می شود.
2. `farm_uuid` از `kwargs`، `query_params` یا body گرفته می شود.
3. مزرعه با شرط `owner=request.user` لود می شود.
4. `authorize_feature` برای همان feature خاص اجرا می شود.
5. در صورت deny، پیام `Access to feature ... is denied.` تنظیم می شود.
6. در صورت unavailable بودن OPA، متن exception به عنوان message برمی گردد.
### تفاوت با middleware
- middleware بر اساس `app -> route feature` تصمیم می گیرد.
- permission بر اساس `required_feature_code` هر view تصمیم می گیرد.
پس ممکن است یک request از middleware عبور کند، ولی در permission لایه دوم رد شود.
---
## استخراج body برای authorization
تابع `get_request_data` برای این ساخته شده که حتی قبل از parse کامل DRF هم بتوان `farm_uuid` را از request پیدا کرد.
### رفتار
- اگر `request.data` از قبل dict یا `QueryDict` باشد، همان برگردانده می شود.
- اگر body خالی باشد، `{}` برمی گردد.
- اگر `content-type` برابر `application/json` باشد، body parse می شود.
- فقط اگر خروجی یک dict باشد cache می شود و برگردانده می شود.
- اگر parse خطا بدهد، `{}` برمی گردد.
این رفتار مخصوصا برای middleware مهم است، چون آنجا همیشه `request.data` آماده نیست.
---
## API موجود در access_control
در حال حاضر route رسمی app این است:
- `POST /api/access-control/farms/<farm_uuid>/authorize/`
تعریف آن در `access_control/urls.py` آمده است.
### View مربوطه
`FarmFeatureAuthorizationView` در `access_control/views.py`
### ورودی
serializer:
```json
{
"features": ["farm_dashboard", "notifications"],
"action": "view"
}
```
- `features` اجباری و non-empty است
- `action` اختیاری است و پیش فرض آن `view` است
### رفتار endpoint
1. کاربر باید authenticate باشد.
2. مزرعه با `farm_uuid` و `owner=request.user` پیدا می شود.
3. request مستقیم به `request_opa_batch_authorization` می رود.
4. پاسخ OPA بدون normalize شدن کامل، داخل `decision` برگردانده می شود.
### پاسخ موفق
ساختار تقریبی:
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "...",
"user": {
"id": 1,
"username": "user",
"email": "user@example.com",
"phone_number": "0912..."
},
"features": ["farm_dashboard"],
"action": "view",
"decision": {
"decisions": {
"farm_dashboard": true
}
}
}
}
```
### پاسخ های خطا
- `404`: مزرعه پیدا نشد
- `503`: سرویس OPA در دسترس نیست
---
## نگاشت app به feature در `config/feature.json`
این فایل تعیین می کند هر app در سطح route با چه featureی کنترل شود.
نمونه های فعلی:
- `auth` -> `auth_access`
- `account` -> `account_management`
- `farm_hub` -> `farm_management`
- `dashboard` -> `farm_dashboard`
- `irrigation` -> `irrigation`
- `fertilization` -> `fertilization`
- `notifications` -> `notifications`
- `access_control` -> `access_control`
### نکته طراحی
این mapping coarse-grained است؛ یعنی در سطح app عمل می کند، نه در سطح تک endpoint. اگر نیاز به granularity بیشتر باشد، باید یا:
- routeها app-level جدا شوند
- یا روی viewها `required_feature_code` های ریزتر تعریف شود
---
## تعامل با authentication
- authentication پیش فرض پروژه JWT است.
- middleware اگر `request.user` آماده نباشد، خودش با `JWTAuthentication` تلاش می کند کاربر را از روی token شناسایی کند.
- اگر token نامعتبر باشد، middleware سکوت می کند و authentication را به جریان عادی DRF واگذار می کند.
این طراحی باعث می شود middleware برای کاربر ناشناس تصمیم اشتباه نگیرد و با لایه auth اصلی conflict نداشته باشد.
---
## رفتار با viewهای AllowAny
در middleware، اگر view داخل `permission_classes` از `AllowAny` استفاده کند، route-level access control روی آن route اعمال نمی شود.
این رفتار برای endpointهای عمومی مثل login، register، یا webhookها لازم است.
---
## observability و metrics
در `access_control/services.py` چند جای مهم instrumentation وجود دارد:
- `observe_operation(...)` برای اندازه گیری عملیات batch authorization
- `log_event(...)` برای ثبت خطاها و warningها
- `record_metric(...)` برای شمارنده هایی مثل:
- `access_control.opa.failure`
- `access_control.opa.invalid_json`
- `access_control.opa.empty_result`
این داده ها برای monitoring کیفیت ارتباط با OPA مهم هستند.
---
## تست های موجود
تست های اصلی در `access_control/tests.py` و بخشی در `farm_hub/tests.py` قرار دارند.
مواردی که الان تست شده اند:
- cache شدن authorization برای route یکسان
- وجود `route` در payload ارسالی به OPA
- پشتیبانی از payload nested در `normalize_opa_batch_result`
- ثبت metric هنگام invalid JSON از OPA
- ارسال `feature_code` و `action` درست از middleware
- resolve شدن profile بر اساس چند rule مختلف
- fallback شدن subscription plan به پلن پیش فرض
این تست ها نشان می دهند که سیستم هم لایه runtime authorization و هم لایه profile resolution را پوشش داده است.
---
## محدودیت ها و نکات مهم فعلی
### 1) route-level mapping در سطح app است
اگر یک app چند endpoint با سطح دسترسی متفاوت داشته باشد، `config/feature.json` به تنهایی کافی نیست و باید از `required_feature_code` یا سیاست های دقیق تر استفاده شود.
### 2) نقش کاربر فعلا ساده است
در `build_opa_user` مقدار `role` فعلا همیشه `farmer` است. اگر در آینده نقش هایی مثل admin، agronomist یا support اضافه شوند، این بخش باید واقعی تر شود.
### 3) profile builder و runtime OPA دو مسیر متفاوت دارند
- `build_farm_access_profile` ruleها را داخل خود Django resolve می کند.
- runtime authorization از OPA جواب می گیرد.
اگر policyهای OPA و داده های rule داخل Django از هم فاصله بگیرند، ممکن است اختلاف رفتار ایجاد شود. بنابراین باید policyها و rule modelها هماهنگ نگه داشته شوند.
### 4) endpoint رسمی profile در app دیده نمی شود
در کد فعلی `access_control/urls.py` فقط endpoint `authorize` ثبت شده است. پس اگر قرار باشد profile نهایی برای فرانت نمایش داده شود، یا باید endpoint جدید ساخته شود یا از سرویس `build_farm_access_profile` در view دیگری استفاده شود.
### 5) migrationهای seed فعلا خالی هستند
فایل های `0003_seed_default_access_rules.py` و `0004_enable_default_feature_access.py` در وضعیت فعلی عملیات migration ندارند. یعنی seed اولیه ruleها و featureها احتمالا هنوز به صورت migration-based پیاده نشده یا بعدا منتقل شده است.
---
## سناریوی کامل اجرای یک request
فرض کنیم کاربر درخواست زیر را می زند:
- `PATCH /api/account/profile/`
جریان کلی:
1. request وارد middleware می شود.
2. middleware تشخیص می دهد app این route برابر `account` است.
3. از `config/feature.json`، feature برابر `account_management` پیدا می شود.
4. method برابر `PATCH` است، پس action می شود `edit`.
5. context کاربر و مزرعه احتمالی جمع آوری می شود.
6. درخواست batch به OPA ارسال می شود.
7. اگر `account_management` مجاز باشد، request به DRF می رسد.
8. DRF authentication/permissionهای عادی را هم بررسی می کند.
9. اگر view یک `required_feature_code` اضافی داشته باشد، `FeatureAccessPermission` دوباره access را بررسی می کند.
10. اگر همه چیز مجاز باشد، business logic view اجرا می شود.
---
## راهنمای توسعه و افزودن قابلیت جدید
اگر بخواهید یک feature جدید به سیستم اضافه کنید، این مراحل پیشنهاد می شود:
### افزودن feature جدید
1. یک `AccessFeature` جدید با `code` مناسب بسازید.
2. اگر لازم است route-level باشد، app مربوطه را در `config/feature.json` map کنید.
3. اگر granular است، روی view مقدار `required_feature_code` تعریف کنید.
4. policy متناظر را در OPA اضافه یا به روز کنید.
5. اگر profile-based resolution هم مهم است، ruleهای Django را هم اضافه کنید.
6. تست middleware/permission/profile را اضافه کنید.
### افزودن rule جدید
1. `AccessRule` بسازید.
2. featureها را به آن وصل کنید.
3. شرط ها را با یکی یا چند مورد از این ها تنظیم کنید:
- subscription plan
- farm type
- product
- sensor catalog
- metadata مثل `sensor_catalog_codes`
4. `priority` را دقیق انتخاب کنید تا overrideها قابل پیش بینی باشند.
---
## جمع بندی
سیستم `access_control` در این پروژه یک لایه دسترسی چندبعدی است که:
- بر پایه user و farm تصمیم می گیرد
- پلن اشتراک، نوع مزرعه، محصول و سنسور را وارد تصمیم می کند
- در سطح route و feature قابل اعمال است
- تصمیم runtime را به OPA واگذار می کند
- برای performance از cache استفاده می کند
- برای profile/resolution داخلی، snapshot قابل ذخیره می سازد
اگر بخواهیم خیلی خلاصه بگوییم:
- `models.py` تعریف می کند چه چیزهایی روی access اثر دارند
- `services.py` context را می سازد، OPA را صدا می زند و نتیجه را normalize می کند
- `middleware.py` روی routeها gatekeeper است
- `permissions.py` روی featureهای ریزتر gatekeeper است
- `feature.json` mapping سطح app را مشخص می کند
- `settings.py` کل سیستم را فعال و تنظیم می کند
File diff suppressed because it is too large Load Diff
@@ -1,612 +0,0 @@
# راهنمای فرانت برای API های Location Data
این فایل برای تیم فرانت نوشته شده تا بتواند API های `Location Data` را سریع و دقیق مصرف کند.
مسیرهای اصلی:
- `GET /api/location-data/`
- `POST /api/location-data/`
- `POST /api/location-data/ndvi-health/`
- `GET /api/location-data/remote-sensing/`
- `POST /api/location-data/remote-sensing/`
- `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/`
- `GET /api/location-data/remote-sensing/cluster-recommendations/`
- `GET /api/location-data/remote-sensing/results/{result_id}/k-options/`
- `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/`
- `GET /api/location-data/remote-sensing/runs/{run_id}/status/`
## احراز هویت
همه این endpointها با JWT کار می‌کنند.
نمونه header:
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
## ساختار عمومی response
تقریبا همه endpointها این فرم را دارند:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
قاعده پیشنهادی در فرانت:
1. اول `HTTP status` را چک کنید.
2. بعد `code` را از body چک کنید.
3. در موفقیت، فقط `data` را به state یا UI بدهید.
4. در خطا، `msg` را به عنوان پیام اصلی نمایش دهید.
5. اگر `data` شامل خطای فیلدها بود، آن را برای فرم map کنید.
نمونه خطای validation:
```json
{
"code": 400,
"msg": "داده نامعتبر.",
"data": {
"farm_uuid": ["This field is required."]
}
}
```
نمونه خطای not found:
```json
{
"code": 404,
"msg": "location پیدا نشد.",
"data": null
}
```
---
## 1) دریافت location ذخیره شده
### `GET /api/location-data/`
کاربرد:
- خواندن location ذخیره شده
- دریافت farm boundary
- دریافت block layout
- دریافت subdivisionها و snapshotهای ماهواره ای ذخیره شده
### query params
- `lat` اختیاری
- `lon` اختیاری
- `farm_uuid` اختیاری
### نمونه درخواست
```http
GET /api/location-data/?farm_uuid=<farm_uuid>
```
### نمونه response
```json
{
"code": 200,
"msg": "success",
"data": {
"source": "database",
"id": 12,
"lon": "51.389000",
"lat": "35.689200",
"input_block_count": 2,
"farm_boundary": {},
"block_layout": {},
"block_subdivisions": [],
"satellite_snapshots": []
}
}
```
### استفاده در فرانت
- `farm_boundary` را برای رسم polygon کل مزرعه استفاده کنید.
- `block_layout` را برای رندر blockها استفاده کنید.
- `block_subdivisions` برای نمایش grid/subdivision مفید است.
- `satellite_snapshots` برای summaryهای تاریخی یا cache قابل استفاده است.
---
## 2) ثبت یا به روزرسانی location
### `POST /api/location-data/`
کاربرد:
- ساخت location جدید
- update location قبلی
- ثبت farm boundary
- ثبت block layout
### نمونه body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"lat": 35.6892,
"lon": 51.389,
"farm_boundary": {
"type": "Polygon",
"coordinates": []
},
"block_layout": {
"blocks": []
}
}
```
### نکات فرانت
- اگر کاربر هنوز boundary را کامل نکرده، این endpoint را صدا نزنید.
- در صورت دریافت `source = created` می‌توانید UI را به عنوان location جدید mark کنید.
- در صورت دریافت `source = database` یعنی رکورد از قبل وجود داشته یا update شده است.
---
## 3) دریافت NDVI health
### `POST /api/location-data/ndvi-health/`
کاربرد:
- گرفتن کارت سلامت پوشش گیاهی مزرعه
- نمایش شاخص NDVI در UI
### body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### response مهم
```json
{
"code": 200,
"msg": "success",
"data": {
"ndviIndex": 0.63,
"mean_ndvi": 0.63,
"ndvi_map": {},
"vegetation_health_class": "healthy",
"observation_date": "2026-05-12",
"satellite_source": "sentinel-2",
"healthData": [
{
"title": "میانگین NDVI",
"value": 0.63,
"color": "green",
"icon": "leaf"
}
]
}
}
```
### استفاده در فرانت
- `ndviIndex` را به عنوان KPI اصلی نمایش دهید.
- `vegetation_health_class` را برای badge یا رنگ وضعیت استفاده کنید.
- `healthData` را برای کارت های summary استفاده کنید.
- `ndvi_map` اگر لایه نقشه داشت، به map layer وصل شود.
---
## 4) خواندن cache سنجش از دور
### `GET /api/location-data/remote-sensing/`
کاربرد:
- فقط داده cache شده یا ذخیره شده را می‌خواند
- پردازش جدید شروع نمی‌کند
### query params
- `farm_uuid` اجباری
- `page` اختیاری
- `page_size` اختیاری
- `start_date` اختیاری
- `end_date` اختیاری
### نمونه درخواست
```http
GET /api/location-data/remote-sensing/?farm_uuid=<farm_uuid>&page=1&page_size=50
```
### فیلدهای مهم response
- `status`
- `source`
- `location`
- `summary`
- `cells`
- `run`
- `subdivision_result`
- `pagination`
- `metadata`
### رفتار پیشنهادی در فرانت بر اساس `status`
- `success`: داده آماده است و باید render شود.
- `processing`: هنوز نتیجه نهایی آماده نیست؛ loading یا polling state نشان دهید.
- `not_found`: هنوز تحلیل برای این مزرعه ساخته نشده؛ می‌توانید `POST /remote-sensing/` را بزنید.
### استفاده در فرانت
- `cells` برای نقشه سلولی و heatmap مناسب است.
- `summary` برای کارت آماری بالای صفحه مناسب است.
- `subdivision_result.cluster_blocks` برای نمایش cluster polygonها استفاده شود.
- `assignments` برای رنگ آمیزی سلول ها بر اساس label کلاستر مفید است.
---
## 5) شروع تحلیل سنجش از دور
### `POST /api/location-data/remote-sensing/`
کاربرد:
- شروع async processing
- ساخت run و `task_id`
- شروع جریان polling برای UI
### body
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### response
```json
{
"code": 202,
"msg": "تحلیل سنجش‌ازدور در صف قرار گرفت.",
"data": {
"status": "processing",
"source": "processing",
"location": {},
"block_code": "",
"chunk_size_sqm": 900,
"temporal_extent": {
"start_date": "2026-04-12",
"end_date": "2026-05-12"
},
"summary": {
"cell_count": 0,
"ndvi_mean": null,
"ndwi_mean": null,
"soil_vv_db_mean": null
},
"cells": [],
"run": {},
"task_id": "11111111-1111-1111-1111-111111111111"
}
}
```
### قرارداد مهم برای فرانت
- همیشه `task_id` را ذخیره کنید.
- اگر `run.id` موجود بود، برای status endpoint از آن استفاده کنید.
- بعد از این endpoint بلافاصله polling را شروع کنید.
### flow پیشنهادی
```text
POST /remote-sensing/
-> دریافت task_id / run
-> هر چند ثانیه GET /runs/{run_id}/status/
-> وقتی status = completed شد، همان payload را مصرف کن
```
---
## 6) دریافت live metrics برای یک cluster
### `GET /api/location-data/remote-sensing/cluster-blocks/{cluster_uuid}/live/`
کاربرد:
- گرفتن metricهای یک cluster
- استفاده برای panel جزئیات یا modal زنده
### نمونه درخواست
```http
GET /api/location-data/remote-sensing/cluster-blocks/<cluster_uuid>/live/
```
### فیلدهای مهم
- `source`
- `cluster_block`
- `summary`
- `metrics`
- `metadata`
### نکات فرانت
- اگر `source = database` بود، label بزنید که داده cache است.
- اگر `source = openeo` بود، می‌توانید label زنده یا live نمایش دهید.
- `metrics` برای KPIهای سریع مناسب است.
- `cluster_block.geometry` را برای هایلایت روی نقشه استفاده کنید.
---
## 7) پیشنهاد گیاه برای clusterها
### `GET /api/location-data/remote-sensing/cluster-recommendations/`
کاربرد:
- دریافت پیشنهاد محصول برای هر cluster
- نمایش candidate plantها و suggested plant
### query params
- `farm_uuid` اجباری
نمونه:
```http
GET /api/location-data/remote-sensing/cluster-recommendations/?farm_uuid=<farm_uuid>
```
### فیلدهای مهم response
- `farm_uuid`
- `location_id`
- `registered_plants`
- `clusters`
- `evaluated_plant_count`
- `cluster_count`
- `source_metadata`
### نکات مهم برای فرانت
- هر آیتم `clusters` دقیقا مربوط به یک cluster از خروجی KMeans است.
- `candidate_plants` لیست کامل رتبه‌بندی است و `suggested_plant` بهترین آیتم همان لیست است.
- `resolved_metrics` همان متریک نهایی است که برای simulation استفاده شده و بهتر است مبنای نمایش KPI باشد.
- `cluster_block` برای رسم روی نقشه و نمایش geometry، centroid و cellها استفاده می‌شود.
- `source_metadata.has_sensor_metrics` مشخص می‌کند آیا باید در UI بخش سنسورها را نمایش دهید یا نه.
### استفاده در فرانت
برای هر cluster این بخش ها مهم هستند:
- `sub_block_code`
- `cluster_label`
- `temporal_extent`
- `cluster_block`
- `satellite_metrics`
- `sensor_metrics`
- `resolved_metrics`
- `candidate_plants`
- `suggested_plant`
### UI پیشنهادی
- کارت cluster با عنوان `sub_block_code` یا `cluster_label`
- بازه زمانی از `temporal_extent.start_date` تا `temporal_extent.end_date`
- KPIهای `resolved_metrics`
- جدول candidateها با score
- highlight کردن `suggested_plant`
- اگر `candidate_plants` خالی بود، state خالی و بدون recommendation نشان دهید
---
## 8) لیست K optionها
### `GET /api/location-data/remote-sensing/results/{result_id}/k-options/`
کاربرد:
- گرفتن همه Kهای ذخیره شده برای یک subdivision result
### response مهم
```json
{
"code": 200,
"msg": "success",
"data": {
"result_id": 5,
"active_requested_k": 3,
"recommended_requested_k": 4,
"options": []
}
}
```
### استفاده در فرانت
- `active_requested_k` را به عنوان گزینه فعال UI نشان دهید.
- `recommended_requested_k` را با badge پیشنهادی نمایش دهید.
- `options` را برای dropdown یا segmented control استفاده کنید.
---
## 9) فعال سازی یک K
### `POST /api/location-data/remote-sensing/results/{result_id}/k-options/activate/`
### body
```json
{
"requested_k": 4
}
```
### استفاده در فرانت
- وقتی کاربر K جدید را انتخاب می‌کند این endpoint را صدا بزنید.
- بعد از موفقیت، `subdivision_result` برگشتی را جایگزین state قبلی کنید.
- لازم نیست دوباره `GET /remote-sensing/` را صدا بزنید اگر payload کامل برگشت.
---
## 10) polling وضعیت run
### `GET /api/location-data/remote-sensing/runs/{run_id}/status/`
کاربرد:
- فهمیدن این که pipeline در چه مرحله‌ای است
- دریافت نتیجه نهایی به محض completion
### statusهای مهم
- `pending`
- `running`
- `retrying`
- `completed`
- `failed`
### رفتار پیشنهادی در فرانت
- `pending`: queue state
- `running`: progress state
- `retrying`: پیام retry موقت
- `completed`: داده نهایی را render کن
- `failed`: CTA برای retry بده
### نکته مهم
اگر `status = completed` شد، همان response نهایی را مصرف کنید و polling را stop کنید.
---
## فیلدهای مهم برای map
### farm level
- `farm_boundary`
- `block_layout.blocks`
- `block_subdivisions`
### remote sensing level
- `cells[].geometry`
- `subdivision_result.cluster_blocks[].geometry`
- `subdivision_result.assignments[]`
- `cluster_block.geometry`
### پیشنهاد برای لایه های نقشه
1. لایه مرز مزرعه
2. لایه blockها
3. لایه cellها یا heatmap
4. لایه cluster blockها
5. لایه selected cluster highlight
---
## خطاهایی که فرانت باید handle کند
### 400
- ورودی ناقص یا نامعتبر
- باید خطای فیلدی یا toast نشان داده شود
### 404
- مزرعه یا location یا result پیدا نشده
- برای UI بهتر است empty state نمایش داده شود
### 502
- خطا از backend upstream مثل openEO یا AI
- بهتر است retry action داشته باشید
---
## flow پیشنهادی کامل برای صفحه تحلیل
### سناریو اول: فقط نمایش داده موجود
```text
GET /api/location-data/remote-sensing/?farm_uuid=<farm_uuid>
-> اگر status=success : render
-> اگر status=processing : برو به polling
-> اگر status=not_found : دکمه شروع تحلیل نمایش بده
```
### سناریو دوم: کاربر تحلیل را شروع می‌کند
```text
POST /api/location-data/remote-sensing/
-> 202
-> run/task_id را ذخیره کن
-> GET /api/location-data/remote-sensing/runs/{run_id}/status/
-> وقتی completed شد نتیجه را render کن
```
### سناریو سوم: کاربر K را تغییر می‌دهد
```text
GET /results/{result_id}/k-options/
-> انتخاب K
-> POST /results/{result_id}/k-options/activate/
-> subdivision_result جدید را render کن
```
---
## پیشنهاد state management در فرانت
حداقل stateهایی که نیاز دارید:
```ts
{
location: null,
remoteSensing: null,
runStatus: null,
clusterRecommendations: [],
selectedClusterUuid: null,
kOptions: [],
loading: false,
polling: false,
error: null
}
```
---
## نکات نهایی برای تیم فرانت
- برای endpointهای async همیشه polling را در نظر بگیرید.
- `code` را از body نادیده نگیرید.
- روی `status` در remote sensing و run status منطق UI بنویسید.
- داده های هندسی را مستقیم برای map layerها مصرف کنید.
- `cluster_uuid`, `result_id`, `run_id` را بعد از اولین response در state نگه دارید.
---
## فایل مکمل
اگر به جزئیات کامل همه responseها نیاز دارید، این فایل را هم ببینید:
- `docs/location_data_api_responses_fa.md`
-4
View File
@@ -53,8 +53,6 @@ class FarmDeviceSerializer(serializers.ModelSerializer):
"physical_device_uuid", "physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"cluster_uuid",
"location_metadata",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
@@ -118,8 +116,6 @@ class FarmDeviceWriteSerializer(serializers.ModelSerializer):
"physical_device_uuid", "physical_device_uuid",
"name", "name",
"sensor_type", "sensor_type",
"cluster_uuid",
"location_metadata",
"is_active", "is_active",
"specifications", "specifications",
"power_source", "power_source",
+5 -27
View File
@@ -4,10 +4,10 @@ from django.conf import settings
from django.db import transaction from django.db import transaction
from crop_zoning.services import ( from crop_zoning.services import (
create_zones_and_dispatch,
get_default_area_feature, get_default_area_feature,
get_initial_zones_payload, get_initial_zones_payload,
normalize_area_feature, normalize_area_feature,
ensure_latest_area_ready_for_processing,
) )
from external_api_adapter import request as external_api_request from external_api_adapter import request as external_api_request
from external_api_adapter.exceptions import ExternalAPIRequestError from external_api_adapter.exceptions import ExternalAPIRequestError
@@ -22,11 +22,7 @@ class FarmDataSyncError(Exception):
def dispatch_farm_zoning(area_feature, farm): def dispatch_farm_zoning(area_feature, farm):
crop_area = ensure_latest_area_ready_for_processing( crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm)
farm_uuid=farm.farm_uuid,
area_feature=normalize_area_feature(area_feature),
owner=farm.owner,
)
return crop_area, get_initial_zones_payload(crop_area) return crop_area, get_initial_zones_payload(crop_area)
@@ -84,32 +80,15 @@ def sync_farm_data(
if plant_ids: if plant_ids:
request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids] request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids]
if farm.farm_type_id:
request_payload["farm_type_uuid"] = str(farm.farm_type.uuid)
request_payload["farm_type_name"] = farm.farm_type.name
request_payload["farm_type_description"] = farm.farm_type.description
request_payload["farm_type_metadata"] = (
farm.farm_type.metadata if isinstance(farm.farm_type.metadata, dict) else {}
)
resolved_irrigation_method_id = irrigation_method_id resolved_irrigation_method_id = irrigation_method_id
if resolved_irrigation_method_id is None: if resolved_irrigation_method_id is None:
resolved_irrigation_method_id = farm.irrigation_method_id resolved_irrigation_method_id = farm.irrigation_method_id
if resolved_irrigation_method_id is not None: if resolved_irrigation_method_id is not None:
request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id) request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id)
if not any( if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")):
key in request_payload
for key in (
"sensor_payload",
"plant_ids",
"farm_type_uuid",
"farm_type_name",
"irrigation_method_id",
)
):
raise FarmDataSyncError( raise FarmDataSyncError(
"At least one of `sensor_payload`, `plant_ids`, `farm_type`, or `irrigation_method_id` is required for farm data sync." "At least one of `sensor_payload`, `plant_ids`, or `irrigation_method_id` is required for farm data sync."
) )
api_key = getattr(settings, "FARM_DATA_API_KEY", "") api_key = getattr(settings, "FARM_DATA_API_KEY", "")
@@ -118,12 +97,11 @@ def sync_farm_data(
raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.") raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.")
logger.warning( logger.warning(
"Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s farm_type_uuid=%s irrigation_method_id=%s boundary_type=%s", "Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s irrigation_method_id=%s boundary_type=%s",
farm.farm_uuid, farm.farm_uuid,
request_payload.get("sensor_key"), request_payload.get("sensor_key"),
"sensor_payload" in request_payload, "sensor_payload" in request_payload,
request_payload.get("plant_ids"), request_payload.get("plant_ids"),
request_payload.get("farm_type_uuid"),
request_payload.get("irrigation_method_id"), request_payload.get("irrigation_method_id"),
request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None, request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None,
) )
+1 -3
View File
@@ -97,14 +97,12 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(len(response.data["data"]["sensors"]), 1) self.assertEqual(len(response.data["data"]["sensors"]), 1)
self.assertEqual(response.data["data"]["sensors"][0]["sensor_catalog_uuid"], str(self.weather_station.uuid)) self.assertEqual(response.data["data"]["sensors"][0]["sensor_catalog_uuid"], str(self.weather_station.uuid))
self.assertEqual(response.data["data"]["sensors"][0]["physical_device_uuid"], physical_device_uuid) self.assertEqual(response.data["data"]["sensors"][0]["physical_device_uuid"], physical_device_uuid)
self.assertEqual(response.data["data"]["zoning"]["zone_count"], 0) self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1)
self.assertEqual(response.data["data"]["zoning"]["zones"], [])
self.assertEqual( self.assertEqual(
response.data["data"]["zoning"]["zone_count"], response.data["data"]["zoning"]["zone_count"],
CropArea.objects.get().zone_count, CropArea.objects.get().zone_count,
) )
self.assertEqual(CropArea.objects.count(), 1) self.assertEqual(CropArea.objects.count(), 1)
self.assertEqual(CropArea.objects.get().geometry, AREA_GEOJSON)
mock_external_api_request.assert_called_once_with( mock_external_api_request.assert_called_once_with(
"ai", "ai",
"/api/farm-data/", "/api/farm-data/",
+1 -13
View File
@@ -119,19 +119,7 @@ def serialize_products_for_ai(products=None):
"growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {}, "growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {},
"is_active": True, "is_active": True,
"updated_at": product.updated_at.isoformat() if product.updated_at else None, "updated_at": product.updated_at.isoformat() if product.updated_at else None,
"farm_type": { "farm_type": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME,
"uuid": str(product.farm_type.uuid) if product.farm_type_id else None,
"name": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME,
"description": product.farm_type.description if product.farm_type_id else "",
"metadata": (
product.farm_type.metadata
if product.farm_type_id and isinstance(product.farm_type.metadata, dict)
else {}
),
"updated_at": product.farm_type.updated_at.isoformat()
if product.farm_type_id and product.farm_type.updated_at
else None,
},
} }
) )
return payload return payload
+44 -14
View File
@@ -1,14 +1,44 @@
Django>=5.0,<5.2 amqp==5.3.1
djangorestframework>=3.14,<3.16 asgiref==3.11.1
djangorestframework-simplejwt>=5.3,<5.4 async-timeout==5.0.1
django-cors-headers>=4.3,<4.5 attrs==26.1.0
drf-spectacular>=0.27,<0.28 billiard==4.2.4
drf-spectacular-sidecar>=2024.7.1,<2025 celery==5.3.6
celery[redis]>=5.3,<5.4 certifi==2026.4.22
redis>=5.0,<5.1 charset-normalizer==3.4.7
click==8.3.3
mysqlclient>=2.2,<2.3 click-didyoumean==0.3.1
gunicorn>=22,<23 click-plugins==1.1.1.2
python-dotenv>=1.0,<1.1 click-repl==0.3.0
requests>=2.31,<2.33 Django==5.1.15
django-cors-headers==4.4.0
djangorestframework==3.15.2
djangorestframework-simplejwt==5.3.1
drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.12.1
Faker==40.15.0
gunicorn==22.0.0
idna==3.14
inflection==0.5.1
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
kombu==5.6.2
mysqlclient==2.2.8
packaging==26.2
prompt_toolkit==3.0.52
PyJWT==2.12.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.3
redis==5.0.8
referencing==0.37.0
requests==2.32.5
rpds-py==0.30.0
six==1.17.0
sqlparse==0.5.5
typing_extensions==4.15.0
tzdata==2026.2
uritemplate==4.2.0
urllib3==2.7.0
vine==5.1.0
wcwidth==0.7.0
-428
View File
@@ -1,428 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL="${OPENAI_MODEL:-gpt-4o-mini}"
BASE_URL="${OPENAI_BASE_URL:-${GAPGPT_BASE_URL:-https://api.gapgpt.app/v1}}"
API_KEY="${OPENAI_API_KEY:-${GAPGPT_API_KEY:-"sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5"}}"
EDITOR_CMD="${EDITOR:-vi}"
SHOW_API_RESPONSE="${SHOW_API_RESPONSE:-0}"
ALLOWED_BRANCH_TYPES="documentation,feature,fixbug"
COMMIT_MESSAGE=""
BRANCH_NAME=""
FINAL_BRANCH_NAME=""
FINAL_BRANCH_MODE=""
usage() {
cat <<'USAGE'
Usage: ai_git_commit.sh
Required environment variables:
`OPENAI_API_KEY` or `GAPGPT_API_KEY`
Optional environment variables:
`OPENAI_BASE_URL` or `GAPGPT_BASE_URL` (default: https://api.gapgpt.app/v1)
`OPENAI_MODEL` Model name (default: gpt-4o-mini)
`EDITOR` Editor used for manual commit message edits
`SHOW_API_RESPONSE` Print raw API response body when set to 1
USAGE
}
show_jq_install_help() {
cat >&2 <<'EOF'
Error: 'jq' is required but not installed.
Install jq on Linux with one of these commands:
- Ubuntu/Debian: sudo apt update && sudo apt install -y jq
- Fedora/RHEL/CentOS: sudo dnf install -y jq
- Arch Linux: sudo pacman -S jq
- Alpine: sudo apk add jq
EOF
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
if [[ "$command_name" == "jq" ]]; then
show_jq_install_help
exit 1
fi
echo "Error: '$command_name' is required but not installed." >&2
exit 1
fi
}
ensure_git_repo() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "Error: current directory is not a git repository." >&2
exit 1
}
}
get_staged_diff() {
git diff --staged --patch --minimal
}
stage_all_changes() {
git add .
}
build_prompt() {
local diff_content="$1"
cat <<PROMPT
You are a senior engineer helping with Git hygiene.
Analyze the staged git diff below and respond with valid JSON only.
Requirements:
- The response must be a JSON object with exactly these keys: commit_message, branch_name.
- commit_message must be a single descriptive paragraph in plain text.
- branch_name must use the format type/short-description.
- Allowed branch types are only: documentation, feature, fixbug.
- branch_name should be lowercase, concise, and use hyphens instead of spaces.
- Do not wrap the JSON in markdown fences.
Staged diff:
${diff_content}
PROMPT
}
call_openai() {
local prompt="$1"
local endpoint="${BASE_URL%/}/chat/completions"
local payload
local response_file
local http_code
payload=$(jq -n \
--arg model "$MODEL" \
--arg prompt "$prompt" \
'{
model: $model,
temperature: 0.2,
response_format: {type: "json_object"},
messages: [
{role: "system", content: "You generate commit metadata from git diffs and always return strict JSON."},
{role: "user", content: $prompt}
]
}')
response_file=$(mktemp)
http_code=$(curl --silent --show-error \
--output "$response_file" \
--write-out "%{http_code}" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${API_KEY}" \
--data "$payload" \
"$endpoint")
if [[ "$SHOW_API_RESPONSE" == "1" ]]; then
echo
echo "Raw API response:"
echo "----------------------------------------"
cat "$response_file"
echo
echo "----------------------------------------"
fi
if [[ ! "$http_code" =~ ^2 ]]; then
echo "API request failed with HTTP ${http_code}." >&2
echo "Raw API error response:" >&2
echo "----------------------------------------" >&2
cat "$response_file" >&2
echo >&2
echo "----------------------------------------" >&2
rm -f "$response_file"
return 1
fi
cat "$response_file"
rm -f "$response_file"
}
extract_ai_content() {
local api_response="$1"
jq -er '.choices[0].message.content' <<<"$api_response"
}
parse_ai_json() {
local ai_json="$1"
COMMIT_MESSAGE=$(jq -er '.commit_message' <<<"$ai_json")
BRANCH_NAME=$(jq -er '.branch_name' <<<"$ai_json")
}
edit_multiline_value() {
local initial_value="$1"
local temp_file
temp_file=$(mktemp)
printf '%s\n' "$initial_value" > "$temp_file"
"$EDITOR_CMD" "$temp_file"
local edited_value
edited_value=$(sed '/^[[:space:]]*$/d' "$temp_file")
rm -f "$temp_file"
if [[ -z "$edited_value" ]]; then
echo "Error: value cannot be empty." >&2
exit 1
fi
printf '%s' "$edited_value"
}
edit_single_line_value() {
local current_value="$1"
local updated_value
read -r -e -i "$current_value" -p "> " updated_value </dev/tty
if [[ -z "$updated_value" ]]; then
echo "Error: value cannot be empty." >&2
exit 1
fi
printf '%s' "$updated_value"
}
confirm_pull_from_develop() {
local choice
echo >&2
echo "Before asking AI, do you want to pull the latest changes from \`develop\` into the current branch?" >&2
read -r -p "Pull from develop now? [y/N]: " choice </dev/tty
if [[ "$choice" =~ ^[Yy]$ ]]; then
git pull origin develop
fi
}
confirm_or_edit_commit_message() {
local current_message="$1"
echo >&2
echo "Suggested commit message:" >&2
echo "----------------------------------------" >&2
printf '%s\n' "$current_message" >&2
echo "----------------------------------------" >&2
echo "1) Use as-is" >&2
echo "2) Edit in \$EDITOR (${EDITOR_CMD})" >&2
local choice
read -r -p "Choose an option [1-2]: " choice </dev/tty
case "$choice" in
1) printf '%s' "$current_message" ;;
2) edit_multiline_value "$current_message" ;;
*)
echo "Error: invalid selection." >&2
exit 1
;;
esac
}
list_local_branches() {
git for-each-ref --format='%(refname:short)' refs/heads
}
select_existing_branch() {
mapfile -t branches < <(list_local_branches)
if [[ ${#branches[@]} -eq 0 ]]; then
echo "Error: no local branches found." >&2
exit 1
fi
echo >&2
echo "Select an existing branch:" >&2
select branch in "${branches[@]}"; do
if [[ -n "${branch:-}" ]]; then
printf '%s' "$branch"
return 0
fi
echo "Invalid selection. Try again." >&2
done
}
validate_branch_name() {
local branch_name="$1"
local branch_type="${branch_name%%/*}"
if ! git check-ref-format --branch "$branch_name" >/dev/null 2>&1; then
echo "Error: '$branch_name' is not a valid git branch name." >&2
exit 1
fi
if [[ "$branch_name" != */* ]]; then
echo "Error: branch name must use the format type/short-description." >&2
exit 1
fi
case "$branch_type" in
documentation|feature|fixbug) ;;
*)
echo "Error: branch type must be one of: documentation, feature, fixbug." >&2
exit 1
;;
esac
}
choose_branch() {
local suggested_branch="$1"
local branch_choice
local branch_value
local branch_mode
echo >&2
echo "Suggested branch name: $suggested_branch" >&2
echo "1) Create new branch with suggested name" >&2
echo "2) Edit branch name and create new branch" >&2
echo "3) Select an existing branch" >&2
read -r -p "Choose an option [1-3]: " branch_choice </dev/tty
case "$branch_choice" in
1)
branch_value="$suggested_branch"
branch_mode="new"
;;
2)
branch_value=$(edit_single_line_value "$suggested_branch")
branch_mode="new"
;;
3)
branch_value=$(select_existing_branch)
branch_mode="existing"
;;
*)
echo "Error: invalid selection." >&2
exit 1
;;
esac
if [[ "$branch_mode" == "new" ]]; then
validate_branch_name "$branch_value"
fi
if git show-ref --verify --quiet "refs/heads/$branch_value"; then
branch_mode="existing"
fi
FINAL_BRANCH_NAME="$branch_value"
FINAL_BRANCH_MODE="$branch_mode"
}
checkout_branch() {
local branch_name="$1"
local branch_mode="$2"
if [[ "$branch_mode" == "new" ]]; then
git checkout -b "$branch_name"
else
git checkout "$branch_name"
fi
}
push_branch() {
local branch_name="$1"
local branch_mode="$2"
if [[ "$branch_mode" == "new" ]]; then
git push -u origin "$branch_name"
else
git push origin "$branch_name"
fi
}
print_summary() {
local commit_message="$1"
local branch_name="$2"
local branch_mode="$3"
echo
echo "Final plan:"
echo "- Branch: $branch_name ($branch_mode)"
echo "- Commit message:"
printf '%s\n' "$commit_message"
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
require_command git
require_command curl
require_command jq
ensure_git_repo
if [[ -z "$API_KEY" ]]; then
echo "Error: set OPENAI_API_KEY or GAPGPT_API_KEY." >&2
exit 1
fi
confirm_pull_from_develop
stage_all_changes
local staged_diff
staged_diff=$(get_staged_diff)
if [[ -z "$staged_diff" ]]; then
echo "Error: there are no staged changes to analyze." >&2
exit 1
fi
echo "Analyzing staged changes with ${MODEL}..."
local prompt
prompt=$(build_prompt "$staged_diff")
local api_response
if ! api_response=$(call_openai "$prompt"); then
echo "Error: failed to contact the OpenAI-compatible API." >&2
exit 1
fi
local ai_content
if ! ai_content=$(extract_ai_content "$api_response"); then
echo "Error: API response did not include message content." >&2
exit 1
fi
if ! parse_ai_json "$ai_content"; then
echo "Error: AI response was not valid JSON with the required keys." >&2
exit 1
fi
local final_commit_message
final_commit_message=$(confirm_or_edit_commit_message "$COMMIT_MESSAGE")
choose_branch "$BRANCH_NAME"
print_summary "$final_commit_message" "$FINAL_BRANCH_NAME" "$FINAL_BRANCH_MODE"
local final_confirmation
read -r -p "Proceed with checkout and commit? [y/N]: " final_confirmation
if [[ ! "$final_confirmation" =~ ^[Yy]$ ]]; then
echo "Aborted. No branch switch or commit was made."
exit 0
fi
checkout_branch "$FINAL_BRANCH_NAME" "$FINAL_BRANCH_MODE"
local commit_file
commit_file=$(mktemp)
printf '%s\n' "$final_commit_message" > "$commit_file"
git commit -F "$commit_file"
rm -f "$commit_file"
push_branch "$FINAL_BRANCH_NAME" "$FINAL_BRANCH_MODE"
echo
echo "Done: committed and pushed on '$FINAL_BRANCH_NAME'."
}
main "$@"