first commit

This commit is contained in:
2026-03-19 22:54:29 +03:30
parent 1a178f39b7
commit 035bc6f74d
91 changed files with 3821 additions and 130 deletions
View File
+24
View File
@@ -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")
+7
View File
@@ -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}"
)
)
+34
View File
@@ -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 = []
+100
View File
@@ -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})"
+93
View File
@@ -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 دریافت می‌کنید."
}
}
]
}
+76
View File
@@ -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)
+117
View File
@@ -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,
}
+8
View File
@@ -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"),
]
+196
View File
@@ -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,
)