first commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
from .models import SoilDepthData, SoilLocation
|
||||
|
||||
|
||||
class SoilDepthDataInline(admin.TabularInline):
|
||||
model = SoilDepthData
|
||||
extra = 0
|
||||
readonly_fields = ("depth_label", "bdod", "cec", "cfvo", "clay", "nitrogen", "ocd", "ocs", "phh2o", "sand", "silt", "soc", "wv0010", "wv0033", "wv1500")
|
||||
|
||||
|
||||
@admin.register(SoilLocation)
|
||||
class SoilLocationAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "latitude", "longitude", "is_complete", "created_at")
|
||||
list_filter = ("created_at",)
|
||||
search_fields = ("latitude", "longitude")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
inlines = [SoilDepthDataInline]
|
||||
|
||||
|
||||
@admin.register(SoilDepthData)
|
||||
class SoilDepthDataAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "soil_location", "depth_label", "bdod", "cec", "phh2o", "clay", "sand", "silt")
|
||||
list_filter = ("depth_label",)
|
||||
search_fields = ("soil_location__latitude", "soil_location__longitude")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SoilDataConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "location_data"
|
||||
verbose_name = "Soil Data (SoilGrids)"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Management command: اجرای یکبار rename اپ label از soil_data به location_data در DB.
|
||||
این دستور را یک بار قبل از اجرای migrate اجرا کنید:
|
||||
python manage.py rename_soil_data_label
|
||||
python manage.py migrate
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rename app label from soil_data to location_data in django_migrations and django_content_type"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE django_migrations SET app = %s WHERE app = %s",
|
||||
["location_data", "soil_data"],
|
||||
)
|
||||
migrations_updated = cursor.rowcount
|
||||
cursor.execute(
|
||||
"UPDATE django_content_type SET app_label = %s WHERE app_label = %s",
|
||||
["location_data", "soil_data"],
|
||||
)
|
||||
content_types_updated = cursor.rowcount
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done. django_migrations rows updated: {migrations_updated}, "
|
||||
f"django_content_type rows updated: {content_types_updated}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated manually for location_data
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SoilLocation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("latitude", models.DecimalField(db_index=True, decimal_places=6, help_text="عرض جغرافیایی (lat)", max_digits=9)),
|
||||
("longitude", models.DecimalField(db_index=True, decimal_places=6, help_text="طول جغرافیایی (lon)", max_digits=9)),
|
||||
("depth_0_5cm", models.JSONField(blank=True, help_text="دادههای لایه ۰–۵ سانتیمتر از API SoilGrids", null=True)),
|
||||
("depth_5_15cm", models.JSONField(blank=True, help_text="دادههای لایه ۵–۱۵ سانتیمتر از API SoilGrids", null=True)),
|
||||
("depth_15_30cm", models.JSONField(blank=True, help_text="دادههای لایه ۱۵–۳۰ سانتیمتر از API SoilGrids", null=True)),
|
||||
("task_id", models.CharField(blank=True, help_text="شناسه تسک Celery در حال پردازش", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="soillocation",
|
||||
constraint=models.UniqueConstraint(fields=("latitude", "longitude"), name="soil_location_unique_lat_lon"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
# Generated manually: refactor to SoilDepthData table
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SoilDepthData",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"depth_label",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("0-5cm", "۰–۵ سانتیمتر"),
|
||||
("5-15cm", "۵–۱۵ سانتیمتر"),
|
||||
("15-30cm", "۱۵–۳۰ سانتیمتر"),
|
||||
],
|
||||
db_index=True,
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("bdod", models.FloatField(blank=True, null=True)),
|
||||
("cec", models.FloatField(blank=True, null=True)),
|
||||
("cfvo", models.FloatField(blank=True, null=True)),
|
||||
("clay", models.FloatField(blank=True, null=True)),
|
||||
("nitrogen", models.FloatField(blank=True, null=True)),
|
||||
("ocd", models.FloatField(blank=True, null=True)),
|
||||
("ocs", models.FloatField(blank=True, null=True)),
|
||||
("phh2o", models.FloatField(blank=True, null=True)),
|
||||
("sand", models.FloatField(blank=True, null=True)),
|
||||
("silt", models.FloatField(blank=True, null=True)),
|
||||
("soc", models.FloatField(blank=True, null=True)),
|
||||
("wv0010", models.FloatField(blank=True, null=True)),
|
||||
("wv0033", models.FloatField(blank=True, null=True)),
|
||||
("wv1500", models.FloatField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"soil_location",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="depths",
|
||||
to="location_data.soillocation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["soil_location", "depth_label"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="soildepthdata",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("soil_location", "depth_label"),
|
||||
name="soil_depth_unique_location_depth",
|
||||
),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="soillocation",
|
||||
name="depth_0_5cm",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="soillocation",
|
||||
name="depth_5_15cm",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="soillocation",
|
||||
name="depth_15_30cm",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations
|
||||
from django.db import migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
نشانگر تغییر اپ label از soil_data به location_data.
|
||||
پیش از اجرای این migration، دستور زیر را اجرا کنید:
|
||||
python manage.py rename_soil_data_label
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0002_soildepthdata_refactor"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -0,0 +1,100 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SoilLocation(models.Model):
|
||||
"""
|
||||
مختصات جغرافیایی برای دادههای خاک.
|
||||
هر مختصات سه سطر در SoilDepthData دارد (۰–۵، ۵–۱۵، ۱۵–۳۰ سانتیمتر).
|
||||
"""
|
||||
|
||||
latitude = models.DecimalField(
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
db_index=True,
|
||||
help_text="عرض جغرافیایی (lat)",
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
db_index=True,
|
||||
help_text="طول جغرافیایی (lon)",
|
||||
)
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="شناسه تسک Celery در حال پردازش",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["latitude", "longitude"],
|
||||
name="soil_location_unique_lat_lon",
|
||||
)
|
||||
]
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"SoilLocation({self.latitude}, {self.longitude})"
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
"""آیا هر سه عمق ذخیره شدهاند؟"""
|
||||
return self.depths.count() == 3
|
||||
|
||||
|
||||
class SoilDepthData(models.Model):
|
||||
"""
|
||||
دادههای خاک برای یک عمق مشخص، مرتبط با یک SoilLocation.
|
||||
مقادیر خام از API SoilGrids (قبل از اعمال d_factor).
|
||||
"""
|
||||
|
||||
DEPTH_0_5 = "0-5cm"
|
||||
DEPTH_5_15 = "5-15cm"
|
||||
DEPTH_15_30 = "15-30cm"
|
||||
DEPTH_CHOICES = [
|
||||
(DEPTH_0_5, "۰–۵ سانتیمتر"),
|
||||
(DEPTH_5_15, "۵–۱۵ سانتیمتر"),
|
||||
(DEPTH_15_30, "۱۵–۳۰ سانتیمتر"),
|
||||
]
|
||||
|
||||
soil_location = models.ForeignKey(
|
||||
SoilLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="depths",
|
||||
)
|
||||
depth_label = models.CharField(
|
||||
max_length=10,
|
||||
choices=DEPTH_CHOICES,
|
||||
db_index=True,
|
||||
)
|
||||
# خواص خاک — مقادیر mean از API (raw)
|
||||
bdod = models.FloatField(null=True, blank=True)
|
||||
cec = models.FloatField(null=True, blank=True)
|
||||
cfvo = models.FloatField(null=True, blank=True)
|
||||
clay = models.FloatField(null=True, blank=True)
|
||||
nitrogen = models.FloatField(null=True, blank=True)
|
||||
ocd = models.FloatField(null=True, blank=True)
|
||||
ocs = models.FloatField(null=True, blank=True)
|
||||
phh2o = models.FloatField(null=True, blank=True)
|
||||
sand = models.FloatField(null=True, blank=True)
|
||||
silt = models.FloatField(null=True, blank=True)
|
||||
soc = models.FloatField(null=True, blank=True)
|
||||
wv0010 = models.FloatField(null=True, blank=True)
|
||||
wv0033 = models.FloatField(null=True, blank=True)
|
||||
wv1500 = models.FloatField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["soil_location", "depth_label"],
|
||||
name="soil_depth_unique_location_depth",
|
||||
)
|
||||
]
|
||||
ordering = ["soil_location", "depth_label"]
|
||||
|
||||
def __str__(self):
|
||||
return f"SoilDepthData({self.soil_location_id}, {self.depth_label})"
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Soil Data",
|
||||
"description": "API دادههای خاک (SoilGrids) بر اساس مختصات جغرافیایی",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8020"
|
||||
},
|
||||
{
|
||||
"key": "task_id",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Soil Data (query)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/soil-data/?lon=52.42&lat=36.38",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "soil-data", ""],
|
||||
"query": [
|
||||
{
|
||||
"key": "lon",
|
||||
"value": "52.42",
|
||||
"description": "طول جغرافیایی"
|
||||
},
|
||||
{
|
||||
"key": "lat",
|
||||
"value": "36.38",
|
||||
"description": "عرض جغرافیایی"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "دریافت داده خاک با lon و lat در query. اگر داده در DB باشد 200، وگرنه 202 با task_id برمیگردد."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get Soil Data (POST)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"lon\": 52.42,\n \"lat\": 36.38\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/soil-data/",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "soil-data", ""]
|
||||
},
|
||||
"description": "دریافت داده خاک با lon و lat در body. اگر داده در DB باشد 200، وگرنه 202 با task_id برمیگردد."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Task Status",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/soil-data/tasks/{{task_id}}/status/",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "soil-data", "tasks", "{{task_id}}", "status", ""]
|
||||
},
|
||||
"description": "بررسی وضعیت تسک واکشی خاک. task_id را از پاسخ 202 دریافت میکنید."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import SoilDepthData, SoilLocation
|
||||
|
||||
|
||||
class SoilDataRequestSerializer(serializers.Serializer):
|
||||
"""سریالایزر ورودی: lon و lat برای درخواست داده خاک."""
|
||||
|
||||
lon = serializers.DecimalField(max_digits=9, decimal_places=6, required=True)
|
||||
lat = serializers.DecimalField(max_digits=9, decimal_places=6, required=True)
|
||||
|
||||
|
||||
class SoilDepthDataSerializer(serializers.ModelSerializer):
|
||||
"""سریالایزر خروجی برای هر عمق خاک."""
|
||||
|
||||
class Meta:
|
||||
model = SoilDepthData
|
||||
fields = [
|
||||
"depth_label",
|
||||
"bdod",
|
||||
"cec",
|
||||
"cfvo",
|
||||
"clay",
|
||||
"nitrogen",
|
||||
"ocd",
|
||||
"ocs",
|
||||
"phh2o",
|
||||
"sand",
|
||||
"silt",
|
||||
"soc",
|
||||
"wv0010",
|
||||
"wv0033",
|
||||
"wv1500",
|
||||
]
|
||||
|
||||
|
||||
class SoilLocationResponseSerializer(serializers.ModelSerializer):
|
||||
"""سریالایزر خروجی برای SoilLocation همراه با depths."""
|
||||
|
||||
lon = serializers.DecimalField(
|
||||
source="longitude",
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
read_only=True,
|
||||
)
|
||||
lat = serializers.DecimalField(
|
||||
source="latitude",
|
||||
max_digits=9,
|
||||
decimal_places=6,
|
||||
read_only=True,
|
||||
)
|
||||
depths = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SoilLocation
|
||||
fields = ["id", "lon", "lat", "depths"]
|
||||
|
||||
def get_depths(self, obj):
|
||||
from .tasks import DEPTHS
|
||||
|
||||
depth_qs = obj.depths.all()
|
||||
order = {d: i for i, d in enumerate(DEPTHS)}
|
||||
sorted_depths = sorted(
|
||||
depth_qs, key=lambda d: order.get(d.depth_label, 99)
|
||||
)
|
||||
return SoilDepthDataSerializer(sorted_depths, many=True).data
|
||||
|
||||
|
||||
class SoilDataTaskResponseSerializer(serializers.Serializer):
|
||||
"""سریالایزر خروجی وقتی تسک در صف قرار گرفته (۲۰۲)."""
|
||||
|
||||
source = serializers.CharField(default="task")
|
||||
task_id = serializers.CharField()
|
||||
lon = serializers.FloatField(source="longitude")
|
||||
lat = serializers.FloatField(source="latitude")
|
||||
status_url = serializers.URLField(required=False)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
تسکهای Celery برای واکشی دادههای خاک از API SoilGrids.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import requests
|
||||
from config.celery import app
|
||||
from django.db import transaction
|
||||
|
||||
from .models import SoilDepthData, SoilLocation
|
||||
|
||||
SOILGRIDS_BASE = "https://rest.isric.org/soilgrids/v2.0/properties/query"
|
||||
PROPERTIES = [
|
||||
"bdod", "cec", "cfvo", "clay", "nitrogen", "ocd", "ocs",
|
||||
"phh2o", "sand", "silt", "soc", "wv0010", "wv0033", "wv1500",
|
||||
]
|
||||
VALUES = ["Q0.5", "Q0.05", "Q0.95", "mean", "uncertainty"]
|
||||
DEPTHS = ["0-5cm", "5-15cm", "15-30cm"]
|
||||
|
||||
|
||||
def _fetch_soilgrids(lon: float, lat: float, depth: str) -> dict | None:
|
||||
"""درخواست به API SoilGrids برای یک عمق."""
|
||||
params = {
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"depth": depth,
|
||||
}
|
||||
for p in PROPERTIES:
|
||||
params.setdefault("property", []).append(p)
|
||||
for v in VALUES:
|
||||
params.setdefault("value", []).append(v)
|
||||
|
||||
resp = requests.get(
|
||||
SOILGRIDS_BASE,
|
||||
params=params,
|
||||
headers={"accept": "application/json"},
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _parse_response_to_fields(data: dict) -> dict:
|
||||
"""
|
||||
استخراج مقادیر mean از response و ساخت dict مناسب برای SoilDepthData.
|
||||
"""
|
||||
fields = {p: None for p in PROPERTIES}
|
||||
layers = data.get("properties", {}).get("layers", [])
|
||||
for layer in layers:
|
||||
name = layer.get("name")
|
||||
if name not in fields:
|
||||
continue
|
||||
depths_list = layer.get("depths", [])
|
||||
if depths_list:
|
||||
values = depths_list[0].get("values", {})
|
||||
mean_val = values.get("mean")
|
||||
if mean_val is not None:
|
||||
fields[name] = float(mean_val)
|
||||
return fields
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def fetch_soil_data_task(self, latitude: float, longitude: float):
|
||||
"""
|
||||
واکشی دادههای خاک برای مختصات دادهشده از SoilGrids و ذخیره در DB.
|
||||
برای هر عمق (0-5cm, 5-15cm, 15-30cm) یک ریکوئست جدا زده میشود.
|
||||
"""
|
||||
lat = Decimal(str(round(float(latitude), 6)))
|
||||
lon = Decimal(str(round(float(longitude), 6)))
|
||||
|
||||
with transaction.atomic():
|
||||
location, created = SoilLocation.objects.select_for_update().get_or_create(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
defaults={"task_id": self.request.id},
|
||||
)
|
||||
if not created:
|
||||
location.task_id = self.request.id
|
||||
location.save(update_fields=["task_id"])
|
||||
|
||||
for i, depth in enumerate(DEPTHS):
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={
|
||||
"current": i + 1,
|
||||
"total": len(DEPTHS),
|
||||
"message": f"در حال واکشی عمق {depth}...",
|
||||
},
|
||||
)
|
||||
try:
|
||||
data = _fetch_soilgrids(float(lon), float(lat), depth)
|
||||
except requests.RequestException as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"location_id": location.id,
|
||||
"depth": depth,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
fields = _parse_response_to_fields(data)
|
||||
with transaction.atomic():
|
||||
SoilDepthData.objects.update_or_create(
|
||||
soil_location=location,
|
||||
depth_label=depth,
|
||||
defaults=fields,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
location.task_id = ""
|
||||
location.save(update_fields=["task_id"])
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"location_id": location.id,
|
||||
"depths": DEPTHS,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import SoilDataTaskStatusView, SoilDataView
|
||||
|
||||
urlpatterns = [
|
||||
path("", SoilDataView.as_view(), name="soil-data"),
|
||||
path("tasks/<str:task_id>/status/", SoilDataTaskStatusView.as_view(), name="soil-data-task-status"),
|
||||
]
|
||||
@@ -0,0 +1,196 @@
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import SoilLocation
|
||||
from .serializers import (
|
||||
SoilDataRequestSerializer,
|
||||
SoilDataTaskResponseSerializer,
|
||||
SoilLocationResponseSerializer,
|
||||
)
|
||||
from .tasks import fetch_soil_data_task
|
||||
|
||||
|
||||
class SoilDataView(APIView):
|
||||
"""
|
||||
API خاک: مختصات جغرافیایی را میگیرد.
|
||||
اگر داده در DB موجود باشد، برگردانده میشود؛ در غیر این صورت
|
||||
تسک Celery صف میشود و task_id برمیگردد.
|
||||
"""
|
||||
|
||||
def _get_request_data(self, request):
|
||||
return request.data if request.method == "POST" else request.query_params
|
||||
|
||||
@extend_schema(
|
||||
tags=["Soil Data"],
|
||||
summary="دریافت داده خاک (GET)",
|
||||
description="با ارسال lat و lon، داده خاک از DB یا از طریق تسک Celery برگردانده میشود.",
|
||||
parameters=[
|
||||
{
|
||||
"name": "lat",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "number"},
|
||||
"description": "عرض جغرافیایی",
|
||||
},
|
||||
{
|
||||
"name": "lon",
|
||||
"in": "query",
|
||||
"required": True,
|
||||
"schema": {"type": "number"},
|
||||
"description": "طول جغرافیایی",
|
||||
},
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(description="داده خاک از دیتابیس"),
|
||||
202: OpenApiResponse(description="تسک در صف قرار گرفت"),
|
||||
400: OpenApiResponse(description="داده نامعتبر"),
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
return self._process(request)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Soil Data"],
|
||||
summary="دریافت داده خاک (POST)",
|
||||
description="با ارسال lat و lon در بدنه، داده خاک از DB یا از طریق تسک Celery برگردانده میشود.",
|
||||
request=SoilDataRequestSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(description="داده خاک از دیتابیس"),
|
||||
202: OpenApiResponse(description="تسک در صف قرار گرفت"),
|
||||
400: OpenApiResponse(description="داده نامعتبر"),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={"lat": 35.6892, "lon": 51.3890},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
return self._process(request)
|
||||
|
||||
def _process(self, request):
|
||||
data = self._get_request_data(request)
|
||||
serializer = SoilDataRequestSerializer(data=data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
lat = serializer.validated_data["lat"]
|
||||
lon = serializer.validated_data["lon"]
|
||||
lat_rounded = round(lat, 6)
|
||||
lon_rounded = round(lon, 6)
|
||||
|
||||
location = (
|
||||
SoilLocation.objects.filter(
|
||||
latitude=lat_rounded,
|
||||
longitude=lon_rounded,
|
||||
)
|
||||
.prefetch_related("depths")
|
||||
.first()
|
||||
)
|
||||
|
||||
if location and location.is_complete:
|
||||
data_serializer = SoilLocationResponseSerializer(location)
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"source": "database",
|
||||
**data_serializer.data,
|
||||
},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
result = fetch_soil_data_task.delay(float(lat_rounded), float(lon_rounded))
|
||||
task_data = SoilDataTaskResponseSerializer(
|
||||
{
|
||||
"task_id": result.id,
|
||||
"longitude": float(lon_rounded),
|
||||
"latitude": float(lat_rounded),
|
||||
"status_url": f"/api/soil-data/tasks/{result.id}/status/",
|
||||
}
|
||||
).data
|
||||
return Response(
|
||||
{
|
||||
"code": 202,
|
||||
"msg": "تسک در صف. وضعیت را با task_id بررسی کنید.",
|
||||
"data": task_data,
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
class SoilDataTaskStatusView(APIView):
|
||||
"""وضعیت تسک واکشی خاک. در صورت SUCCESS لیست اطلاعات هر سه عمق برگردانده میشود."""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Soil Data"],
|
||||
summary="وضعیت تسک داده خاک",
|
||||
description="وضعیت تسک Celery واکشی داده خاک را برمیگرداند.",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="SoilTaskStatusResponse",
|
||||
fields={
|
||||
"code": drf_serializers.IntegerField(),
|
||||
"msg": drf_serializers.CharField(),
|
||||
"data": inline_serializer(
|
||||
name="SoilTaskStatusData",
|
||||
fields={
|
||||
"task_id": drf_serializers.CharField(),
|
||||
"status": drf_serializers.CharField(),
|
||||
"message": drf_serializers.CharField(required=False),
|
||||
"progress": drf_serializers.DictField(required=False),
|
||||
"result": drf_serializers.JSONField(required=False),
|
||||
"error": drf_serializers.CharField(required=False),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, task_id):
|
||||
from celery.result import AsyncResult
|
||||
|
||||
result = AsyncResult(task_id)
|
||||
state = result.state
|
||||
data = {"task_id": task_id, "status": state}
|
||||
|
||||
if state == "PENDING":
|
||||
data["message"] = "تسک در صف یا یافت نشد."
|
||||
elif state == "PROGRESS":
|
||||
data["progress"] = result.info
|
||||
elif state == "SUCCESS":
|
||||
task_result = result.result
|
||||
if isinstance(task_result, dict) and task_result.get("status") == "completed":
|
||||
location_id = task_result.get("location_id")
|
||||
location = (
|
||||
SoilLocation.objects.filter(pk=location_id)
|
||||
.prefetch_related("depths")
|
||||
.first()
|
||||
)
|
||||
if location and location.is_complete:
|
||||
data["result"] = SoilLocationResponseSerializer(location).data
|
||||
else:
|
||||
data["result"] = task_result
|
||||
else:
|
||||
data["result"] = task_result
|
||||
elif state == "FAILURE":
|
||||
data["error"] = str(result.result)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
Reference in New Issue
Block a user