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
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
||||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
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.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"auth.apps.AuthConfig",
|
|
||||||
"account",
|
|
||||||
"sensor_hub",
|
|
||||||
"dashboard",
|
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
|
"tasks",
|
||||||
|
"soil_data",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -98,9 +96,12 @@ REST_FRAMEWORK = {
|
|||||||
"DEFAULT_PERMISSION_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.permissions.AllowAny",
|
"rest_framework.permissions.AllowAny",
|
||||||
],
|
],
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = DEBUG
|
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 = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/auth/", include("auth.urls")),
|
path("api/tasks/", include("tasks.urls")),
|
||||||
path("api/account/", include("account.urls")),
|
path("api/soil-data/", include("soil_data.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")),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: ai-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: ai-web
|
container_name: ai-web
|
||||||
@@ -40,12 +45,33 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8020:8000"
|
- "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:
|
volumes:
|
||||||
ai_mysql_data:
|
ai_mysql_data:
|
||||||
|
|||||||
+30
-1
@@ -31,10 +31,16 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: ai-redis
|
||||||
|
ports:
|
||||||
|
- "6380:6379" # host:container — سرویسها داخل شبکه از redis:6379 استفاده میکنند
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: ai-web
|
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:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
ports:
|
ports:
|
||||||
@@ -43,9 +49,32 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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:
|
volumes:
|
||||||
ai_mysql_data:
|
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
|
mysqlclient>=2.2,<3
|
||||||
gunicorn>=22,<25
|
gunicorn>=22,<25
|
||||||
python-dotenv>=1.0,<2
|
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