Add Redis service and Celery configuration to Docker setup
- Introduced Redis service in both docker-compose files for production and development. - Updated web and celery services to use Redis as the broker and result backend. - Added necessary environment variables for Celery in settings.py. - Included new tasks and soil_data apps in Django settings and updated URL routing. - Updated requirements.txt to include Celery and Redis dependencies.
This commit is contained in:
@@ -19,4 +19,5 @@ COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
app = Celery("config")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
+8
-7
@@ -18,12 +18,10 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"auth.apps.AuthConfig",
|
||||
"account",
|
||||
"sensor_hub",
|
||||
"dashboard",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"tasks",
|
||||
"soil_data",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -98,9 +96,12 @@ REST_FRAMEWORK = {
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.AllowAny",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
],
|
||||
}
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = DEBUG
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
|
||||
+2
-5
@@ -3,9 +3,6 @@ from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/auth/", include("auth.urls")),
|
||||
path("api/account/", include("account.urls")),
|
||||
path("api/sensor-hub/", include("sensor_hub.urls")),
|
||||
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
|
||||
path("api/farm-dashboard/", include("dashboard.urls")),
|
||||
path("api/tasks/", include("tasks.urls")),
|
||||
path("api/soil-data/", include("soil_data.urls")),
|
||||
]
|
||||
|
||||
@@ -33,6 +33,11 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ai-redis
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build: .
|
||||
container_name: ai-web
|
||||
@@ -40,12 +45,33 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8020:8000"
|
||||
|
||||
celery:
|
||||
build: .
|
||||
container_name: ai-celery
|
||||
command: celery -A config worker -l info
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ai_mysql_data:
|
||||
|
||||
+30
-1
@@ -31,10 +31,16 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ai-redis
|
||||
ports:
|
||||
- "6380:6379" # host:container — سرویسها داخل شبکه از redis:6379 استفاده میکنند
|
||||
|
||||
web:
|
||||
build: .
|
||||
container_name: ai-web
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
@@ -43,9 +49,32 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
build: .
|
||||
container_name: ai-celery
|
||||
command: celery -A config worker -l info
|
||||
volumes:
|
||||
- .:/app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DB_HOST: db
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
SKIP_MIGRATE: "1"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
ai_mysql_data:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "${SKIP_MIGRATE}" != "1" ]; then
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput --fake-initial
|
||||
echo "Migrations done."
|
||||
fi
|
||||
echo "Starting command: $*"
|
||||
exec "$@"
|
||||
@@ -5,3 +5,6 @@ django-cors-headers>=4.3,<5
|
||||
mysqlclient>=2.2,<3
|
||||
gunicorn>=22,<25
|
||||
python-dotenv>=1.0,<2
|
||||
celery[redis]>=5.4,<6
|
||||
redis>=5.0,<6
|
||||
requests>=2.31,<3
|
||||
|
||||
@@ -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 = "soil_data"
|
||||
verbose_name = "Soil Data (SoilGrids)"
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated manually for soil_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 = [
|
||||
("soil_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="soil_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,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,120 @@
|
||||
from rest_framework import status
|
||||
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
|
||||
|
||||
def get(self, request):
|
||||
return self._process(request)
|
||||
|
||||
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 لیست اطلاعات هر سه عمق برگردانده میشود."""
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "tasks"
|
||||
verbose_name = "Celery Tasks"
|
||||
@@ -0,0 +1,15 @@
|
||||
import time
|
||||
|
||||
from config.celery import app
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def sample_task(self, duration: int = 1):
|
||||
"""تسک نمونه برای تست. duration تعداد ثانیهای که تسک طول میکشه."""
|
||||
for i in range(duration):
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"current": i + 1, "total": duration, "message": "در حال پردازش..."},
|
||||
)
|
||||
time.sleep(1)
|
||||
return {"status": "completed", "duration": duration}
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import TaskStatusView, TaskTriggerView
|
||||
|
||||
urlpatterns = [
|
||||
path("", TaskTriggerView.as_view(), name="task-trigger"),
|
||||
path("<str:task_id>/status/", TaskStatusView.as_view(), name="task-status"),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .celery_tasks import sample_task
|
||||
|
||||
|
||||
class TaskTriggerView(APIView):
|
||||
"""
|
||||
ثبت و اجرای تسک.
|
||||
POST با بدنه اختیاری: {"duration": 3} - مدت زمان تسک به ثانیه.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
duration = request.data.get("duration", 1)
|
||||
try:
|
||||
duration = int(duration)
|
||||
duration = max(1, min(duration, 60))
|
||||
except (TypeError, ValueError):
|
||||
duration = 1
|
||||
result = sample_task.delay(duration)
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": {"task_id": result.id}},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class TaskStatusView(APIView):
|
||||
"""
|
||||
وضعیت تسک بر اساس task_id.
|
||||
GET /api/tasks/<task_id>/status/
|
||||
"""
|
||||
|
||||
def get(self, request, task_id):
|
||||
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":
|
||||
data["result"] = result.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