This commit is contained in:
2026-05-10 22:49:07 +03:30
parent 2d1f7da89e
commit 2a6321a263
15 changed files with 2667 additions and 162 deletions
+203 -16
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from typing import Any
from django.apps import apps
from django.core.paginator import EmptyPage, Paginator
@@ -49,6 +50,21 @@ from .serializers import (
from .tasks import run_remote_sensing_analysis_task
MAX_REMOTE_SENSING_PAGE_SIZE = 200
REMOTE_SENSING_RUN_STAGE_ORDER = (
"queued",
"running",
"preparing_analysis_grid",
"analysis_grid_ready",
"analysis_cells_selected",
"using_cached_observations",
"fetching_remote_metrics",
"remote_metrics_fetched",
"observations_persisted",
"clustering_completed",
"completed",
"failed",
"retrying",
)
SoilLocationPayloadSerializer = inline_serializer(
name="SoilLocationPayloadSerializer",
@@ -636,13 +652,19 @@ class RemoteSensingRunStatusView(APIView):
def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int, page_size: int) -> dict:
run_data = RemoteSensingRunSerializer(run).data
task_id = (run.metadata or {}).get("task_id")
task_data = _build_remote_sensing_task_payload(run)
effective_status = _apply_live_retry_state_override(run_data, task_data)
status_payload = {
"status": effective_status or run_data["status_label"],
"source": "database",
"run": run_data,
"task_id": task_id,
"task": task_data,
}
if run.status in {RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING}:
return {
"status": run_data["status_label"],
"source": "database",
"run": run_data,
"task_id": task_id,
}
return status_payload
if run.status == RemoteSensingRun.STATUS_FAILURE:
return status_payload
location = _get_location_by_lat_lon(run.soil_location.latitude, run.soil_location.longitude, prefetch=True)
observations = _get_remote_sensing_observations(
@@ -654,10 +676,7 @@ def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int
subdivision_result = getattr(run, "subdivision_result", None)
response_payload = {
"status": run_data["status_label"],
"source": "database",
"run": run_data,
"task_id": task_id,
**status_payload,
"location": SoilLocationResponseSerializer(location).data,
"block_code": run.block_code,
"chunk_size_sqm": run.chunk_size_sqm,
@@ -705,6 +724,180 @@ def _build_remote_sensing_run_status_payload(run: RemoteSensingRun, *, page: int
return response_payload
def _get_remote_sensing_async_result(task_id: str):
try:
from celery.result import AsyncResult
except ImportError: # pragma: no cover - fallback when Celery is absent
return None
try:
return AsyncResult(task_id)
except Exception: # pragma: no cover - depends on Celery backend configuration
return None
def _serialize_task_value(value):
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _serialize_task_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_serialize_task_value(item) for item in value]
return str(value)
def _build_remote_sensing_task_payload(run: RemoteSensingRun) -> dict:
metadata = dict(run.metadata or {})
timestamps = dict(metadata.get("timestamps") or {})
stage_details = dict(metadata.get("stage_details") or {})
current_stage = metadata.get("stage")
failed_stage = metadata.get("failed_stage")
task_payload = {
"current_stage": current_stage,
"current_stage_details": stage_details.get(current_stage, {}),
"timestamps": timestamps,
"stages": _build_remote_sensing_stage_entries(
current_stage=current_stage,
stage_details=stage_details,
timestamps=timestamps,
run_status=run.status,
),
}
if failed_stage:
task_payload["failed_stage"] = failed_stage
metric_progress = (stage_details.get("fetching_remote_metrics") or {}).get("metric_progress")
if metric_progress:
task_payload["metric_progress"] = metric_progress
retry_context = stage_details.get("retrying")
if retry_context:
task_payload["retry"] = retry_context
task_payload["last_error"] = retry_context.get("last_error")
failure_reason = None
if metadata.get("stage") == "failed" or run.status == RemoteSensingRun.STATUS_FAILURE:
failure_reason = metadata.get("failure_reason") or run.error_message
if failure_reason:
task_payload["failure_reason"] = failure_reason
task_id = metadata.get("task_id")
celery_payload = _build_remote_sensing_celery_payload(str(task_id)) if task_id else None
if celery_payload is not None:
task_payload["celery"] = celery_payload
return task_payload
def _apply_live_retry_state_override(run_data: dict[str, Any], task_data: dict[str, Any]) -> str | None:
celery_payload = task_data.get("celery") or {}
if celery_payload.get("state") != "RETRY":
return None
retry_context = dict(task_data.get("retry") or {})
if not retry_context:
retry_context = {
"retry_count": None,
"retry_delay_seconds": None,
"last_error": task_data.get("failure_reason") or celery_payload.get("info"),
"failed_stage": task_data.get("failed_stage"),
"failed_stage_details": (
task_data.get("current_stage_details", {})
if task_data.get("current_stage") == "failed"
else {}
),
}
task_data["retry"] = retry_context
task_data["last_error"] = retry_context.get("last_error") or celery_payload.get("info")
task_data["current_stage"] = "retrying"
task_data["current_stage_details"] = retry_context
task_data.pop("failure_reason", None)
_upsert_retrying_stage_entry(task_data, retry_context)
run_data["status_label"] = "retrying"
run_data["pipeline_status"] = "retrying"
run_data["stage"] = "retrying"
return "retrying"
def _upsert_retrying_stage_entry(task_data: dict[str, Any], retry_context: dict[str, Any]) -> None:
stages = list(task_data.get("stages") or [])
retrying_entry = {
"name": "retrying",
"status": "running",
"entered_at": (task_data.get("timestamps") or {}).get("retrying_at"),
"details": retry_context,
}
for index, entry in enumerate(stages):
if entry.get("name") == "retrying":
stages[index] = retrying_entry
task_data["stages"] = stages
return
stages.append(retrying_entry)
task_data["stages"] = stages
def _build_remote_sensing_stage_entries(
*,
current_stage: str | None,
stage_details: dict,
timestamps: dict,
run_status: str,
) -> list[dict]:
stage_names = []
for stage_name in REMOTE_SENSING_RUN_STAGE_ORDER:
if stage_name == current_stage or stage_name in stage_details or f"{stage_name}_at" in timestamps:
stage_names.append(stage_name)
if current_stage and current_stage not in stage_names:
stage_names.append(current_stage)
entries = []
for stage_name in stage_names:
if run_status == RemoteSensingRun.STATUS_FAILURE and stage_name == current_stage:
stage_status = "failed"
elif stage_name == current_stage and run_status == RemoteSensingRun.STATUS_PENDING:
stage_status = "pending"
elif stage_name == current_stage and run_status == RemoteSensingRun.STATUS_RUNNING:
stage_status = "running"
else:
stage_status = "completed"
entries.append(
{
"name": stage_name,
"status": stage_status,
"entered_at": timestamps.get(f"{stage_name}_at"),
"details": stage_details.get(stage_name, {}),
}
)
return entries
def _build_remote_sensing_celery_payload(task_id: str) -> dict | None:
async_result = _get_remote_sensing_async_result(task_id)
if async_result is None:
return None
try:
payload = {
"state": str(async_result.state),
"ready": bool(async_result.ready()),
"successful": bool(async_result.successful()) if async_result.ready() else False,
"failed": bool(async_result.failed()) if async_result.ready() else False,
}
except Exception: # pragma: no cover - depends on Celery backend configuration
return None
info = getattr(async_result, "info", None)
if info not in (None, {}):
payload["info"] = _serialize_task_value(info)
if async_result.failed():
payload["error"] = _serialize_task_value(async_result.result)
return payload
def _get_location_by_lat_lon(lat, lon, *, prefetch: bool = False):
lat_rounded = round(lat, 6)
lon_rounded = round(lon, 6)
@@ -894,8 +1087,6 @@ def _build_remote_sensing_summary(observations):
ndwi_mean=Avg("ndwi"),
lst_c_mean=Avg("lst_c"),
soil_vv_db_mean=Avg("soil_vv_db"),
dem_m_mean=Avg("dem_m"),
slope_deg_mean=Avg("slope_deg"),
)
summary = {
"cell_count": observations.count(),
@@ -903,8 +1094,6 @@ def _build_remote_sensing_summary(observations):
"ndwi_mean": _round_or_none(aggregates.get("ndwi_mean")),
"lst_c_mean": _round_or_none(aggregates.get("lst_c_mean")),
"soil_vv_db_mean": _round_or_none(aggregates.get("soil_vv_db_mean")),
"dem_m_mean": _round_or_none(aggregates.get("dem_m_mean")),
"slope_deg_mean": _round_or_none(aggregates.get("slope_deg_mean")),
}
return summary
@@ -916,8 +1105,6 @@ def _empty_remote_sensing_summary():
"ndwi_mean": None,
"lst_c_mean": None,
"soil_vv_db_mean": None,
"dem_m_mean": None,
"slope_deg_mean": None,
}