From 4e28bacad67e37884f389a9d2277ce2a72688141 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Tue, 5 May 2026 21:01:58 +0330 Subject: [PATCH] UPDATE --- AI_INTEGRATION_FLOW_CONTRACT.md | 26 + AI_ROUTE_CONNECTION_AUDIT.md | 152 +++--- API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md | 130 +++-- access_control/services.py | 93 +++- access_control/tests.py | 24 + celerybeat-schedule | Bin 16384 -> 16384 bytes config/failure_contract.py | 53 +++ config/integration_contract.py | 45 ++ config/observability.py | 129 +++++ crop_zoning/defaults.py | 27 ++ crop_zoning/services.py | 6 +- dashboard/defaults.py | 42 ++ dashboard/mock_data.py | 378 +-------------- dashboard/serializers.py | 2 +- dashboard/services.py | 28 +- dashboard/templates.py | 298 ++++++++++++ dashboard/tests.py | 2 +- dashboard/views.py | 9 +- device_hub/migrations/0001_initial.py | 50 +- .../migrations/0004_absorb_sensor_catalog.py | 31 +- ...evicecatalog_and_add_communication_type.py | 47 +- .../0008_farmdevice_device_catalogs.py | 40 +- .../0009_sync_devicecatalog_schema.py | 47 ++ device_hub/services.py | 393 ++++++++++----- device_hub/templates.py | 23 + device_hub/tests.py | 22 + device_hub/views.py | 50 +- docs/dashboard_card_service_map.md | 232 +++------ economic_overview/defaults.py | 8 + economic_overview/services.py | 7 +- economic_overview/tests.py | 16 + economic_overview/views.py | 106 +++-- external_api_adapter/json/ai/index.json | 447 ++++++++++++++---- farm_ai_assistant/defaults.py | 9 + farm_ai_assistant/views.py | 4 +- farm_alerts/defaults.py | 29 ++ farm_alerts/services.py | 41 +- farm_alerts/tests.py | 3 + farm_alerts/views.py | 21 +- fertilization/defaults.py | 35 ++ fertilization/services.py | 11 +- fertilization/views.py | 4 +- irrigation/defaults.py | 28 ++ irrigation/services.py | 105 +++- irrigation/tests.py | 84 ++++ irrigation/views.py | 127 ++++- plants/tests.py | 74 ++- plants/views.py | 72 ++- water/defaults.py | 36 ++ water/services.py | 33 +- yield_harvest/defaults.py | 31 ++ yield_harvest/services.py | 15 +- yield_harvest/tests.py | 58 +++ yield_harvest/views.py | 61 ++- 54 files changed, 2729 insertions(+), 1115 deletions(-) create mode 100644 AI_INTEGRATION_FLOW_CONTRACT.md create mode 100644 config/failure_contract.py create mode 100644 config/integration_contract.py create mode 100644 config/observability.py create mode 100644 crop_zoning/defaults.py create mode 100644 dashboard/defaults.py create mode 100644 dashboard/templates.py create mode 100644 device_hub/migrations/0009_sync_devicecatalog_schema.py create mode 100644 device_hub/templates.py create mode 100644 economic_overview/defaults.py create mode 100644 farm_ai_assistant/defaults.py create mode 100644 farm_alerts/defaults.py create mode 100644 fertilization/defaults.py create mode 100644 irrigation/defaults.py create mode 100644 water/defaults.py create mode 100644 yield_harvest/defaults.py diff --git a/AI_INTEGRATION_FLOW_CONTRACT.md b/AI_INTEGRATION_FLOW_CONTRACT.md new file mode 100644 index 0000000..da26b65 --- /dev/null +++ b/AI_INTEGRATION_FLOW_CONTRACT.md @@ -0,0 +1,26 @@ +# Backend ↔ AI Integration Flow Contract + +## Ownership +- `Backend/plants` owns the canonical plant catalog stored in Backend DB. +- `Ai/farm_data` stores plant catalog snapshots and derived farm read-model data for AI workflows. +- `Backend/farm_alerts` returns persisted tracker snapshots; it does not expose live AI inference on the tracker endpoint. +- `Ai/crop_simulation` owns simulation-derived outputs and live inference tasks. + +## Flow Types +- `direct_proxy`: Backend forwards request/response to AI without changing ownership. +- `backend_owned_data_with_ai_enrichment`: Backend owns the base record and augments it with AI output or AI sync. +- `cached_snapshot`: Response is served from persisted snapshot state. +- `live_ai_inference`: Response or task is generated from live AI execution. +- `ai_owned_derived_output`: AI returns computed or derived outputs from its own services/read-models. + +## Response Metadata +Touched endpoints now expose a top-level `meta` object with: +- `flow_type` +- `source_type` +- `source_service` +- `ownership` +- `live` +- `cached` +- optional `generated_at` +- optional `snapshot_at` +- optional sync fields for Backend plant endpoints diff --git a/AI_ROUTE_CONNECTION_AUDIT.md b/AI_ROUTE_CONNECTION_AUDIT.md index eb79070..3913dec 100644 --- a/AI_ROUTE_CONNECTION_AUDIT.md +++ b/AI_ROUTE_CONNECTION_AUDIT.md @@ -1,78 +1,100 @@ -# بررسی اتصال routeهای درخواستی به سرویس AI +# Backend ↔ AI Route Connection Audit -این گزارش فقط بر اساس کد backend تهیه شده و معیار آن این است: +Last reconciled against current route registrations and view implementations in: -- آیا در کد، `external_api_request(...)` یا `external_request(...)` با **همین route** به سرویس `ai` زده می‌شود یا نه -- اگر با route دیگری به AI متصل شده باشد، route واقعی ذکر شده -- اگر اصلا اتصال AI برای آن route پیدا نشود، به عنوان `متصل نیست` علامت خورده +- `Backend/config/urls.py` +- `Backend/*/urls.py` +- `Backend/*/views.py` +- `Ai/config/urls.py` +- `Ai/*/urls.py` +- `Backend/external_api_adapter/json/ai/index.json` -## متصل به AI با همین route +## Status Vocabulary -| API | اتصال | شواهد | -|---|---|---| -| `POST /api/rag/chat/` | بله | `farm_ai_assistant/views.py:511` | -| `POST /api/soile/moisture-heatmap/` | بله | `soil/views.py:136` | -| `POST /api/soile/health-summary/` | بله | `soil/views.py:182` | -| `POST /api/soile/anomaly-detection/` | بله | `soil/views.py:90` | -| `POST /api/farm-data/` | بله | `farm_hub/services.py:166`, `farm_hub/services.py:89`, `sensor_external_api/services.py:165`, `sensor_external_api/services.py:125` | -| `POST /api/weather/water-need-prediction/` | بله | `water/views.py:136` | -| `POST /api/economy/overview/` | بله | `economic_overview/views.py:73` | -| `GET /api/irrigation/` | بله | `irrigation/views.py:78` | -| `POST /api/irrigation/recommend/` | بله | `irrigation/views.py:165` | -| `POST /api/fertilization/recommend/` | بله | `fertilization/views.py:122` | -| `POST /api/crop-simulation/growth/` | بله | `yield_harvest/views.py:247` | -| `GET /api/crop-simulation/growth//status/` | بله | `yield_harvest/views.py:293` | -| `POST /api/crop-simulation/current-farm-chart/` | بله | `yield_harvest/views.py:145`, `yield_harvest/views.py:162` | -| `POST /api/crop-simulation/harvest-prediction/` | بله | `yield_harvest/views.py:174`, `yield_harvest/views.py:191` | -| `POST /api/crop-simulation/yield-prediction/` | بله | `yield_harvest/views.py:203`, `yield_harvest/views.py:220` | +- `implemented`: route exists and the corresponding backend ↔ AI integration is implemented now +- `partially_implemented`: route exists, but behavior/readiness is limited or alias-based +- `contract_only`: mock/spec exists, but no real client-facing implementation is registered +- `deprecated`: kept for compatibility or aliasing, but not the preferred canonical route +- `missing`: documented previously, but no route/implementation exists now +- `disabled`: intentionally not exposed for current developer/public use +- `transitional`: works now, but still reflects temporary architecture boundaries or compatibility layers -## به AI وصل هستند، اما نه با همین route +## Runtime vs Seed Rule -| API درخواستی | وضعیت | route واقعی AI در کد | شواهد | -|---|---|---|---| -| `POST /api/weather/farm-card/` | با همین route به AI وصل نیست | `GET /weather-forecast/card` | `water/views.py:49` | -| `POST /api/irrigation/water-stress/` | با همین route به AI وصل نیست | `GET /api/water/stress-index/` | `irrigation/views.py:246` | -| `POST /api/pest-disease/detect/` | با همین route به AI وصل نیست | `POST /api/pest-detection/analyze/` | `pest_detection/views.py:161` | -| `POST /api/pest-disease/risk/` | با همین route به AI وصل نیست | `POST /api/pest-detection/risk/` | `pest_detection/views.py:202` | -| `POST /api/pest-disease/risk-summary/` | با همین route به AI وصل نیست | `GET /api/pest-detection/risk-summary/` | `pest_detection/views.py:235` | -| `POST /api/soil-data/ndvi-health/` | با همین route به AI وصل نیست | برای این path اتصال AI پیدا نشد؛ endpoint محلی پروژه با path دیگری ارائه شده | `crop_health/urls.py:6`, `crop_health/tests.py:82` | -| `POST /api/irrigation/` | route به AI با همین method پیدا نشد | فقط `GET /api/irrigation/` در کد استفاده می‌شود | `irrigation/views.py:78` | +- seed/bootstrap data stays allowed for local/dev/test/bootstrap flows +- runtime application code must not silently return mock/sample/demo data +- if real data is missing, the contract must surface an explicit empty state or structured failure -## متصل نیستند +## Ownership Boundaries -برای این routeها هیچ اتصال معناداری به سرویس AI با همین path در کد پیدا نشد. +- Backend owns canonical plant catalog records exposed in `Backend/plants` +- AI `farm_data` owns the derived farm read-model and canonical AI-side farm ↔ plant assignment path +- Backend farm-alert tracker route is cached snapshot delivery, not live AI on request +- AI crop-simulation routes own live or derived simulation outputs -| API | وضعیت | توضیح | -|---|---|---| -| `POST /api/farm-alerts/tracker/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند | -| `POST /api/farm-alerts/timeline/` | متصل نیست | view محلی mock دارد و اصلا به AI call نمی‌زند | -| `GET /api/soil-data/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `POST /api/soil-data/` | عملا با همین route متصل نیست | در `crop_zoning/services.py` call به `/soil-data` بدون پیشوند `/api` وجود دارد | -| `GET /api/soil-data/tasks//status/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `GET /api/farm-data//detail/` | متصل نیست | هیچ call یا route معناداری پیدا نشد | -| `POST /api/farm-data/parameters/` | متصل نیست | هیچ call یا route معناداری پیدا نشد | -| `GET /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `POST /api/plants/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `GET /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `PUT /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `PATCH /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `DELETE /api/plants//` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `POST /api/plants/fetch-info/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد | -| `GET /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | -| `PUT /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | -| `PATCH /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | -| `DELETE /api/irrigation//` | متصل نیست | route detail/call به AI پیدا نشد | +## Source-Of-Truth Matrix -## نکات مهم +| Backend/API contract | Actual route or AI path | Status | Notes | +|---|---|---:|---| +| `POST /api/rag/chat/` | AI only: `Ai/rag/urls.py` | `implemented` | Real AI route; not a backend client route | +| `POST /api/farm-alerts/tracker/` | `Backend/farm_alerts/views.py` → cached snapshot response | `transitional` | Backend route is production-valid, but semantics are `cached_snapshot`, not live AI inference | +| `POST /api/farm-alerts/timeline/` | no backend route | `missing` | Previously documented incorrectly | +| `GET /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `POST /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `GET /api/soil-data/tasks/{task_id}/status/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `POST /api/soil-data/ndvi-health/` | real backend route is `POST /api/crop-health/ndvi-health/` | `deprecated` | Old path should not be presented as current | +| `POST /api/soile/moisture-heatmap/` | AI route; backend canonical alias is `POST /api/soil/moisture-heatmap/` | `implemented` | `soile/*` is AI-facing, `soil/*` is backend-facing | +| `POST /api/soile/health-summary/` | AI route; backend canonical alias is `POST /api/soil/summary/` | `implemented` | Same as above | +| `POST /api/soile/anomaly-detection/` | AI route; backend canonical alias is `POST /api/soil/anomalies/` | `implemented` | Same as above | +| `POST /api/farm-data/` | AI route exists; backend uses it for sync | `implemented` | Internal AI contract; not a backend public endpoint | +| `GET /api/farm-data/{farm_uuid}/detail/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract | +| `POST /api/farm-data/parameters/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract | +| `POST /api/weather/farm-card/` | backend route exists; AI canonical route also exists | `implemented` | Backend proxies to weather functionality | +| `POST /api/weather/water-need-prediction/` | AI route exists; backend public contract differs | `partially_implemented` | AI path is real; backend public path is different | +| `POST /api/economy/overview/` | backend + AI route exist | `implemented` | End-to-end connected | +| `GET /api/plants/` | AI route exists as `Ai/plant/urls.py` and backend route exists as `GET /api/plants/` | `implemented` | Different services, both real | +| `POST /api/plants/` | AI + backend real | `implemented` | Different services, both real | +| `GET /api/plants/{pk}/` | AI + backend real | `implemented` | Backend is canonical catalog; AI is its own service/snapshot consumer | +| `PUT /api/plants/{pk}/` | AI route real; backend route not exposed with PUT | `partially_implemented` | Real on AI, not mirrored on backend public app | +| `PATCH /api/plants/{pk}/` | AI route real; backend route not exposed with PATCH | `partially_implemented` | Same limitation | +| `DELETE /api/plants/{pk}/` | AI route real; backend route not exposed with DELETE | `partially_implemented` | Same limitation | +| `POST /api/plants/fetch-info/` | AI route real | `implemented` | AI route exists; backend public equivalent is absent | +| `POST /api/pest-disease/detect/` | backend alias + AI route real | `implemented` | Canonical current path | +| `POST /api/pest-disease/risk/` | backend alias + AI route real | `implemented` | Canonical current path | +| `POST /api/pest-disease/risk-summary/` | backend alias route exists | `implemented` | Implemented in backend alias layer | +| `GET /api/irrigation/` | backend + AI real | `implemented` | Canonical list route | +| `POST /api/irrigation/` | AI route real; backend route currently list/create mismatch | `partially_implemented` | Backend public create contract is not yet cleanly reconciled | +| `GET /api/irrigation/{pk}/` | AI route real; backend route missing | `partially_implemented` | Real in AI only | +| `PUT /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Present in mock/spec and AI service, not a backend public route | +| `PATCH /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same | +| `DELETE /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same | +| `POST /api/irrigation/recommend/` | backend + AI real | `implemented` | Canonical route | +| `GET /api/irrigation/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current backend or AI route registration found | +| `POST /api/fertilization/recommend/` | backend + AI real | `implemented` | Canonical route | +| `GET /api/fertilization/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current route registration found | +| `POST /api/crop-simulation/growth/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/` | `deprecated` | Real AI route, but backend public source-of-truth remains under `yield-harvest/*` | +| `GET /api/crop-simulation/growth/{task_id}/status/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/{task_id}/status/` | `deprecated` | Same | +| `POST /api/crop-simulation/current-farm-chart/` | AI route real; backend canonical client route is `/api/yield-harvest/current-farm-chart/` | `deprecated` | Same | +| `POST /api/crop-simulation/harvest-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/harvest-prediction/` | `deprecated` | Same | +| `POST /api/crop-simulation/yield-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/yield-prediction/` | `deprecated` | Same | -- `crop-simulation` ها هنوز در `yield_harvest/views.py` به AI وصل هستند، ولی route عمومی backend آن‌ها حذف شده است. -- `farm-alerts/tracker` و `farm-alerts/timeline` در backend وجود دارند، اما داده‌شان mock است و به AI وصل نیستند. -- `weather/farm-card` برای AI از route دیگری استفاده می‌کند: `/weather-forecast/card`. -- `irrigation/water-stress` هم به جای route درخواستی، به `/api/water/stress-index/` روی AI وصل شده است. -- `soil-data` وضعیت یکدستی ندارد: specهای mock برای `/api/soil-data/...` موجود است، ولی call واقعی کد به `/soil-data` بدون پیشوند `/api` دیده می‌شود. +## Response Semantics -## جمع‌بندی +- `farm-alerts/tracker` backend route → `cached snapshot` +- `irrigation/*` backend routes → mostly `proxy` or `backend-owned data with AI enrichment` +- `yield-harvest/*` backend routes → `proxy` to AI plus persisted backend logs for some summaries +- `farm-data/*` AI routes → `AI-owned derived read/write model` -- متصل به AI با همین route: `15` مورد -- متصل به AI ولی با route متفاوت: `7` مورد -- متصل نیست: `18` مورد +## Reconciliation Notes + +- `pest-disease/*` is now the real backend alias and AI contract. Older references to `pest-detection/analyze` as the “real” path are stale. +- `farm-alerts/timeline` is not a registered backend route and must not be documented as implemented. +- `soil-data/*`, `farm-data/*`, and several `plants/*` routes are real on the AI service, but not backend public routes; docs must distinguish internal AI contracts from backend client APIs. +- `crop-simulation/*` remains real on AI, while backend public endpoints are exposed under `yield-harvest/*`. +- task status endpoints for fertilization and irrigation recommendation remain mock/spec-only in `Backend/external_api_adapter/json/ai/index.json`. +- schema UI endpoints are intentionally disabled in AI; developers should rely on version-controlled audit docs until schema publishing is intentionally re-enabled. + +## Known Gaps / Follow-up + +- Some backend docs still use historical “AI route” wording where “internal AI contract” would be more precise. +- Some dashboard-era docs still need cleanup where old mock fallback language remains. diff --git a/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md b/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md index 3966cc2..97c0f97 100644 --- a/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md +++ b/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md @@ -1,84 +1,68 @@ -# گزارش وضعیت استفاده APIهای درخواستی +# Requested Endpoint Usage Audit -این گزارش فقط بر اساس کد همین repository تهیه شده است و برای هر API سه چیز بررسی شده: +This file is the backend-facing API status matrix reconciled against current code. -- آیا به عنوان route واقعی در Django backend اکسپوز شده است یا نه -- آیا در کد، تست‌ها، داکیومنت‌های پروژه یا adapterها به آن ارجاع داده شده است یا نه -- اگر path/method اشتباه باشد، نزدیک‌ترین endpoint واقعی پروژه چیست +Status vocabulary: -## 1) استفاده‌شده و فعال در backend +- `implemented` +- `partially_implemented` +- `stub/contract-only` +- `deprecated` +- `missing` -این APIها هم در routeهای backend وجود دارند و هم در کد/تست/داک پروژه استفاده شده‌اند. +## Endpoint Matrix -| API | وضعیت | شواهد | -|---|---|---| -| `POST /api/weather/farm-card/` | فعال و استفاده‌شده | `water/weather_urls.py`, `water/views.py`, `water/tests.py` | -| `POST /api/economy/overview/` | فعال و استفاده‌شده | `economic_overview/urls.py`, `economic_overview/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | -| `GET /api/irrigation/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` | -| `POST /api/irrigation/recommend/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/views.py`, `irrigation/tests.py` | -| `POST /api/irrigation/water-stress/` | فعال و استفاده‌شده | `irrigation/urls.py`, `irrigation/tests.py` | -| `POST /api/fertilization/recommend/` | فعال و استفاده‌شده | `fertilization/urls.py`, `fertilization/views.py`, `API_DATA_SOURCE_AUDIT_FA.md` | +| Endpoint | Backend route | AI route | Status | Notes | +|---|---|---|---:|---| +| `POST /api/weather/farm-card/` | yes | yes | `implemented` | Current backend public weather card route. | +| `POST /api/economy/overview/` | yes | yes | `implemented` | End-to-end route is live. | +| `GET /api/irrigation/` | yes | yes | `implemented` | Method list route is live. | +| `POST /api/irrigation/recommend/` | yes | yes | `implemented` | Recommendation route is live. | +| `POST /api/irrigation/water-stress/` | yes | yes | `implemented` | Backend route proxies to AI-backed water stress flow. | +| `POST /api/fertilization/recommend/` | yes | yes | `implemented` | Live route. | +| `POST /api/pest-disease/detect/` | yes | yes | `implemented` | Canonical current public alias. | +| `POST /api/pest-disease/risk/` | yes | yes | `implemented` | Canonical current public alias. | +| `POST /api/pest-disease/risk-summary/` | yes | no separate AI route | `implemented` | Backend route derives risk summary from the same AI risk integration. | +| `POST /api/farm-alerts/tracker/` | yes | yes | `partially_implemented` | Backend serves snapshot-backed tracker response; not a direct request-time AI invocation. | +| `POST /api/farm-alerts/timeline/` | no | no | `missing` | Was documented, but no route exists. | +| `POST /api/soil/summary/` | yes | n/a | `implemented` | Backend public summary route. | +| `POST /api/soil/anomalies/` | yes | via `POST /api/soile/anomaly-detection/` | `implemented` | Backend canonical route. | +| `POST /api/soil/moisture-heatmap/` | yes | via `POST /api/soile/moisture-heatmap/` | `implemented` | Backend canonical route. | +| `POST /api/crop-health/ndvi-health/` | yes | via `POST /api/soil-data/ndvi-health/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/current-farm-chart/` | yes | via `/api/crop-simulation/current-farm-chart/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/harvest-prediction/` | yes | via `/api/crop-simulation/harvest-prediction/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/yield-prediction/` | yes | via `/api/crop-simulation/yield-prediction/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/growth/` | yes | via `/api/crop-simulation/growth/` | `implemented` | Backend canonical route. | +| `GET /api/yield-harvest/growth/{task_id}/status/` | yes | via `/api/crop-simulation/growth/{task_id}/status/` | `implemented` | Backend canonical route. | +| `GET /api/yield-harvest/summary/` | yes | no | `implemented` | Summary route exists. | +| `GET /api/yield-harvest/yield-harvest-summary/` | yes | via AI summary service | `implemented` | Compatibility alias remains live. | -## 2) در پروژه استفاده شده‌اند، اما به عنوان endpoint مستقیم backend اکسپوز نیستند +## Internal AI Contracts Not To Present As Backend Public APIs -این‌ها یا فقط به عنوان path سرویس خارجی AI استفاده می‌شوند، یا route داخلی‌شان با path دیگری در backend ارائه شده است. +| Endpoint | Status | Notes | +|---|---:|---| +| `POST /api/rag/chat/` | `implemented` | AI service route only. | +| `GET|POST /api/soil-data/` | `implemented` | AI service route only. | +| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | AI service route only. | +| `POST /api/soile/*` | `implemented` | AI service routes; backend public aliases are under `soil/*`. | +| `POST /api/farm-data/` | `implemented` | AI service route used for integration and sync. | +| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | AI service route. | +| `POST /api/farm-data/parameters/` | `implemented` | AI service route. | +| `POST /api/weather/water-need-prediction/` | `implemented` | AI service route; backend public contract is under `water/*`. | +| `POST /api/crop-simulation/*` | `implemented` | AI service routes; backend public contract is under `yield-harvest/*`. | -| API | وضعیت | توضیح | شواهد | -|---|---|---|---| -| `POST /api/rag/chat/` | استفاده داخلی | route محلی نیست؛ به عنوان درخواست خروجی به سرویس AI استفاده می‌شود | `farm_ai_assistant/views.py`, `external_api_adapter/json/ai/index.json` | -| `GET /api/soil-data/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` | -| `POST /api/soil-data/` | استفاده داخلی/contract | route محلی ندارد؛ mock/spec دارد و integration نزدیک آن در `crop_zoning` به `/soil-data` صدا زده می‌شود | `external_api_adapter/json/ai/index.json`, `crop_zoning/services.py` | -| `GET /api/soil-data/tasks//status/` | فقط contract/mock | route محلی ندارد؛ فقط در adapter mock/spec آمده | `external_api_adapter/json/ai/index.json` | -| `POST /api/soile/moisture-heatmap/` | استفاده داخلی | backend به جای آن `POST /api/soil/moisture-heatmap/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | -| `POST /api/soile/health-summary/` | استفاده داخلی | backend به جای آن `POST /api/soil/summary/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | -| `POST /api/soile/anomaly-detection/` | استفاده داخلی | backend به جای آن `POST /api/soil/anomalies/` را اکسپوز کرده و این path را به AI صدا می‌زند | `soil/views.py`, `soil/tests.py`, `soil/urls.py` | -| `POST /api/farm-data/` | استفاده داخلی | route محلی ندارد؛ برای sync داده مزرعه به سرویس بیرونی استفاده می‌شود | `farm_hub/services.py`, `sensor_external_api/services.py`, `farm_hub/tests.py` | -| `POST /api/weather/water-need-prediction/` | استفاده داخلی | route محلی ندارد؛ backend endpoint معادل را با `GET /api/water/need-prediction/` ارائه می‌کند و خودش این path را به AI صدا می‌زند | `water/views.py`, `water/urls.py`, `water/tests.py` | -| `POST /api/crop-simulation/growth/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` | -| `GET /api/crop-simulation/growth//status/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز در view/testها ارجاع مانده | `yield_harvest/views.py`, `yield_harvest/tests.py` | -| `POST /api/crop-simulation/current-farm-chart/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` | -| `POST /api/crop-simulation/harvest-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py`, `yield_harvest/tests.py` | -| `POST /api/crop-simulation/yield-prediction/` | استفاده باقیمانده در کد | route آن حذف شده، ولی هنوز به عنوان AI path در کد وجود دارد | `yield_harvest/views.py` | +## Contract-Only / Stale Spec Entries -## 3) در لیست شما آمده‌اند، اما با method/path فعلی استفاده نمی‌شوند +| Endpoint | Status | Notes | +|---|---:|---| +| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. | +| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. | +| `PUT|PATCH|DELETE /api/irrigation/{pk}/` | `stub/contract-only` | Spec exists, but no backend public route is registered. | -این‌ها یا method اشتباه دارند، یا path صحیح پروژه چیز دیگری است، یا اصلا implementation محلی برایشان پیدا نشد. +## Deprecated Path Decisions -| API | وضعیت | توضیح | شواهد | -|---|---|---|---| -| `POST /api/farm-alerts/tracker/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | -| `POST /api/farm-alerts/timeline/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` پیاده‌سازی شده | `farm_alerts/urls.py`, `farm_alerts/views.py`, `FRONTEND_PAGES_APIS_GUIDE.md` | -| `POST /api/soil-data/ndvi-health/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/soil/health/ndvi-health/` است | `crop_health/tests.py`, `crop_health/urls.py` | -| `GET /api/farm-data//detail/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo | -| `POST /api/farm-data/parameters/` | استفاده نمی‌شود | route یا reference معناداری پیدا نشد | جستجو در کل repo | -| `GET /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `POST /api/plants/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `GET /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `PUT /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `PATCH /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `DELETE /api/plants//` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `POST /api/plants/fetch-info/` | استفاده نمی‌شود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` | -| `POST /api/pest-disease/detect/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/analyze/` است | `pest_detection/urls.py`, `pest_detection/views.py` | -| `POST /api/pest-disease/risk/` | استفاده نمی‌شود | endpoint واقعی پروژه `POST /api/pest-detection/risk/` است | `pest_detection/urls.py`, `pest_detection/views.py` | -| `POST /api/pest-disease/risk-summary/` | استفاده نمی‌شود | path و method هر دو متفاوت‌اند؛ endpoint واقعی `GET /api/pest-detection/risk-summary/` است | `pest_detection/urls.py`, `pest_detection/views.py`, `pest_detection/tests.py` | -| `POST /api/irrigation/` | استفاده نمی‌شود | path وجود دارد ولی فقط `GET` list پیاده‌سازی شده | `irrigation/urls.py`, `irrigation/views.py` | -| `GET /api/irrigation//` | استفاده نمی‌شود | route detail پیدا نشد | `irrigation/urls.py` | -| `PUT /api/irrigation//` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation/urls.py` | -| `PATCH /api/irrigation//` | استفاده نمی‌شود | route detail/update پیدا نشد | `irrigation/urls.py` | -| `DELETE /api/irrigation//` | استفاده نمی‌شود | route detail/delete پیدا نشد | `irrigation/urls.py` | - -## 4) جمع‌بندی سریع - -- فعال و قابل استفاده در backend: `6` مورد -- استفاده داخلی یا باقیمانده در کد ولی بدون route مستقیم: `14` مورد -- استفاده‌نشده / path یا method اشتباه / بدون implementation: `20` مورد - -## 5) نکات مهم برای پاک‌سازی - -- `crop-simulation` routeها حذف شده‌اند، ولی referenceهای آن هنوز در `yield_harvest/views.py` و `yield_harvest/tests.py` باقی مانده‌اند. -- `rag/chat` و `farm-data` بیشتر contract داخلی با سرویس AI هستند، نه endpoint قابل استفاده برای کلاینت frontend. -- چند API در لیست شما نام قدیمی یا اشتباه دارند و در backend با path جدیدتری پیاده‌سازی شده‌اند: - - `soil-data/ndvi-health` -> `soil/health/ndvi-health` - - `pest-disease/*` -> `pest-detection/*` - - `weather/water-need-prediction` -> `water/need-prediction` - - `soile/*` -> به صورت داخلی برای AI استفاده می‌شود، ولی route عمومی backend با `soil/*` است +| Old path | Replacement | +|---|---| +| `/api/soil-data/ndvi-health/` | `/api/crop-health/ndvi-health/` | +| `/api/crop-simulation/*` as backend public routes | `/api/yield-harvest/*` | +| `/api/soile/*` as backend public routes | `/api/soil/*` | diff --git a/access_control/services.py b/access_control/services.py index a0b59e4..4e32f4d 100644 --- a/access_control/services.py +++ b/access_control/services.py @@ -1,5 +1,7 @@ import hashlib import json +import logging +import time from functools import lru_cache from pathlib import Path from urllib.parse import urljoin @@ -10,11 +12,15 @@ from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured from django.http import QueryDict from farm_hub.models import FarmHub +from config.observability import classify_exception, log_event, observe_operation, record_metric from .catalog import GOLD_PLAN_CODE from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan +logger = logging.getLogger(__name__) + + class AccessControlError(Exception): pass @@ -268,20 +274,54 @@ def request_opa_batch_authorization(farm, user, features, action, route=None): payload = {"input": build_authorization_input(farm, user, features, action, route=route)} - try: - response = requests.post( - _opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH), - json=payload, - timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT, - ) - response.raise_for_status() - except requests.RequestException as exc: - raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc + with observe_operation(source="backend.access_control", provider="opa", operation="batch_authorization"): + started_at = time.monotonic() + try: + response = requests.post( + _opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH), + json=payload, + timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT, + ) + response.raise_for_status() + except requests.RequestException as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="opa batch authorization request failed", + source="backend.access_control", + provider="opa", + operation="batch_authorization", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code=failure.error_code, + route=route, + feature_count=len(features), + ) + record_metric("access_control.opa.failure", error_code=failure.error_code) + raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc - try: - return response.json().get("result", {}) - except ValueError as exc: - raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc + try: + result = response.json().get("result", {}) + except ValueError as exc: + log_event( + level=logging.ERROR, + message="opa batch authorization returned invalid json", + source="backend.access_control", + provider="opa", + operation="batch_authorization", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code="parse_error", + route=route, + feature_count=len(features), + status_code=response.status_code, + ) + record_metric("access_control.opa.invalid_json") + raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc + if not result: + record_metric("access_control.opa.empty_result") + logger.warning("OPA returned empty authorization result for route=%s", route) + return result def normalize_opa_batch_result(data, features): @@ -319,7 +359,18 @@ def batch_authorize_features(farm, user, features, action, route=None): try: cached_result = cache.get(cache_key) - except Exception: + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.WARNING, + message="authorization cache read failed", + source="backend.access_control", + provider="cache", + operation="batch_authorize_features", + result_status="error", + error_code=failure.error_code, + route=route, + ) cached_result = None if isinstance(cached_result, dict): @@ -330,8 +381,18 @@ def batch_authorize_features(farm, user, features, action, route=None): try: cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout()) - except Exception: - pass + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.WARNING, + message="authorization cache write failed", + source="backend.access_control", + provider="cache", + operation="batch_authorize_features", + result_status="error", + error_code=failure.error_code, + route=route, + ) return decisions diff --git a/access_control/tests.py b/access_control/tests.py index 0c40285..cad76f2 100644 --- a/access_control/tests.py +++ b/access_control/tests.py @@ -4,6 +4,7 @@ from unittest.mock import patch from django.test import RequestFactory, SimpleTestCase, override_settings from account.views import ProfileView +from config.observability import METRICS from .middleware import RouteFeatureAccessMiddleware from .services import batch_authorize_features, build_authorization_input @@ -19,6 +20,9 @@ TEST_CACHES = { @override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300) class AccessControlServiceTests(SimpleTestCase): + def tearDown(self): + METRICS.clear() + def test_batch_authorize_features_uses_cache_for_same_route(self): farm = SimpleNamespace(farm_uuid="farm-uuid") user = SimpleNamespace(id=7) @@ -95,6 +99,26 @@ class AccessControlServiceTests(SimpleTestCase): }, ) + @patch("access_control.services.requests.post") + @override_settings(ACCESS_CONTROL_AUTHZ_ENABLED=True, ACCESS_CONTROL_AUTHZ_BASE_URL="https://opa.example", ACCESS_CONTROL_AUTHZ_BATCH_PATH="/v1/data/authz", ACCESS_CONTROL_AUTHZ_TIMEOUT=1) + def test_request_opa_batch_authorization_records_invalid_json_metric(self, mock_post): + response = mock_post.return_value + response.raise_for_status.return_value = None + response.json.side_effect = ValueError("bad json") + farm = SimpleNamespace(farm_uuid="farm-uuid") + user = SimpleNamespace(id=7, username="u", email="", phone_number="", is_staff=False, is_superuser=False) + + with self.assertRaises(Exception): + batch_authorize_features( + farm=farm, + user=user, + features=["farm_dashboard"], + action="view", + route="/api/farm-dashboard/", + ) + + self.assertEqual(METRICS["access_control.opa.invalid_json"], 1) + class RouteFeatureAccessMiddlewareTests(SimpleTestCase): def test_middleware_passes_route_feature_and_method_to_service(self): diff --git a/celerybeat-schedule b/celerybeat-schedule index 0c42804408f68bde863550b33ef846dfd92b6245..fcb7c4f5cb7b04cb090bcc8e23a4394d01024456 100644 GIT binary patch delta 28 jcmZo@U~Fh$+|Xvs&ML^l$0wUO`J+J~W5nkB#)>=udM*fe delta 28 jcmZo@U~Fh$+|Xvs&LYa9$H%^I@<)R}#(>TDjTLzSevSyq diff --git a/config/failure_contract.py b/config/failure_contract.py new file mode 100644 index 0000000..1ecbc68 --- /dev/null +++ b/config/failure_contract.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FailureContract: + status: str = "error" + error_code: str = "internal_error" + message: str = "" + source: str = "application" + warnings: list[str] = field(default_factory=list) + retriable: bool = False + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + payload = { + "status": self.status, + "error_code": self.error_code, + "message": self.message, + "source": self.source, + "warnings": list(self.warnings), + "retriable": self.retriable, + } + if self.details: + payload["details"] = self.details + return payload + + +class StructuredServiceError(Exception): + def __init__( + self, + *, + error_code: str, + message: str, + source: str, + warnings: list[str] | None = None, + retriable: bool = False, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.contract = FailureContract( + error_code=error_code, + message=message, + source=source, + warnings=warnings or [], + retriable=retriable, + details=details or {}, + ) + + def to_dict(self) -> dict[str, Any]: + return self.contract.to_dict() diff --git a/config/integration_contract.py b/config/integration_contract.py new file mode 100644 index 0000000..0c69cb0 --- /dev/null +++ b/config/integration_contract.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +def _isoformat(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + return value + + +def build_integration_meta( + *, + flow_type: str, + source_type: str, + source_service: str, + ownership: str, + live: bool, + cached: bool, + generated_at: Any = None, + snapshot_at: Any = None, + sync_attempted: bool | None = None, + sync_status: str | None = None, + notes: list[str] | None = None, +) -> dict[str, Any]: + meta = { + "flow_type": flow_type, + "source_type": source_type, + "source_service": source_service, + "ownership": ownership, + "live": live, + "cached": cached, + } + if generated_at is not None: + meta["generated_at"] = _isoformat(generated_at) + if snapshot_at is not None: + meta["snapshot_at"] = _isoformat(snapshot_at) + if sync_attempted is not None: + meta["sync_attempted"] = sync_attempted + if sync_status is not None: + meta["sync_status"] = sync_status + if notes: + meta["notes"] = notes + return meta diff --git a/config/observability.py b/config/observability.py new file mode 100644 index 0000000..1ce819f --- /dev/null +++ b/config/observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import logging +import time +from collections import Counter +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + + +logger = logging.getLogger(__name__) +_request_id_ctx: ContextVar[str | None] = ContextVar("backend_request_id", default=None) +METRICS: Counter[str] = Counter() + + +def set_request_id(request_id: str | None) -> None: + _request_id_ctx.set(request_id) + + +def get_request_id() -> str | None: + return _request_id_ctx.get() + + +def record_metric(name: str, value: int = 1, **tags: Any) -> None: + suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None) + metric_key = f"{name}|{suffix}" if suffix else name + METRICS[metric_key] += value + + +@dataclass +class ClassifiedFailure: + error_code: str + failure_type: str + retriable: bool + + +def classify_exception(exc: Exception) -> ClassifiedFailure: + exc_name = exc.__class__.__name__.lower() + message = str(exc).lower() + if "timeout" in exc_name or "timeout" in message: + return ClassifiedFailure("timeout", "timeout", True) + if "json" in exc_name or "json" in message: + return ClassifiedFailure("parse_error", "parse_error", False) + if "validation" in exc_name or "invalid" in message: + return ClassifiedFailure("validation_failure", "validation_failure", False) + if "connection" in exc_name or "unavailable" in message: + return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True) + return ClassifiedFailure("provider_error", "provider_error", True) + + +def log_event( + *, + level: int, + message: str, + source: str, + provider: str | None, + operation: str, + result_status: str, + duration_ms: float | None = None, + error_code: str | None = None, + **extra: Any, +) -> None: + payload = { + "source": source, + "provider": provider, + "operation": operation, + "result_status": result_status, + "duration_ms": round(duration_ms, 2) if duration_ms is not None else None, + "error_code": error_code, + "request_id": get_request_id(), + } + payload.update({key: value for key, value in extra.items() if value is not None}) + logger.log(level, message, extra={"event": payload}) + + +class observe_operation: + def __init__(self, *, source: str, provider: str | None, operation: str): + self.source = source + self.provider = provider + self.operation = operation + self.started_at = 0.0 + + def __enter__(self): + self.started_at = time.monotonic() + log_event( + level=logging.INFO, + message="backend operation started", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="started", + ) + return self + + def __exit__(self, exc_type, exc, _tb): + duration_ms = (time.monotonic() - self.started_at) * 1000 + if exc is None: + log_event( + level=logging.INFO, + message="backend operation completed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="success", + duration_ms=duration_ms, + ) + record_metric("backend.operation.success", source=self.source, provider=self.provider, operation=self.operation) + return False + + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="backend operation failed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="error", + duration_ms=duration_ms, + error_code=failure.error_code, + failure_type=failure.failure_type, + ) + record_metric( + "backend.operation.failure", + source=self.source, + provider=self.provider, + operation=self.operation, + error_code=failure.error_code, + ) + return False diff --git a/crop_zoning/defaults.py b/crop_zoning/defaults.py new file mode 100644 index 0000000..7b5ee67 --- /dev/null +++ b/crop_zoning/defaults.py @@ -0,0 +1,27 @@ +DEFAULT_AREA_FEATURE = { + "area": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.405, 35.672], + [51.41, 35.695], + [51.385, 35.71], + [51.365, 35.688], + [51.38, 35.68], + ] + ], + }, + } +} + +DEFAULT_PRODUCTS_PAYLOAD = { + "products": [ + {"id": "wheat", "label": "گندم", "color": "#6bcb77"}, + {"id": "canola", "label": "کلزا", "color": "#ffd93d"}, + {"id": "saffron", "label": "زعفران", "color": "#9b59b6"}, + ] +} diff --git a/crop_zoning/services.py b/crop_zoning/services.py index a4b2aa3..161b138 100644 --- a/crop_zoning/services.py +++ b/crop_zoning/services.py @@ -13,7 +13,7 @@ from farm_hub.models import FarmHub from external_api_adapter.adapter import request as external_request -from .mock_data import AREA_RESPONSE_DATA, PRODUCTS_RESPONSE_DATA +from .defaults import DEFAULT_AREA_FEATURE, DEFAULT_PRODUCTS_PAYLOAD from .models import ( CropArea, CropProduct, @@ -27,7 +27,7 @@ from .models import ( ) EARTH_RADIUS_METERS = 6378137.0 -PRODUCT_DEFAULTS = PRODUCTS_RESPONSE_DATA["products"] +PRODUCT_DEFAULTS = DEFAULT_PRODUCTS_PAYLOAD["products"] DEFAULT_CELL_SIDE_KM = 0.15 DEFAULT_ZONE_PAGE_SIZE = 10 RULE_BASED_ALGORITHM = "rule_based_v1" @@ -132,7 +132,7 @@ def get_zone_page_request_params(query_params): def get_default_area_feature(): - return deepcopy(AREA_RESPONSE_DATA["area"]) + return deepcopy(DEFAULT_AREA_FEATURE["area"]) def normalize_area_feature(area_feature): diff --git a/dashboard/defaults.py b/dashboard/defaults.py new file mode 100644 index 0000000..220f2c1 --- /dev/null +++ b/dashboard/defaults.py @@ -0,0 +1,42 @@ +from copy import deepcopy + + +VALID_ROW_IDS = [ + "overviewKpis", + "weatherAlerts", + "sensorMonitoring", + "sensorCharts", + "alertsWater", + "predictions", + "soilHeatmap", + "ndviRecommendations", + "economic", +] + +VALID_CARD_IDS = [ + "farmOverviewKpis", + "farmWeatherCard", + "farmAlertsTracker", + "sensorValuesList", + "sensorRadarChart", + "sensorComparisonChart", + "anomalyDetectionCard", + "farmAlertsTimeline", + "waterNeedPrediction", + "harvestPredictionCard", + "yieldPredictionChart", + "soilMoistureHeatmap", + "ndviHealthCard", + "recommendationsList", + "economicOverview", +] + +DEFAULT_CONFIG = { + "disabled_card_ids": [], + "row_order": VALID_ROW_IDS.copy(), + "enable_drag_reorder": True, +} + + +def get_default_dashboard_config(): + return deepcopy(DEFAULT_CONFIG) diff --git a/dashboard/mock_data.py b/dashboard/mock_data.py index d3cc378..3309255 100644 --- a/dashboard/mock_data.py +++ b/dashboard/mock_data.py @@ -1,363 +1,21 @@ """ -Static mock data for Farm Dashboard API. +Backward-compatible mock exports for dashboard fake content. + +Use `dashboard.defaults` for runtime configuration defaults and +`dashboard.templates` for fallback card payload templates. """ -from copy import deepcopy -from threading import Lock - - -VALID_ROW_IDS = [ - "overviewKpis", - "weatherAlerts", - "sensorMonitoring", - "sensorCharts", - "alertsWater", - "predictions", - "soilHeatmap", - "ndviRecommendations", - "economic", -] - -VALID_CARD_IDS = [ - "farmOverviewKpis", - "farmWeatherCard", - "farmAlertsTracker", - "sensorValuesList", - "sensorRadarChart", - "sensorComparisonChart", - "anomalyDetectionCard", - "farmAlertsTimeline", - "waterNeedPrediction", - "harvestPredictionCard", - "yieldPredictionChart", - "soilMoistureHeatmap", - "ndviHealthCard", - "recommendationsList", - "economicOverview", -] - -DEFAULT_CONFIG = { - "disabled_card_ids": [], - "row_order": VALID_ROW_IDS.copy(), - "enable_drag_reorder": True, -} - -_config_lock = Lock() -_config_state = deepcopy(DEFAULT_CONFIG) - - -def get_config(): - with _config_lock: - return deepcopy(_config_state) - - -def update_config(changes): - with _config_lock: - _config_state.update(deepcopy(changes)) - return deepcopy(_config_state) - - -def reset_config(): - with _config_lock: - _config_state.clear() - _config_state.update(deepcopy(DEFAULT_CONFIG)) - return deepcopy(_config_state) - -# 4.1 farmOverviewKpis -FARM_OVERVIEW_KPIS = { - "kpis": [ - { - "id": "disease_risk", - "title": "ریسک بیماری", - "subtitle": "۷ روز اخیر", - "stats": "پایین", - "avatarColor": "success", - "avatarIcon": "tabler-bug", - "chipText": "5%", - "chipColor": "success", - }, - { - "id": "yield_prediction", - "title": "پیش‌بینی عملکرد", - "subtitle": "این فصل", - "stats": "42 تن", - "avatarColor": "secondary", - "avatarIcon": "tabler-chart-bar", - "chipText": "+8%", - "chipColor": "success", - }, - { - "id": "pest_risk", - "title": "ریسک آفات", - "subtitle": "پیش‌بینی هوشمند", - "stats": "15%", - "avatarColor": "warning", - "avatarIcon": "tabler-bug-off", - "chipText": "تحت نظر", - "chipColor": "warning", - }, - ] -} - -# 4.2 farmWeatherCard -FARM_WEATHER_CARD = { - "condition": "صاف", - "temperature": 24, - "unit": "°C", - "humidity": 45, - "windSpeed": 12, - "windUnit": "km/h", - "chartData": { - "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"], - "series": [[18, 22, 26, 28, 25, 20, 18]], - }, -} - -# 4.3 farmAlertsTracker -FARM_ALERTS_TRACKER = { - "totalAlerts": 3, - "radialBarValue": 30, - "alertStats": [ - { - "title": "کمبود آب", - "count": "2", - "avatarColor": "error", - "avatarIcon": "tabler-droplet-half-2", - }, - { - "title": "ریسک قارچی", - "count": "1", - "avatarColor": "warning", - "avatarIcon": "tabler-mushroom", - }, - { - "title": "هشدار یخبندان", - "count": "0", - "avatarColor": "info", - "avatarIcon": "tabler-snowflake", - }, - ], -} - -# 4.4 sensorValuesList -SENSOR_VALUES_LIST = { - "sensors": [ - { - "title": "28°C", - "subtitle": "دمای هوا", - "trendNumber": 2.1, - "trend": "positive", - "unit": "°C", - }, - { - "title": "24°C", - "subtitle": "دمای خاک", - "trendNumber": -0.5, - "trend": "negative", - "unit": "°C", - }, - { - "title": "65%", - "subtitle": "رطوبت هوا", - "trendNumber": 3.2, - "trend": "positive", - "unit": "%", - }, - { - "title": "42%", - "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)", - "trendNumber": -1.8, - "trend": "negative", - "unit": "%", - }, - { - "title": "6.8", - "subtitle": "pH خاک", - "trendNumber": 0.2, - "trend": "positive", - "unit": "pH", - }, - { - "title": "1.2", - "subtitle": "هدایت الکتریکی (dS/m)", - "trendNumber": 0.1, - "trend": "positive", - "unit": "dS/m", - }, - { - "title": "850", - "subtitle": "شدت نور (لوکس)", - "trendNumber": 15.3, - "trend": "positive", - "unit": "lux", - }, - { - "title": "12", - "subtitle": "سرعت باد (کیلومتر/ساعت)", - "trendNumber": -2.4, - "trend": "negative", - "unit": "km/h", - }, - ] -} - -# 4.8 farmAlertsTimeline -FARM_ALERTS_TIMELINE = { - "alerts": [ - { - "title": "ریسک کمبود آب", - "description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.", - "time": "۱۵ دقیقه پیش", - "color": "warning", - }, - { - "title": "ریسک بیماری قارچی", - "description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.", - "time": "۱ ساعت پیش", - "color": "error", - }, - { - "title": "پیشنهاد آبیاری", - "description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.", - "time": "۲ ساعت پیش", - "color": "info", - }, - { - "title": "بررسی شوری خاک", - "description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.", - "time": "۴ ساعت پیش", - "color": "success", - }, - ] -} - -# 4.9 waterNeedPrediction -WATER_NEED_PREDICTION = { - "totalNext7Days": 3290, - "unit": "m³", - "categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"], - "series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}], -} - -# 4.10 harvestPredictionCard -HARVEST_PREDICTION_CARD = { - "date": "2025-10-15", - "dateFormatted": "۱۵ اکتبر ۲۰۲۵", - "daysUntil": 58, - "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", - "optimalWindowStart": "2025-10-12", - "optimalWindowEnd": "2025-10-18", -} - -# 4.11 yieldPredictionChart -YIELD_PREDICTION_CHART = { - "categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], - "series": [ - {"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]}, - {"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]}, - ], - "summary": [ - { - "title": "عملکرد پیش‌بینی‌شده", - "subtitle": "این فصل", - "amount": "42 تن", - "avatarColor": "primary", - "avatarIcon": "tabler-chart-bar", - }, - { - "title": "تاریخ برداشت", - "subtitle": "حدود ۱۵ اکتبر", - "amount": "+8%", - "avatarColor": "success", - "avatarIcon": "tabler-calendar", - }, - ], -} - -# 4.14 recommendationsList -RECOMMENDATIONS_LIST = { - "recommendations": [ - { - "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح", - "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.", - "avatarIcon": "tabler-droplet", - "avatarColor": "primary", - }, - { - "title": "کود: NPK 20-20-20", - "subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.", - "avatarIcon": "tabler-leaf", - "avatarColor": "success", - }, - { - "title": "قارچ‌کش: پیشگیرانه", - "subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.", - "avatarIcon": "tabler-mushroom", - "avatarColor": "warning", - }, - { - "title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر", - "subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.", - "avatarIcon": "tabler-calendar-event", - "avatarColor": "info", - }, - ] -} - -# 4.15 economicOverview -ECONOMIC_OVERVIEW = { - "economicData": [ - { - "title": "هزینه آب", - "value": "€720", - "subtitle": "این ماه", - "avatarIcon": "tabler-droplet", - "avatarColor": "primary", - }, - { - "title": "صرفه‌جویی آب هوشمند", - "value": "€156", - "subtitle": "۱۸٪ صرفه‌جویی شده", - "avatarIcon": "tabler-bulb", - "avatarColor": "success", - }, - { - "title": "بازده سرمایه پلتفرم", - "value": "127%", - "subtitle": "نسبت به سال گذشته", - "avatarIcon": "tabler-chart-line", - "avatarColor": "info", - }, - { - "title": "پیش‌بینی درآمد", - "value": "€42k", - "subtitle": "این فصل", - "avatarIcon": "tabler-currency-euro", - "avatarColor": "success", - }, - ], - "chartSeries": [ - {"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]}, - {"name": "کود", "data": [80, 85, 90, 75, 82, 78]}, - ], - "chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], -} - -# Unified response for GET /api/farm-dashboard (section 5) -ALL_CARDS = { - "farmOverviewKpis": FARM_OVERVIEW_KPIS , # این باید سه روز یکبار محتواش محاسبه بشه - "farmWeatherCard": FARM_WEATHER_CARD, # هروز - "farmAlertsTracker": FARM_ALERTS_TRACKER, #هروز - "sensorValuesList": SENSOR_VALUES_LIST,#هروز - "sensorRadarChart": {}, - "sensorComparisonChart": {}, - "anomalyDetectionCard": {}, - "farmAlertsTimeline": FARM_ALERTS_TIMELINE, - "waterNeedPrediction": WATER_NEED_PREDICTION, - "harvestPredictionCard": HARVEST_PREDICTION_CARD, - "yieldPredictionChart": YIELD_PREDICTION_CHART, - "soilMoistureHeatmap": {}, - "ndviHealthCard": {}, - "recommendationsList": RECOMMENDATIONS_LIST, # این باید حتما از recommendetion ها گرفته بشه - "economicOverview": ECONOMIC_OVERVIEW, -} +from .defaults import DEFAULT_CONFIG, VALID_CARD_IDS, VALID_ROW_IDS +from .templates import ( + ALL_CARD_TEMPLATES as ALL_CARDS, + ECONOMIC_OVERVIEW, + FARM_ALERTS_TIMELINE, + FARM_ALERTS_TRACKER, + FARM_OVERVIEW_KPIS, + FARM_WEATHER_CARD, + HARVEST_PREDICTION_CARD, + RECOMMENDATIONS_LIST, + SENSOR_VALUES_LIST, + WATER_NEED_PREDICTION, + YIELD_PREDICTION_CHART, +) diff --git a/dashboard/serializers.py b/dashboard/serializers.py index b9821b1..9d62f68 100644 --- a/dashboard/serializers.py +++ b/dashboard/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .mock_data import VALID_CARD_IDS, VALID_ROW_IDS +from .defaults import VALID_CARD_IDS, VALID_ROW_IDS class FarmDashboardConfigSerializer(serializers.Serializer): diff --git a/dashboard/services.py b/dashboard/services.py index 1089983..feaf44c 100644 --- a/dashboard/services.py +++ b/dashboard/services.py @@ -20,7 +20,7 @@ from device_hub.services import ( ) from yield_harvest.services import get_yield_harvest_summary_data -from .mock_data import ALL_CARDS +from .templates import get_all_card_templates def _update_kpi(card_lookup, card_data): @@ -83,33 +83,41 @@ def _build_recommendations_list(farm, fallback_data, harvest_card): def get_farm_dashboard_cards(farm): - cards = deepcopy(ALL_CARDS) + cards = get_all_card_templates() - weather_card = get_farm_weather_card_data(farm) + water_cards = { + "farmWeatherCard": get_farm_weather_card_data(farm), + "waterNeedPrediction": get_water_need_prediction_data(farm), + "waterStressIndex": get_water_stress_index_data(farm), + } crop_health_summary = get_crop_health_summary_data(farm) risk_summary = get_risk_summary_data(farm) yield_summary = get_yield_harvest_summary_data(farm) - water_stress_index = get_water_stress_index_data(farm) sensor_summary = get_sensor_7_in_1_summary_data(farm) + alert_cards = { + "farmAlertsTracker": get_alert_tracker_data(farm), + "farmAlertsTimeline": get_alert_timeline_data(farm), + } + economic_overview = get_economic_overview_data(farm) avg_soil_moisture = sensor_summary["avgSoilMoisture"] - cards["farmWeatherCard"] = weather_card - cards["farmAlertsTracker"] = get_alert_tracker_data(farm) - cards["farmAlertsTimeline"] = get_alert_timeline_data(farm) + cards["farmWeatherCard"] = water_cards["farmWeatherCard"] + cards["farmAlertsTracker"] = alert_cards["farmAlertsTracker"] + cards["farmAlertsTimeline"] = alert_cards["farmAlertsTimeline"] cards["sensorValuesList"] = sensor_summary["sensorValuesList"] cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"] - cards["waterNeedPrediction"] = get_water_need_prediction_data(farm) + cards["waterNeedPrediction"] = water_cards["waterNeedPrediction"] cards["harvestPredictionCard"] = yield_summary["harvest_prediction_card"] cards["yieldPredictionChart"] = yield_summary["yield_prediction_chart"] cards["sensorRadarChart"] = sensor_summary["sensorRadarChart"] cards["sensorComparisonChart"] = sensor_summary["sensorComparisonChart"] cards["soilMoistureHeatmap"] = sensor_summary["soilMoistureHeatmap"] cards["ndviHealthCard"] = crop_health_summary["ndviHealthCard"] - cards["economicOverview"] = get_economic_overview_data(farm) + cards["economicOverview"] = economic_overview cards["farmOverviewKpis"] = _build_overview_kpis( cards["farmOverviewKpis"], crop_health_summary, - water_stress_index, + water_cards["waterStressIndex"], avg_soil_moisture, risk_summary, yield_summary, diff --git a/dashboard/templates.py b/dashboard/templates.py new file mode 100644 index 0000000..ab4df50 --- /dev/null +++ b/dashboard/templates.py @@ -0,0 +1,298 @@ +""" +Static dashboard payload templates used only as fallback content. +""" + +from copy import deepcopy + + +FARM_OVERVIEW_KPIS = { + "kpis": [ + { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "۷ روز اخیر", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-bug", + "chipText": "5%", + "chipColor": "success", + }, + { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": "42 تن", + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "+8%", + "chipColor": "success", + }, + { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش‌بینی هوشمند", + "stats": "15%", + "avatarColor": "warning", + "avatarIcon": "tabler-bug-off", + "chipText": "تحت نظر", + "chipColor": "warning", + }, + ] +} + +FARM_WEATHER_CARD = { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"], + "series": [[18, 22, 26, 28, 25, 20, 18]], + }, +} + +FARM_ALERTS_TRACKER = { + "totalAlerts": 3, + "radialBarValue": 30, + "alertStats": [ + { + "title": "کمبود آب", + "count": "2", + "avatarColor": "error", + "avatarIcon": "tabler-droplet-half-2", + }, + { + "title": "ریسک قارچی", + "count": "1", + "avatarColor": "warning", + "avatarIcon": "tabler-mushroom", + }, + { + "title": "هشدار یخبندان", + "count": "0", + "avatarColor": "info", + "avatarIcon": "tabler-snowflake", + }, + ], +} + +SENSOR_VALUES_LIST = { + "sensors": [ + { + "title": "28°C", + "subtitle": "دمای هوا", + "trendNumber": 2.1, + "trend": "positive", + "unit": "°C", + }, + { + "title": "24°C", + "subtitle": "دمای خاک", + "trendNumber": -0.5, + "trend": "negative", + "unit": "°C", + }, + { + "title": "65%", + "subtitle": "رطوبت هوا", + "trendNumber": 3.2, + "trend": "positive", + "unit": "%", + }, + { + "title": "42%", + "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)", + "trendNumber": -1.8, + "trend": "negative", + "unit": "%", + }, + { + "title": "6.8", + "subtitle": "pH خاک", + "trendNumber": 0.2, + "trend": "positive", + "unit": "pH", + }, + { + "title": "1.2", + "subtitle": "هدایت الکتریکی (dS/m)", + "trendNumber": 0.1, + "trend": "positive", + "unit": "dS/m", + }, + { + "title": "850", + "subtitle": "شدت نور (لوکس)", + "trendNumber": 15.3, + "trend": "positive", + "unit": "lux", + }, + { + "title": "12", + "subtitle": "سرعت باد (کیلومتر/ساعت)", + "trendNumber": -2.4, + "trend": "negative", + "unit": "km/h", + }, + ] +} + +FARM_ALERTS_TIMELINE = { + "alerts": [ + { + "title": "ریسک کمبود آب", + "description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.", + "time": "۱۵ دقیقه پیش", + "color": "warning", + }, + { + "title": "ریسک بیماری قارچی", + "description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.", + "time": "۱ ساعت پیش", + "color": "error", + }, + { + "title": "پیشنهاد آبیاری", + "description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.", + "time": "۲ ساعت پیش", + "color": "info", + }, + { + "title": "بررسی شوری خاک", + "description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.", + "time": "۴ ساعت پیش", + "color": "success", + }, + ] +} + +WATER_NEED_PREDICTION = { + "totalNext7Days": 3290, + "unit": "m³", + "categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"], + "series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}], +} + +HARVEST_PREDICTION_CARD = { + "date": "2025-10-15", + "dateFormatted": "۱۵ اکتبر ۲۰۲۵", + "daysUntil": 58, + "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", + "optimalWindowStart": "2025-10-12", + "optimalWindowEnd": "2025-10-18", +} + +YIELD_PREDICTION_CHART = { + "categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + "series": [ + {"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]}, + {"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]}, + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": "42 تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar", + }, + { + "title": "تاریخ برداشت", + "subtitle": "حدود ۱۵ اکتبر", + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar", + }, + ], +} + +RECOMMENDATIONS_LIST = { + "recommendations": [ + { + "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح", + "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "کود: NPK 20-20-20", + "subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", + }, + { + "title": "قارچ‌کش: پیشگیرانه", + "subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.", + "avatarIcon": "tabler-mushroom", + "avatarColor": "warning", + }, + { + "title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر", + "subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.", + "avatarIcon": "tabler-calendar-event", + "avatarColor": "info", + }, + ] +} + +ECONOMIC_OVERVIEW = { + "economicData": [ + { + "title": "هزینه آب", + "value": "€720", + "subtitle": "این ماه", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "صرفه‌جویی آب هوشمند", + "value": "€156", + "subtitle": "۱۸٪ صرفه‌جویی شده", + "avatarIcon": "tabler-bulb", + "avatarColor": "success", + }, + { + "title": "بازده سرمایه پلتفرم", + "value": "127%", + "subtitle": "نسبت به سال گذشته", + "avatarIcon": "tabler-chart-line", + "avatarColor": "info", + }, + { + "title": "پیش‌بینی درآمد", + "value": "€42k", + "subtitle": "این فصل", + "avatarIcon": "tabler-currency-euro", + "avatarColor": "success", + }, + ], + "chartSeries": [ + {"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]}, + {"name": "کود", "data": [80, 85, 90, 75, 82, 78]}, + ], + "chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], +} + +ALL_CARD_TEMPLATES = { + "farmOverviewKpis": FARM_OVERVIEW_KPIS, + "farmWeatherCard": FARM_WEATHER_CARD, + "farmAlertsTracker": FARM_ALERTS_TRACKER, + "sensorValuesList": SENSOR_VALUES_LIST, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, + "farmAlertsTimeline": FARM_ALERTS_TIMELINE, + "waterNeedPrediction": WATER_NEED_PREDICTION, + "harvestPredictionCard": HARVEST_PREDICTION_CARD, + "yieldPredictionChart": YIELD_PREDICTION_CHART, + "soilMoistureHeatmap": {}, + "ndviHealthCard": {}, + "recommendationsList": RECOMMENDATIONS_LIST, + "economicOverview": ECONOMIC_OVERVIEW, +} + + +def get_all_card_templates(): + return deepcopy(ALL_CARD_TEMPLATES) diff --git a/dashboard/tests.py b/dashboard/tests.py index 9c95eac..fbb5e64 100644 --- a/dashboard/tests.py +++ b/dashboard/tests.py @@ -7,7 +7,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate from access_control.models import AccessFeature, AccessRule from farm_hub.models import FarmHub, FarmType -from .mock_data import DEFAULT_CONFIG +from .defaults import DEFAULT_CONFIG from .models import FarmDashboardConfig from .views import FarmDashboardCardsView, FarmDashboardConfigView diff --git a/dashboard/views.py b/dashboard/views.py index f9c04c6..373d818 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -11,8 +11,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema from config.swagger import code_response from farm_hub.models import FarmHub +from .defaults import get_default_dashboard_config from .services import get_farm_dashboard_cards -from .mock_data import DEFAULT_CONFIG from .models import FarmDashboardConfig from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer @@ -29,12 +29,13 @@ class FarmAccessMixin: @staticmethod def _get_or_create_dashboard_config(farm): + default_config = get_default_dashboard_config() config, _created = FarmDashboardConfig.objects.get_or_create( farm=farm, defaults={ - "disabled_card_ids": DEFAULT_CONFIG["disabled_card_ids"], - "row_order": DEFAULT_CONFIG["row_order"], - "enable_drag_reorder": DEFAULT_CONFIG["enable_drag_reorder"], + "disabled_card_ids": default_config["disabled_card_ids"], + "row_order": default_config["row_order"], + "enable_drag_reorder": default_config["enable_drag_reorder"], }, ) return config diff --git a/device_hub/migrations/0001_initial.py b/device_hub/migrations/0001_initial.py index 091a26a..cd39d5e 100644 --- a/device_hub/migrations/0001_initial.py +++ b/device_hub/migrations/0001_initial.py @@ -4,11 +4,23 @@ import django.db.models.deletion from django.db import migrations, models +def _create_model_if_missing(app_label, model_name): + def _operation(apps, schema_editor): + model = apps.get_model(app_label, model_name) + existing_tables = set(schema_editor.connection.introspection.table_names()) + if model._meta.db_table in existing_tables: + return + schema_editor.create_model(model) + + return _operation + + class Migration(migrations.Migration): initial = True + atomic = False dependencies = [ - ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ("farm_hub", "0001_initial"), ] operations = [ @@ -33,24 +45,48 @@ class Migration(migrations.Migration): ], options={"db_table": "sensor_catalogs", "ordering": ["code"]}, ), + ], + ), + migrations.RunPython( + _create_model_if_missing("device_hub", "SensorCatalog"), + migrations.RunPython.noop, + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ migrations.CreateModel( - name="FarmDevice", + name="FarmSensor", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), - ("physical_device_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), ("name", models.CharField(max_length=255)), ("sensor_type", models.CharField(blank=True, default="", max_length=255)), ("is_active", models.BooleanField(default=True)), ("specifications", models.JSONField(blank=True, default=dict)), ("power_source", models.JSONField(blank=True, default=dict)), + ("customization", models.JSONField(blank=True, default=dict)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), - ("farm", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sensors", to="farm_hub.farmhub")), - ("sensor_catalog", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.sensorcatalog")), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sensors", + to="farm_hub.farmhub", + ), + ), ], options={"db_table": "farm_sensors", "ordering": ["-created_at"]}, ), + ], + ), + migrations.RunPython( + _create_model_if_missing("device_hub", "FarmSensor"), + migrations.RunPython.noop, + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ migrations.CreateModel( name="SensorExternalRequestLog", fields=[ @@ -65,4 +101,8 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython( + _create_model_if_missing("device_hub", "SensorExternalRequestLog"), + migrations.RunPython.noop, + ), ] diff --git a/device_hub/migrations/0004_absorb_sensor_catalog.py b/device_hub/migrations/0004_absorb_sensor_catalog.py index fefded1..e36ea15 100644 --- a/device_hub/migrations/0004_absorb_sensor_catalog.py +++ b/device_hub/migrations/0004_absorb_sensor_catalog.py @@ -1,10 +1,35 @@ -from django.db import migrations +import uuid + +import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("device_hub", "0003_absorb_sensor_external_api"), + ("farm_hub", "0003_farmsensor_catalog_and_physical_device"), ] - operations = [] - + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name="farmsensor", + name="physical_device_uuid", + field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + migrations.AddField( + model_name="farmsensor", + name="sensor_catalog", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farm_sensors", + to="device_hub.sensorcatalog", + ), + ), + ], + ), + ] diff --git a/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py b/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py index 4707fc2..5bb1380 100644 --- a/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py +++ b/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py @@ -9,23 +9,34 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RenameModel( - old_name="SensorCatalog", - new_name="DeviceCatalog", - ), - migrations.AddField( - model_name="devicecatalog", - name="device_communication_type", - field=models.CharField( - choices=[("output_only", "Output Only"), ("input_only", "Input Only")], - db_index=True, - default="output_only", - max_length=32, - ), - ), - migrations.AlterField( - model_name="farmdevice", - name="sensor_catalog", - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="farm_devices", to="device_hub.devicecatalog"), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RenameModel( + old_name="SensorCatalog", + new_name="DeviceCatalog", + ), + migrations.AddField( + model_name="devicecatalog", + name="device_communication_type", + field=models.CharField( + choices=[("output_only", "Output Only"), ("input_only", "Input Only")], + db_index=True, + default="output_only", + max_length=32, + ), + ), + migrations.AlterField( + model_name="farmdevice", + name="sensor_catalog", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farm_devices", + to="device_hub.devicecatalog", + ), + ), + ], ), ] diff --git a/device_hub/migrations/0008_farmdevice_device_catalogs.py b/device_hub/migrations/0008_farmdevice_device_catalogs.py index 99ccb7b..3f558aa 100644 --- a/device_hub/migrations/0008_farmdevice_device_catalogs.py +++ b/device_hub/migrations/0008_farmdevice_device_catalogs.py @@ -1,23 +1,51 @@ from django.db import migrations, models +def ensure_device_catalogs_m2m_table(apps, schema_editor): + FarmDevice = apps.get_model("device_hub", "FarmDevice") + through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through + existing_tables = set(schema_editor.connection.introspection.table_names()) + if through_model._meta.db_table not in existing_tables: + schema_editor.create_model(through_model) + + def copy_sensor_catalog_to_device_catalogs(apps, schema_editor): FarmDevice = apps.get_model("device_hub", "FarmDevice") - for farm_device in FarmDevice.objects.exclude(sensor_catalog__isnull=True).iterator(): - farm_device.device_catalogs.add(farm_device.sensor_catalog_id) + through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through + through_table = through_model._meta.db_table + farm_device_column = through_model._meta.get_field("farmdevice").column + device_catalog_column = through_model._meta.get_field("devicecatalog").column + + with schema_editor.connection.cursor() as cursor: + for farm_device_id, sensor_catalog_id in FarmDevice.objects.exclude(sensor_catalog__isnull=True).values_list("pk", "sensor_catalog_id").iterator(): + cursor.execute( + f""" + INSERT IGNORE INTO {schema_editor.quote_name(through_table)} + ({schema_editor.quote_name(farm_device_column)}, {schema_editor.quote_name(device_catalog_column)}) + VALUES (%s, %s) + """, + [farm_device_id, sensor_catalog_id], + ) class Migration(migrations.Migration): + atomic = False dependencies = [ ("device_hub", "0007_devicecatalog_dynamic_fields"), ] operations = [ - migrations.AddField( - model_name="farmdevice", - name="device_catalogs", - field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name="farmdevice", + name="device_catalogs", + field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"), + ), + ], ), + migrations.RunPython(ensure_device_catalogs_m2m_table, migrations.RunPython.noop), migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop), ] diff --git a/device_hub/migrations/0009_sync_devicecatalog_schema.py b/device_hub/migrations/0009_sync_devicecatalog_schema.py new file mode 100644 index 0000000..219c596 --- /dev/null +++ b/device_hub/migrations/0009_sync_devicecatalog_schema.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +def add_column_if_missing(schema_editor, table_name, column_name, field): + existing_columns = { + column.name + for column in schema_editor.connection.introspection.get_table_description( + schema_editor.connection.cursor(), + table_name, + ) + } + if column_name in existing_columns: + return + field.set_attributes_from_name(column_name) + schema_editor.add_field( + field.model, + field, + ) + + +def sync_devicecatalog_schema(apps, schema_editor): + DeviceCatalog = apps.get_model("device_hub", "DeviceCatalog") + table_name = DeviceCatalog._meta.db_table + + fields = [ + DeviceCatalog._meta.get_field("device_communication_type"), + DeviceCatalog._meta.get_field("payload_mapping"), + DeviceCatalog._meta.get_field("display_schema"), + DeviceCatalog._meta.get_field("supported_widgets"), + DeviceCatalog._meta.get_field("commands_schema"), + DeviceCatalog._meta.get_field("capabilities"), + ] + + for field in fields: + add_column_if_missing(schema_editor, table_name, field.column, field) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("device_hub", "0008_farmdevice_device_catalogs"), + ] + + operations = [ + migrations.RunPython(sync_devicecatalog_schema, migrations.RunPython.noop), + ] diff --git a/device_hub/services.py b/device_hub/services.py index 1fe6dfb..9576fb1 100644 --- a/device_hub/services.py +++ b/device_hub/services.py @@ -6,12 +6,13 @@ from django.conf import settings from django.db import OperationalError, ProgrammingError, transaction from django.utils import timezone +from config.failure_contract import StructuredServiceError from external_api_adapter import request as external_api_request from external_api_adapter.exceptions import ExternalAPIRequestError from notifications.services import create_notification_for_farm_uuid -from .mock_data import ANOMALY_DETECTION_CARD, AVG_SOIL_MOISTURE, SENSOR_COMPARISON_CHART, SENSOR_RADAR_CHART, SENSOR_VALUES_LIST, SOIL_MOISTURE_HEATMAP from .models import FarmDevice, SensorExternalRequestLog +from .templates import AVG_SOIL_MOISTURE_TEMPLATE, SENSOR_META_TEMPLATE, SOIL_MOISTURE_HEATMAP_TEMPLATE logger = logging.getLogger(__name__) @@ -19,6 +20,17 @@ logger = logging.getLogger(__name__) class FarmDataForwardError(Exception): pass + +class DeviceDataUnavailableError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, details: dict | None = None, retriable: bool = False): + super().__init__( + error_code=error_code, + message=message, + source="db", + details=details, + retriable=retriable, + ) + SENSOR_FIELDS = [ {"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"}, {"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"}, @@ -257,21 +269,37 @@ def get_primary_soil_sensor(*, farm): def _get_sensor_context(farm=None): if farm is None: - return None + raise DeviceDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for sensor context lookup.", + ) primary_sensor = get_primary_soil_sensor(farm=farm) if primary_sensor is None: - return None + raise DeviceDataUnavailableError( + error_code="sensor_not_found", + message=f"No primary soil sensor found for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + ) try: logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid) - except ValueError: - return None + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Sensor history lookup failed for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + retriable=True, + ) from exc history = [] for log in logs_queryset[:MAX_HISTORY_ITEMS]: readings = _extract_readings(log.payload) if readings: history.append((log, readings)) if not history: - return None + raise DeviceDataUnavailableError( + error_code="no_sensor_readings", + message=f"No sensor readings found for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + ) latest_log, latest_readings = history[0] farm_device_map = get_farm_device_map_for_logs(logs=[latest_log]) farm_device = farm_device_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor @@ -304,40 +332,46 @@ def _calculate_status_chip(value): def get_sensor_7_in_1_values_list_data(farm=None, context=None): - data = deepcopy(SENSOR_VALUES_LIST) context = _get_sensor_context(farm) if context is None else context - data["sensor"] = _build_sensor_meta(context, data["sensor"]) - if not context: - return data + data = { + "sensor": _build_sensor_meta(context, SENSOR_META_TEMPLATE), + "sensors": [], + } latest_readings = context["latest_readings"] previous_readings = context["previous_readings"] - sensors = [] for field in SENSOR_FIELDS: value = latest_readings.get(field["id"]) if value is None: continue previous = previous_readings.get(field["id"]) change = 0.0 if previous is None else round(value - previous, 2) - sensors.append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]}) - if sensors: - data["sensors"] = sensors + data["sensors"].append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]}) + if not data["sensors"]: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"Latest sensor payload has no usable numeric values for farm_uuid={farm.farm_uuid if farm else None}.", + ) return data def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None): - data = deepcopy(AVG_SOIL_MOISTURE) context = _get_sensor_context(farm) if context is None else context - if not context: - return data moisture = context["latest_readings"].get("soil_moisture") if moisture is None: - return data + raise DeviceDataUnavailableError( + error_code="missing_soil_moisture", + message=f"Latest sensor payload is missing soil_moisture for farm_uuid={farm.farm_uuid if farm else None}.", + ) chip_text, chip_color, avatar_color = _calculate_status_chip(moisture) - data["stats"] = _format_value(moisture, "%") - data["chipText"] = chip_text - data["chipColor"] = chip_color - data["avatarColor"] = avatar_color - return data + return { + **deepcopy(AVG_SOIL_MOISTURE_TEMPLATE), + "stats": _format_value(moisture, "%"), + "chipText": chip_text, + "chipColor": chip_color, + "avatarColor": avatar_color, + "status": "success", + "source": "db", + } def _score_field(value, field): @@ -353,10 +387,7 @@ def _score_field(value, field): def get_sensor_7_in_1_radar_chart_data(farm=None, context=None): - data = deepcopy(SENSOR_RADAR_CHART) context = _get_sensor_context(farm) if context is None else context - if not context: - return data latest_readings = context["latest_readings"] scores, labels = [], [] for field in SENSOR_FIELDS: @@ -365,32 +396,42 @@ def get_sensor_7_in_1_radar_chart_data(farm=None, context=None): continue labels.append(field["radar_label"]) scores.append(_score_field(value, field)) - if labels: - data["labels"] = labels - data["series"] = [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}] - return data + if not labels: + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"No usable sensor readings found for radar chart farm_uuid={farm.farm_uuid if farm else None}.", + ) + return { + "labels": labels, + "series": [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}], + "status": "success", + "source": "db", + } def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None): - data = deepcopy(SENSOR_COMPARISON_CHART) context = _get_sensor_context(farm) if context is None else context - if not context: - return data history = list(reversed(context["history"][:MAX_CHART_POINTS])) moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None] if not moisture_points: - return data + raise DeviceDataUnavailableError( + error_code="no_comparison_data", + message=f"No soil moisture history found for comparison chart farm_uuid={farm.farm_uuid if farm else None}.", + ) categories = [item[0] for item in moisture_points] values = [round(item[1], 2) for item in moisture_points] current_value = values[-1] baseline_value = values[0] if len(values) > 1 else 55.0 percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0 - data["currentValue"] = round(current_value, 2) - data["vsLastWeekValue"] = round(percent_change, 2) - data["vsLastWeek"] = f"{percent_change:+.1f}%" - data["categories"] = categories - data["series"] = [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}] - return data + return { + "currentValue": round(current_value, 2), + "vsLastWeekValue": round(percent_change, 2), + "vsLastWeek": f"{percent_change:+.1f}%", + "categories": categories, + "series": [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}], + "status": "success", + "source": "db", + } def _build_anomaly_item(field, value): @@ -408,10 +449,7 @@ def _build_anomaly_item(field, value): def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None): - data = deepcopy(ANOMALY_DETECTION_CARD) context = _get_sensor_context(farm) if context is None else context - if not context: - return data anomalies = [] for field in SENSOR_FIELDS: value = context["latest_readings"].get(field["id"]) @@ -420,27 +458,38 @@ def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None): anomaly = _build_anomaly_item(field, value) if anomaly is not None: anomalies.append(anomaly) - data["anomalies"] = anomalies or [{"sensor": "سنسور 7 در 1 خاک", "value": "نرمال", "expected": "تمام شاخص‌ها در بازه مجاز هستند", "deviation": "0", "severity": "success"}] - return data + return { + "anomalies": anomalies, + "status": "success", + "source": "db", + "warnings": [] if anomalies else ["No anomalies detected from the latest sensor readings."], + } def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None): - data = deepcopy(SOIL_MOISTURE_HEATMAP) context = _get_sensor_context(farm) if context is None else context - if not context: - return data history = list(reversed(context["history"][:MAX_CHART_POINTS])) chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None] if not chart_points: - return data - sensor_name = data["zones"][0] + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"No soil moisture history found for heatmap farm_uuid={farm.farm_uuid if farm else None}.", + ) + sensor_name = ( + SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"][0] + if SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"] + else "سنسور خاک" + ) farm_device = context.get("farm_device") if farm_device is not None and farm_device.name: sensor_name = farm_device.name - data["zones"] = [sensor_name] - data["hours"] = [point["x"] for point in chart_points] - data["series"] = [{"name": sensor_name, "data": chart_points}] - return data + return { + "zones": [sensor_name], + "hours": [point["x"] for point in chart_points], + "series": [{"name": sensor_name, "data": chart_points}], + "status": "success", + "source": "db", + } def get_sensor_7_in_1_summary_data(farm=None): @@ -473,8 +522,10 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value) start_date = timezone.localdate() - timedelta(days=days - 1) try: logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid, date_from=start_date) - except ValueError: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor comparison chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc grouped_logs = {} for log in reversed(list(logs_queryset[: days * 24])): bucket_date = timezone.localtime(log.created_at).date() @@ -482,7 +533,9 @@ def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value) if numeric_payload: grouped_logs[bucket_date] = numeric_payload if not grouped_logs: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + raise DeviceDataUnavailableError( + f"No sensor history found for comparison chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) sorted_dates = sorted(grouped_logs.keys()) categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] series_map = {} @@ -500,13 +553,17 @@ def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value): start_time = timezone.now() - VALUES_LIST_RANGES[range_value] try: logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) - except ValueError: - return {"sensors": []} + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor values list data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) if not logs: latest_log = logs_queryset.order_by("-created_at", "-id").first() if latest_log is None: - return {"sensors": []} + raise DeviceDataUnavailableError( + f"No sensor logs found for values list farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) logs = [latest_log] earliest_payload, latest_payload = {}, {} for log in logs: @@ -517,7 +574,9 @@ def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value): earliest_payload = numeric_payload latest_payload = numeric_payload if not latest_payload: - return {"sensors": []} + raise DeviceDataUnavailableError( + f"Latest sensor payload has no numeric values for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) sensors = [] for field_name, title, unit in VALUES_LIST_FIELDS: current_value = latest_payload.get(field_name) @@ -533,13 +592,17 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): start_time = timezone.now() - RADAR_CHART_RANGES[range_value] try: logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) - except ValueError: - return {"labels": [], "series": []} + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor radar chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) if not logs: latest_log = logs_queryset.order_by("-created_at", "-id").first() if latest_log is None: - return {"labels": [], "series": []} + raise DeviceDataUnavailableError( + f"No sensor logs found for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) logs = [latest_log] latest_payload = {} for log in logs: @@ -547,7 +610,9 @@ def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): if numeric_payload: latest_payload = numeric_payload if not latest_payload: - return {"labels": [], "series": []} + raise DeviceDataUnavailableError( + f"Latest sensor payload has no numeric values for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) labels, current_data, ideal_data = [], [], [] for field_name, label, ideal_value in RADAR_CHART_FIELDS: current_value = latest_payload.get(field_name) @@ -728,11 +793,24 @@ def _get_device_supported_widgets(device_catalog): def _get_device_history_context(farm_device): if farm_device is None: - return None + raise DeviceDataUnavailableError( + error_code="device_not_found", + message="Farm device instance is required for history lookup.", + ) try: logs_queryset = get_device_logs(farm_device) - except ValueError: - return None + except ValueError as exc: + logger.error( + "Device history lookup failed for farm_device_id=%s: %s", + getattr(farm_device, "id", None), + exc, + ) + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device history lookup failed for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + retriable=True, + ) from exc history = [] device_catalog = get_device_catalog_for_farm_device(farm_device) for log in logs_queryset[:MAX_HISTORY_ITEMS]: @@ -741,14 +819,11 @@ def _get_device_history_context(farm_device): if readings or normalized_payload: history.append((log, readings, normalized_payload)) if not history: - return { - "farm_device": farm_device, - "latest_log": None, - "latest_readings": {}, - "latest_payload": {}, - "previous_readings": {}, - "history": [], - } + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device history found for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) latest_log, latest_readings, latest_payload = history[0] return { "farm_device": farm_device, @@ -775,15 +850,11 @@ def build_device_latest_payload(farm_device, *, device_code): device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) latest_log = get_latest_device_log(farm_device, device_catalog=device_catalog) if latest_log is None: - return { - "physical_device_uuid": farm_device.physical_device_uuid, - "device_code": device_code, - "device_catalog_code": device_catalog.code if device_catalog else None, - "raw_payload": {}, - "normalized_payload": {}, - "readings": {}, - "created_at": None, - } + raise DeviceDataUnavailableError( + error_code="no_device_payload", + message=f"No device payload log found for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) return { "physical_device_uuid": farm_device.physical_device_uuid, "device_code": device_code, @@ -798,14 +869,23 @@ def build_device_latest_payload(farm_device, *, device_code): def build_device_values_list(farm_device, range_value, *, device_code): try: logs_queryset = get_device_logs(farm_device) - except ValueError: - return {"sensors": []} + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device values list data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + retriable=True, + ) from exc start_time = timezone.now() - VALUES_LIST_RANGES[range_value] logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) if not logs: latest_log = logs_queryset.order_by("-created_at", "-id").first() if latest_log is None: - return {"sensors": []} + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device logs found for values list farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) logs = [latest_log] device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) earliest_payload = {} @@ -818,7 +898,11 @@ def build_device_values_list(farm_device, range_value, *, device_code): earliest_payload = normalized_payload latest_payload = normalized_payload if not latest_payload: - return {"sensors": []} + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"Latest device payload has no numeric values for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) sensors = [] for field in _get_device_field_definitions(device_catalog): current_value = latest_payload.get(field["id"]) @@ -835,7 +919,13 @@ def build_device_values_list(farm_device, range_value, *, device_code): "unit": field["unit"], } ) - return {"sensors": sensors} + if not sensors: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No device values could be derived for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return {"sensors": sensors, "status": "success", "source": "db"} def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None): @@ -860,6 +950,12 @@ def build_device_summary_values_list(farm_device, context=None, *, device_catalo "unit": field["unit"], } ) + if not data["sensors"]: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No summary values available for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) return data @@ -867,7 +963,11 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code): device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) context = _get_device_history_context(farm_device) if not context or not context.get("latest_readings"): - return {"labels": [], "series": []} + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"Device radar chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) labels, current_data, ideal_data = [], [], [] for field in _get_device_field_definitions(device_catalog): current_value = context["latest_readings"].get(field["id"]) @@ -877,7 +977,13 @@ def build_device_radar_chart(farm_device, range_value=None, *, device_code): current_data.append(round(current_value, 2)) midpoint = (field["ideal_min"] + field["ideal_max"]) / 2 ideal_data.append(round(midpoint, 2)) - return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]} + if not labels: + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"No usable readings found for radar chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}], "status": "success", "source": "db"} def build_device_comparison_chart(farm_device, range_value, *, device_code): @@ -885,8 +991,13 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code): start_date = timezone.localdate() - timedelta(days=days - 1) try: logs_queryset = get_device_logs(farm_device, date_from=start_date) - except ValueError: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device comparison chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + retriable=True, + ) from exc device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) field_definitions = _get_device_field_definitions(device_catalog) grouped_logs = {} @@ -894,7 +1005,11 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code): bucket_date = timezone.localtime(log.created_at).date() grouped_logs[bucket_date] = extract_device_readings(device_catalog, log.payload) if not grouped_logs: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device history found for comparison chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) sorted_dates = sorted(grouped_logs.keys()) categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] series = [] @@ -912,12 +1027,18 @@ def build_device_comparison_chart(farm_device, range_value, *, device_code): if not primary_data: primary_data = data_points if not series or not primary_data: - return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + raise DeviceDataUnavailableError( + error_code="no_comparison_data", + message=f"Device comparison chart has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) return { "series": series, "categories": categories, "currentValue": round(primary_data[-1], 2), "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]), + "status": "success", + "source": "db", } @@ -933,29 +1054,37 @@ def build_device_anomaly_detection_card(farm_device, context=None, *, device_cat anomaly = _build_anomaly_item(field, value) if anomaly is not None: anomalies.append(anomaly) + if not latest_readings: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No latest readings available for anomaly detection farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) return { - "anomalies": anomalies or [ - { - "sensor": farm_device.name if farm_device else "Device", - "value": "نرمال", - "expected": "تمام شاخص‌ها در بازه مجاز هستند", - "deviation": "0", - "severity": "success", - } - ] + "anomalies": anomalies, + "status": "success", + "source": "db", + "warnings": [] if anomalies else ["No anomalies detected from the latest device readings."], } def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_catalog=None): - data = deepcopy(SOIL_MOISTURE_HEATMAP) context = _get_device_history_context(farm_device) if context is None else context if not context or not context.get("history"): - return data + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"Device heatmap data is unavailable for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) field_definitions = _get_device_field_definitions(device_catalog) primary_field = field_definitions[0] if field_definitions else None if primary_field is None: - return data + raise DeviceDataUnavailableError( + error_code="invalid_schema", + message=f"Device field schema is missing for heatmap farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) chart_points = [] for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]): value = readings.get(primary_field["id"]) @@ -963,33 +1092,51 @@ def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_cata continue chart_points.append({"x": log.created_at.strftime("%H:%M"), "y": round(value, 2)}) if not chart_points: - return data - sensor_name = farm_device.name if farm_device and farm_device.name else data["zones"][0] - data["zones"] = [sensor_name] - data["hours"] = [point["x"] for point in chart_points] - data["series"] = [{"name": sensor_name, "data": chart_points}] - return data + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"Device heatmap has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + sensor_name = farm_device.name if farm_device and farm_device.name else SOIL_MOISTURE_HEATMAP["zones"][0] + return { + "zones": [sensor_name], + "hours": [point["x"] for point in chart_points], + "series": [{"name": sensor_name, "data": chart_points}], + "status": "success", + "source": "db", + } def build_device_avg_primary_metric(farm_device, context=None, *, device_catalog=None): - data = deepcopy(AVG_SOIL_MOISTURE) context = _get_device_history_context(farm_device) if context is None else context latest_readings = context.get("latest_readings", {}) if context else {} device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) field_definitions = _get_device_field_definitions(device_catalog) primary_field = field_definitions[0] if field_definitions else None if primary_field is None: - return data + raise DeviceDataUnavailableError( + error_code="invalid_schema", + message=f"Device field schema is missing for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) primary_value = latest_readings.get(primary_field["id"]) if primary_value is None: - return data + raise DeviceDataUnavailableError( + error_code="missing_primary_metric", + message=f"Primary metric is missing for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) chip_text, chip_color, avatar_color = _calculate_status_chip(primary_value) - data["title"] = primary_field["label"] - data["stats"] = _format_value(primary_value, primary_field["unit"]) - data["chipText"] = chip_text - data["chipColor"] = chip_color - data["avatarColor"] = avatar_color - return data + return { + **deepcopy(AVG_SOIL_MOISTURE), + "title": primary_field["label"], + "stats": _format_value(primary_value, primary_field["unit"]), + "chipText": chip_text, + "chipColor": chip_color, + "avatarColor": avatar_color, + "status": "success", + "source": "db", + } def build_device_summary(farm_device, *, device_code): diff --git a/device_hub/templates.py b/device_hub/templates.py new file mode 100644 index 0000000..f3bf20e --- /dev/null +++ b/device_hub/templates.py @@ -0,0 +1,23 @@ +AVG_SOIL_MOISTURE_TEMPLATE = { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-droplet", + "chipText": "بدون داده", + "chipColor": "secondary", +} + +SENSOR_META_TEMPLATE = { + "name": "سنسور 7 در 1 خاک", + "physicalDeviceUuid": None, + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": None, +} + +SOIL_MOISTURE_HEATMAP_TEMPLATE = { + "zones": [], + "hours": [], + "series": [], +} diff --git a/device_hub/tests.py b/device_hub/tests.py index 7c2585b..a3772ea 100644 --- a/device_hub/tests.py +++ b/device_hub/tests.py @@ -5,6 +5,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate from farm_hub.models import FarmHub, FarmType from .models import DeviceCatalog, SensorExternalRequestLog +from .services import DeviceDataUnavailableError, build_device_anomaly_detection_card from .views import DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView @@ -91,6 +92,27 @@ class DeviceHubGenericViewsTests(TestCase): self.assertIn("values_list", response.data["data"]["supportedWidgets"]) self.assertIn("sensorValuesList", response.data["data"]) + def test_device_summary_view_returns_validation_error_when_history_missing(self): + SensorExternalRequestLog.objects.all().delete() + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 400) + self.assertIn("no device history found", response.data["device_code"][0].lower()) + + def test_build_device_anomaly_detection_card_returns_explicit_empty_success(self): + payload = build_device_anomaly_detection_card(self.device) + + self.assertEqual(payload["status"], "success") + self.assertEqual(payload["source"], "db") + self.assertEqual(payload["anomalies"], []) + self.assertTrue(payload["warnings"]) + def test_input_only_device_command_view_rejects_input_only_device_code(self): input_catalog = DeviceCatalog.objects.create( code="valve_v1", diff --git a/device_hub/views.py b/device_hub/views.py index 1a1d561..6dc58e8 100644 --- a/device_hub/views.py +++ b/device_hub/views.py @@ -13,7 +13,7 @@ from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerial from .authentication import SensorExternalAPIKeyAuthentication from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer from .serializers import DeviceCatalogSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer -from .services import FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog +from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog class DeviceCatalogListView(APIView): @@ -114,6 +114,12 @@ class DeviceRadarChartView(DeviceBaseView): return Response(data, status=status.HTTP_200_OK) +class SensorExternalRequestLogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + class DeviceLogListView(DeviceBaseView): pagination_class = SensorExternalRequestLogPagination @@ -194,21 +200,33 @@ class Sensor7In1SummaryView(APIView): @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}) def get(self, request): farm = self._get_farm(request) - return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_summary_data(farm)}, status=status.HTTP_200_OK) + try: + data = get_sensor_7_in_1_summary_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) class Sensor7In1RadarChartView(Sensor7In1SummaryView): @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())}) def get(self, request): farm = self._get_farm(request) - return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_radar_chart_data(farm)}, status=status.HTTP_200_OK) + try: + data = get_sensor_7_in_1_radar_chart_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())}) def get(self, request): farm = self._get_farm(request) - return Response({"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK) + try: + data = get_sensor_7_in_1_comparison_chart_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) class SensorComparisonChartView(Sensor7In1SummaryView): @@ -218,7 +236,11 @@ class SensorComparisonChartView(Sensor7In1SummaryView): serializer.is_valid(raise_exception=True) farm = self._get_farm(request) sensor = self._get_primary_sensor(farm=farm) - return Response(get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) + try: + data = get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) class SensorValuesListView(Sensor7In1SummaryView): @@ -228,7 +250,11 @@ class SensorValuesListView(Sensor7In1SummaryView): serializer.is_valid(raise_exception=True) farm = self._get_farm(request) sensor = self._get_primary_sensor(farm=farm) - return Response(get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) + try: + data = get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) class SensorRadarChartView(Sensor7In1SummaryView): @@ -238,13 +264,11 @@ class SensorRadarChartView(Sensor7In1SummaryView): serializer.is_valid(raise_exception=True) farm = self._get_farm(request) sensor = self._get_primary_sensor(farm=farm) - return Response(get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]), status=status.HTTP_200_OK) - - -class SensorExternalRequestLogPagination(PageNumberPagination): - page_size = 20 - page_size_query_param = "page_size" - max_page_size = 100 + try: + data = get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) class SensorExternalAPIView(APIView): diff --git a/docs/dashboard_card_service_map.md b/docs/dashboard_card_service_map.md index 3ca8c35..4936667 100644 --- a/docs/dashboard_card_service_map.md +++ b/docs/dashboard_card_service_map.md @@ -1,192 +1,80 @@ # نقشه سرویس کارت های داشبورد -این فایل فقط برای پیدا کردن منبع داده هر کارت داشبورد نوشته شده است تا قبل از پیاده سازی API تجمیعی بدانیم هر کارت باید از کدام app و کدام service تغذیه شود. +این سند مرجع فشرده `وضعیت واقعی کارت های داشبورد` است؛ نه طراحی آینده. +تمرکز آن روی منبع داده واقعی، status فعلی، و semantics پاسخ در runtime است. + +## قانون runtime در برابر seed + +- داده seed / bootstrap / fixture مجاز است و باید فقط از مسیرهای seeding و bootstrap در دسترس بماند. +- داده `mock/sample/demo` نباید در مسیر runtime سرویس، view یا adapter برای تولید پاسخ production-like استفاده شود. +- اگر داده واقعی وجود ندارد، سرویس باید `empty state` یا `failure contract` صریح برگرداند، نه داده ساختگی موفق. ## نقطه شروع فعلی -- تجمیع اصلی کارت ها الان در `dashboard/services.py` داخل تابع `get_farm_dashboard_cards` انجام می شود. -- endpoint فعلی ارسال کارت ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد. -- لیست کارت های معتبر در `dashboard/mock_data.py` داخل `VALID_CARD_IDS` نگهداری می شود. +- تجمیع اصلی کارت‌ها در `dashboard/services.py` داخل `get_farm_dashboard_cards` انجام می‌شود. +- endpoint فعلی ارسال کارت‌ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد. +- لیست کارت‌های معتبر در `dashboard/defaults.py` داخل `VALID_CARD_IDS` نگهداری می‌شود. -## جمع بندی سریع +## جمع‌بندی سریع -| Card ID | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح | -| --- | --- | --- | --- | --- | -| `farmOverviewKpis` | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | خودش داده مستقل ندارد و از چند app ساخته می شود | -| `farmWeatherCard` | آب و هوا | `get_farm_weather_card_data` | `water` | از لاگ پیش بینی هوا می آید | -| `farmAlertsTracker` | هشدارها | `get_alert_tracker_data` | `farm_alerts` | شمارش هشدارهای فعال | -| `sensorValuesList` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | لیست آخرین مقادیر سنسور | -| `sensorRadarChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | امتیازدهی راداری وضعیت سنسور | -| `sensorComparisonChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | مقایسه تاریخی رطوبت خاک | -| `anomalyDetectionCard` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ناهنجاری های خارج از بازه مجاز | -| `farmAlertsTimeline` | هشدارها | `get_alert_timeline_data` | `farm_alerts` | تایم لاین هشدارها | -| `waterNeedPrediction` | آبیاری | `get_water_need_prediction_data` | `water` | عملا از نتیجه آبیاری می خواند | -| `harvestPredictionCard` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | زمان برداشت و بازه بهینه | -| `yieldPredictionChart` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | نمودار پیش بینی عملکرد | -| `soilMoistureHeatmap` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | هیت مپ رطوبت خاک | -| `ndviHealthCard` | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | فعلا mock است | -| `recommendationsList` | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته می شود | -| `economicOverview` | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی و سری نمودار | +| Card ID | Status | semantics | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح | +| --- | --- | --- | --- | --- | --- | --- | +| `farmOverviewKpis` | `implemented / transitional` | aggregator | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | منبع واحد ندارد | +| `farmWeatherCard` | `partial` | provider/persisted | آب و هوا | `get_farm_weather_card_data` | `water` | نباید fallback ساختگی runtime داشته باشد | +| `farmAlertsTracker` | `implemented` | cached snapshot | snapshot persisted | `get_alert_tracker_data` | `farm_alerts` | live AI نیست | +| `sensorValuesList` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | adoption کامل facade `farm_data` هنوز کامل نشده | +| `sensorRadarChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت | +| `sensorComparisonChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت | +| `anomalyDetectionCard` | `implemented / transitional` | derived from sensor logs | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ownership نهایی anomalyها هنوز کامل یکدست نشده | +| `farmAlertsTimeline` | `partial` | persisted timeline | هشدارها | `get_alert_timeline_data` | `farm_alerts` | نباید fallback ساختگی runtime داشته باشد | +| `waterNeedPrediction` | `implemented / proxy-derived` | derived from persisted irrigation recommendation | آبیاری | `get_water_need_prediction_data` | `water` | facade در `water` است ولی business source در `irrigation` قرار دارد | +| `harvestPredictionCard` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید | +| `yieldPredictionChart` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید | +| `soilMoistureHeatmap` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | facade نهایی همه خوانش‌ها را هنوز unify نکرده | +| `ndviHealthCard` | `disabled / partial` | not runtime-ready | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | نباید به‌عنوان کارت implemented کامل معرفی شود | +| `recommendationsList` | `implemented / transitional` | aggregator | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته می‌شود | +| `economicOverview` | `implemented` | persisted/log-based | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی persisted | -## جزئیات هر کارت +## نکات مهم کارت‌ها -### 1) `farmOverviewKpis` +### `farmOverviewKpis` +- aggregator است و باید در `dashboard` بماند. -این کارت در `dashboard/services.py` و توسط `_build_overview_kpis` ساخته می شود و داده اش از چند app می آید: +### `farmWeatherCard` +- source: `water.models.WeatherForecastLog` +- قرارداد runtime: اگر داده هواشناسی موجود نباشد، باید `empty state` یا `failure contract` صریح برگردد، نه mock. -- `crop_health.services.get_crop_health_summary_data` - - KPI: `farmHealthScore` -- `water.services.get_water_stress_index_data` - - KPI: `water_stress_index` -- `sensor_7_in_1.services.get_sensor_7_in_1_summary_data` - - KPI: `avgSoilMoisture` -- `pest_detection.services.get_risk_summary_data` - - KPI ها: `disease_risk` و `pest_risk` -- `yield_harvest.services.get_yield_harvest_summary_data` - - KPI: `yield_prediction_card` +### `farmAlertsTracker` +- source: snapshot persisted +- semantics: `cached snapshot` -نتیجه: این بخش باید در API نهایی به صورت aggregator باقی بماند، چون منبع واحد ندارد. +### `waterNeedPrediction` +- facade فعلی در `water` +- business source واقعی: `irrigation.models.IrrigationRecommendationRequest` +- semantics: `proxy-derived persisted data` -### 2) `farmWeatherCard` +### `harvestPredictionCard` و `yieldPredictionChart` +- source: `yield_harvest.models.YieldHarvestPredictionLog` +- semantics: `persisted AI-derived` -- app: `water` -- service: `water/services.py` -> `get_farm_weather_card_data` -- model/source: `water.models.WeatherForecastLog` -- fallback: `water/mock_data.py` -> `FARM_WEATHER_CARD` +### `ndviHealthCard` +- status: `disabled / partial` +- تا زمانی که source runtime-ready پایدار برای NDVI نهایی نشود، نباید به عنوان کارت production-ready مستند شود. -اگر داده هواشناسی برای مزرعه ثبت نشده باشد، خروجی mock برمی گردد. +## Ownership و transitional boundaries -### 3) `farmAlertsTracker` +- plant catalog canonical در Backend شروع می‌شود. +- dashboard هنوز بعضی کارت‌ها را از facadeهای transitional می‌خواند. +- سنسور / plant / farm ownership به‌تدریج باید به facade `farm_data` نزدیک‌تر شود، ولی همه مصرف‌کننده‌ها هنوز migrate نشده‌اند. -- app: `farm_alerts` -- service: `farm_alerts/services.py` -> `get_alert_tracker_data` -- model/source: `farm_alerts.models.FarmAlert` -- منطق: هشدارهای `is_active=True` را می شمارد و top 3 را برمی گرداند. +## Response Semantics -### 4) `sensorValuesList` +- `farmAlertsTracker` → `cached snapshot` +- `waterNeedPrediction` → `derived from persisted irrigation recommendation` +- `harvestPredictionCard` / `yieldPredictionChart` → `persisted AI-derived snapshot` +- `farmOverviewKpis` / `recommendationsList` → `dashboard-owned aggregator` -- app: `sensor_7_in_1` -- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` -- زیرسرویس واقعی: `get_sensor_7_in_1_values_list_data` -- source dependency: - - `farm.sensors` - - `sensor_external_api.services.get_sensor_external_request_logs_for_farm` - - `sensor_external_api.services.get_farm_sensor_map_for_logs` +## Known Gaps / Follow-up -این کارت وابسته به آخرین لاگ سنسور فیزیکی مزرعه است. - -### 5) `sensorRadarChart` - -- app: `sensor_7_in_1` -- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` -- زیرسرویس واقعی: `get_sensor_7_in_1_radar_chart_data` -- source: همان context سنسور 7-in-1 - -### 6) `sensorComparisonChart` - -- app: `sensor_7_in_1` -- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` -- زیرسرویس واقعی: `get_sensor_7_in_1_comparison_chart_data` -- source: history لاگ های سنسور در `sensor_external_api` - -### 7) `anomalyDetectionCard` - -- app: `sensor_7_in_1` -- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` -- زیرسرویس واقعی: `get_sensor_7_in_1_anomaly_detection_card_data` -- منطق: از روی بازه مجاز هر فیلد سنسور anomaly می سازد. - -نکته: در `farm_alerts` هم تابع `get_anomaly_detection_data` وجود دارد، ولی در داشبورد فعلی استفاده نشده و کارت anomaly از `sensor_7_in_1` می آید. - -### 8) `farmAlertsTimeline` - -- app: `farm_alerts` -- service: `farm_alerts/services.py` -> `get_alert_timeline_data` -- model/source: `farm_alerts.models.FarmAlert` -- fallback: `farm_alerts/mock_data.py` -> `FARM_ALERTS_TIMELINE` - -### 9) `waterNeedPrediction` - -- app aggregator call: `water` -- service: `water/services.py` -> `get_water_need_prediction_data` -- source واقعی داده: `irrigation.models.IrrigationRecommendationRequest` -- منطق: از `response_payload` آخرین recommendation آبیاری، `water_balance.daily` را می خواند. - -نکته مهم: تابعی با همین نام در `irrigation/services.py` هم وجود دارد، اما داشبورد فعلی نسخه `water` را صدا می زند. پس منبع business data عملا app آبیاری است، ولی facade فعلی داخل app `water` قرار دارد. - -### 10) `harvestPredictionCard` - -- app: `yield_harvest` -- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data` -- model/source: `yield_harvest.models.YieldHarvestPredictionLog` -- داده های مهم: `harvest_date`, `days_until_harvest`, `optimal_window_start`, `optimal_window_end` - -### 11) `yieldPredictionChart` - -- app: `yield_harvest` -- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data` -- model/source: `yield_harvest.models.YieldHarvestPredictionLog` -- داده مهم: `chart_data` - -### 12) `soilMoistureHeatmap` - -- app: `sensor_7_in_1` -- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` -- زیرسرویس واقعی: `get_sensor_7_in_1_soil_moisture_heatmap_data` -- source: history رطوبت خاک از لاگ سنسورها - -### 13) `ndviHealthCard` - -- app: `crop_health` -- service: `crop_health/services.py` -> `get_crop_health_summary_data` -- وضعیت فعلی: فعلا مستقیم از mock برمی گردد. -- fallback/source: `crop_health/mock_data.py` - -نکته: این app الان منبع DB-driven در این سرویس ندارد و اگر بخواهیم داده واقعی بدهیم باید مدل یا integration منبع NDVI را همینجا اضافه کنیم. - -### 14) `recommendationsList` - -این کارت در `dashboard/services.py` و توسط `_build_recommendations_list` ساخته می شود. منابع آن: - -- `farm_alerts.services.get_recommendations_list_data` - - model/source: `farm_alerts.models.Recommendation` -- `irrigation.services.get_irrigation_dashboard_recommendation` - - model/source: `irrigation.models.IrrigationRecommendationRequest` -- `fertilization.services.get_fertilization_dashboard_recommendation` - - model/source: `fertilization.models.FertilizationRecommendationRequest` -- `yield_harvest.services.get_yield_harvest_summary_data` - - برای ساخت recommendation مرتبط با بازه برداشت - -نتیجه: این کارت هم aggregator است و بهتر است داخل app `dashboard` بماند. - -### 15) `economicOverview` - -- app: `economic_overview` -- service: `economic_overview/services.py` -> `get_economic_overview_data` -- model/source: `economic_overview.models.EconomicOverviewLog` -- فیلدهای مهم: `economic_data`, `chart_series`, `chart_categories` - -## سرویس هایی که الان ماهیت aggregator دارند - -این بخش ها باید در API نهایی dashboard به صورت orchestration بین app ها مدیریت شوند: - -- `farmOverviewKpis` -- `recommendationsList` -- کل تابع `get_farm_dashboard_cards` - -## app هایی که الان بیشتر mock هستند - -این app ها در مسیر داشبورد فعلی هنوز کاملا به داده واقعی وصل نشده اند یا بخشی از خروجی آن ها mock است: - -- `crop_health` -- `pest_detection` -- بعضی fallback های `water`, `farm_alerts`, `yield_harvest`, `sensor_7_in_1` - -## پیشنهاد برای مرحله بعد - -برای ساخت API تجمیعی تمیز، بهتر است این قرارداد را نگه داریم: - -1. هر app فقط یک service کوچک برای data payload کارت خودش بدهد. -2. app `dashboard` فقط orchestration و merge انجام دهد. -3. برای کارت های ترکیبی مثل `farmOverviewKpis` و `recommendationsList` منطق join داخل `dashboard/services.py` بماند. -4. اگر خواستی endpoint جدید بسازی، `dashboard/views.py` بهترین محل برای API نهایی است چون همین حالا هم farm validation و access control آنجا انجام می شود. +- ownership نهایی خوانش سنسور بین facade `farm_data` و سرویس‌های legacy هنوز در بعضی کارت‌ها transitional است. +- `ndviHealthCard` هنوز برای runtime production-ready نیست. diff --git a/economic_overview/defaults.py b/economic_overview/defaults.py new file mode 100644 index 0000000..6b94f17 --- /dev/null +++ b/economic_overview/defaults.py @@ -0,0 +1,8 @@ +EMPTY_ECONOMIC_OVERVIEW = { + "economicData": [], + "chartSeries": [], + "chartCategories": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted economic overview data is available for this farm."], +} diff --git a/economic_overview/services.py b/economic_overview/services.py index 7f0e60b..1f53f21 100644 --- a/economic_overview/services.py +++ b/economic_overview/services.py @@ -1,11 +1,11 @@ from copy import deepcopy -from .mock_data import ECONOMIC_OVERVIEW +from .defaults import EMPTY_ECONOMIC_OVERVIEW from .models import EconomicOverviewLog def get_economic_overview_data(farm=None): - data = deepcopy(ECONOMIC_OVERVIEW) + data = deepcopy(EMPTY_ECONOMIC_OVERVIEW) if farm is None: return data @@ -14,6 +14,9 @@ def get_economic_overview_data(farm=None): if log is None: return data + data["status"] = "success" + data["source"] = "db" + data["warnings"] = [] if log.economic_data: data["economicData"] = deepcopy(log.economic_data) if log.chart_series: diff --git a/economic_overview/tests.py b/economic_overview/tests.py index dc22dba..c5d7148 100644 --- a/economic_overview/tests.py +++ b/economic_overview/tests.py @@ -79,3 +79,19 @@ class EconomyOverviewViewTests(TestCase): with self.assertRaises(Resolver404): resolve("/api/economic-overview/summary/") + + @patch("economic_overview.views.external_api_request") + def test_overview_returns_structured_502_for_invalid_upstream_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": []}, + ) + + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.data["data"]["error_code"], "invalid_payload") + self.assertEqual(response.data["data"]["source"], "ai_provider") diff --git a/economic_overview/views.py b/economic_overview/views.py index e79e9e8..8a326ee 100644 --- a/economic_overview/views.py +++ b/economic_overview/views.py @@ -1,16 +1,58 @@ +import logging + from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from config.failure_contract import StructuredServiceError from config.swagger import status_response from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError from farm_hub.models import FarmHub from .models import EconomicOverviewLog from .serializers import EconomicOverviewRequestSerializer, EconomicOverviewSerializer +logger = logging.getLogger(__name__) + + +class EconomicOverviewAdapterError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, source: str, retriable: bool = False, details: dict | None = None): + super().__init__( + error_code=error_code, + message=message, + source=source, + retriable=retriable, + details=details, + ) + class EconomyOverviewView(APIView): + @staticmethod + def _extract_result_or_error(adapter_data): + if not isinstance(adapter_data, dict): + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview adapter returned a non-object payload.", + source="ai_provider", + ) + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview adapter payload did not contain structured result data.", + source="ai_provider", + ) + @staticmethod def _get_farm(request, farm_uuid): if not farm_uuid: @@ -26,27 +68,14 @@ class EconomyOverviewView(APIView): status=status.HTTP_404_NOT_FOUND, ) - @staticmethod - def _extract_result(adapter_data): - if not isinstance(adapter_data, dict): - return {} - - data = adapter_data.get("data") - if isinstance(data, dict) and isinstance(data.get("result"), dict): - return data["result"] - if isinstance(data, dict): - return data - - result = adapter_data.get("result") - if isinstance(result, dict): - return result - - return adapter_data - @staticmethod def _persist_log(farm, overview_data): if not isinstance(overview_data, dict): - return + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview data must be a JSON object before persistence.", + source="backend", + ) EconomicOverviewLog.objects.create( farm=farm, economic_data=overview_data.get("economicData", []), @@ -68,12 +97,26 @@ class EconomyOverviewView(APIView): return error_response payload = {"farm_uuid": str(farm.farm_uuid)} - adapter_response = external_api_request( - "ai", - "/api/economy/overview/", - method="POST", - payload=payload, - ) + try: + adapter_response = external_api_request( + "ai", + "/api/economy/overview/", + method="POST", + payload=payload, + ) + except ExternalAPIRequestError as exc: + logger.error("Economic overview upstream request failed for farm_uuid=%s: %s", farm.farm_uuid, exc) + failure = EconomicOverviewAdapterError( + error_code="upstream_unavailable", + message="Economic overview upstream request failed.", + source="ai_provider", + retriable=True, + details={"farm_uuid": str(farm.farm_uuid)}, + ) + return Response( + {"code": 503, "msg": "error", "data": failure.to_dict()}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) if adapter_response.status_code >= 400: response_data = ( @@ -86,8 +129,15 @@ class EconomyOverviewView(APIView): status=adapter_response.status_code, ) - overview_data = self._extract_result(adapter_response.data) - if isinstance(overview_data, dict): - overview_data.setdefault("farm_uuid", str(farm.farm_uuid)) - self._persist_log(farm, overview_data) + try: + overview_data = self._extract_result_or_error(adapter_response.data) + if isinstance(overview_data, dict): + overview_data.setdefault("farm_uuid", str(farm.farm_uuid)) + self._persist_log(farm, overview_data) + except EconomicOverviewAdapterError as exc: + logger.error("Economic overview payload handling failed for farm_uuid=%s: %s", farm.farm_uuid, exc) + return Response( + {"code": 502, "msg": "error", "data": exc.to_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) return Response({"code": 200, "msg": "success", "data": overview_data}, status=status.HTTP_200_OK) diff --git a/external_api_adapter/json/ai/index.json b/external_api_adapter/json/ai/index.json index 3bedd9b..7fa834f 100644 --- a/external_api_adapter/json/ai/index.json +++ b/external_api_adapter/json/ai/index.json @@ -4,622 +4,889 @@ "path": "/api/dashboard-data/generate/", "status_code": 202, "description": "Dashboard data task queued", - "file": "json/mock_data/dashboard-data/generate/post_202.json" + "file": "json/mock_data/dashboard-data/generate/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/dashboard-data/generate/", "status_code": 400, "description": "Missing sensor_id", - "file": "json/mock_data/dashboard-data/generate/post_400.json" + "file": "json/mock_data/dashboard-data/generate/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/dashboard-data/{task_id}/status/", "status_code": 200, "description": "Pending dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_pending.json" + "file": "json/mock_data/dashboard-data/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/dashboard-data/{task_id}/status/", "status_code": 200, "description": "Dashboard task in progress", - "file": "json/mock_data/dashboard-data/status/get_200_progress.json" + "file": "json/mock_data/dashboard-data/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/dashboard-data/{task_id}/status/", "status_code": 200, "description": "Successful dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_success.json" + "file": "json/mock_data/dashboard-data/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/dashboard-data/{task_id}/status/", "status_code": 200, "description": "Failed dashboard task", - "file": "json/mock_data/dashboard-data/status/get_200_failure.json" + "file": "json/mock_data/dashboard-data/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/fertilization/recommend/", "status_code": 202, "description": "Fertilization task queued", - "file": "json/mock_data/fertilization/recommend/post_202.json" + "file": "json/mock_data/fertilization/recommend/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/fertilization/recommend/", "status_code": 400, "description": "Validation error", - "file": "json/mock_data/fertilization/recommend/post_400.json" + "file": "json/mock_data/fertilization/recommend/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/fertilization/recommend/{task_id}/status/", "status_code": 200, "description": "Fertilization status pending", - "file": "json/mock_data/fertilization/status/get_200_pending.json" + "file": "json/mock_data/fertilization/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/fertilization/recommend/{task_id}/status/", "status_code": 200, "description": "Fertilization status progress", - "file": "json/mock_data/fertilization/status/get_200_progress.json" + "file": "json/mock_data/fertilization/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/fertilization/recommend/{task_id}/status/", "status_code": 200, "description": "Fertilization status success", - "file": "json/mock_data/fertilization/status/get_200_success.json" + "file": "json/mock_data/fertilization/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/fertilization/recommend/{task_id}/status/", "status_code": 200, "description": "Fertilization status failure", - "file": "json/mock_data/fertilization/status/get_200_failure.json" + "file": "json/mock_data/fertilization/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/", "status_code": 200, "description": "List irrigation methods", - "file": "json/mock_data/irrigation/methods/get_200.json" + "file": "json/mock_data/irrigation/methods/get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/irrigation/", "status_code": 201, "description": "Create irrigation method", - "file": "json/mock_data/irrigation/methods/post_201.json" + "file": "json/mock_data/irrigation/methods/post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/irrigation/", "status_code": 400, "description": "Irrigation create validation error", - "file": "json/mock_data/irrigation/methods/post_400.json" + "file": "json/mock_data/irrigation/methods/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/irrigation/recommend/", "status_code": 202, "description": "Irrigation recommendation task queued", - "file": "json/mock_data/irrigation/recommend/post_202.json" + "file": "json/mock_data/irrigation/recommend/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/irrigation/recommend/", "status_code": 400, "description": "Irrigation recommendation validation error", - "file": "json/mock_data/irrigation/recommend/post_400.json" + "file": "json/mock_data/irrigation/recommend/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/recommend/{task_id}/status/", "status_code": 200, "description": "Irrigation recommendation status pending", - "file": "json/mock_data/irrigation/recommend/status/get_200_pending.json" + "file": "json/mock_data/irrigation/recommend/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/recommend/{task_id}/status/", "status_code": 200, "description": "Irrigation recommendation status progress", - "file": "json/mock_data/irrigation/recommend/status/get_200_progress.json" + "file": "json/mock_data/irrigation/recommend/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/recommend/{task_id}/status/", "status_code": 200, "description": "Irrigation recommendation status success", - "file": "json/mock_data/irrigation/recommend/status/get_200_success.json" + "file": "json/mock_data/irrigation/recommend/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/recommend/{task_id}/status/", "status_code": 200, "description": "Irrigation recommendation status failure", - "file": "json/mock_data/irrigation/recommend/status/get_200_failure.json" + "file": "json/mock_data/irrigation/recommend/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/irrigation/{pk}/", "status_code": 200, "description": "Irrigation method get success", - "file": "json/mock_data/irrigation/method-detail/get_200.json" + "file": "json/mock_data/irrigation/method-detail/get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/irrigation/{pk}/", "status_code": 404, "description": "Irrigation method get not found", - "file": "json/mock_data/irrigation/method-detail/get_404.json" + "file": "json/mock_data/irrigation/method-detail/get_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/irrigation/{pk}/", "status_code": 200, "description": "Irrigation method put success", - "file": "json/mock_data/irrigation/method-detail/put_200.json" + "file": "json/mock_data/irrigation/method-detail/put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/irrigation/{pk}/", "status_code": 400, "description": "Irrigation method put validation error", - "file": "json/mock_data/irrigation/method-detail/put_400.json" + "file": "json/mock_data/irrigation/method-detail/put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/irrigation/{pk}/", "status_code": 404, "description": "Irrigation method put not found", - "file": "json/mock_data/irrigation/method-detail/put_404.json" + "file": "json/mock_data/irrigation/method-detail/put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/irrigation/{pk}/", "status_code": 200, "description": "Irrigation method patch success", - "file": "json/mock_data/irrigation/method-detail/patch_200.json" + "file": "json/mock_data/irrigation/method-detail/patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/irrigation/{pk}/", "status_code": 400, "description": "Irrigation method patch validation error", - "file": "json/mock_data/irrigation/method-detail/patch_400.json" + "file": "json/mock_data/irrigation/method-detail/patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/irrigation/{pk}/", "status_code": 404, "description": "Irrigation method patch not found", - "file": "json/mock_data/irrigation/method-detail/patch_404.json" + "file": "json/mock_data/irrigation/method-detail/patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "DELETE", "path": "/api/irrigation/{pk}/", "status_code": 200, "description": "Delete irrigation method", - "file": "json/mock_data/irrigation/method-detail/delete_200.json" + "file": "json/mock_data/irrigation/method-detail/delete_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "DELETE", "path": "/api/irrigation/{pk}/", "status_code": 404, "description": "Delete irrigation method not found", - "file": "json/mock_data/irrigation/method-detail/delete_404.json" + "file": "json/mock_data/irrigation/method-detail/delete_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/", "status_code": 200, "description": "Soil data served from database", - "file": "json/mock_data/soil-data/get_200_database.json" + "file": "json/mock_data/soil-data/get_200_database.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/", "status_code": 202, "description": "Soil data fetch task queued", - "file": "json/mock_data/soil-data/get_202_queued.json" + "file": "json/mock_data/soil-data/get_202_queued.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/", "status_code": 400, "description": "Soil data validation error", - "file": "json/mock_data/soil-data/get_400.json" + "file": "json/mock_data/soil-data/get_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/soil-data/", "status_code": 200, "description": "Soil data POST served from database", - "file": "json/mock_data/soil-data/post_200_database.json" + "file": "json/mock_data/soil-data/post_200_database.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/soil-data/", "status_code": 202, "description": "Soil data POST task queued", - "file": "json/mock_data/soil-data/post_202_queued.json" + "file": "json/mock_data/soil-data/post_202_queued.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/soil-data/", "status_code": 400, "description": "Soil data POST validation error", - "file": "json/mock_data/soil-data/post_400.json" + "file": "json/mock_data/soil-data/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/tasks/{task_id}/status/", "status_code": 200, "description": "Soil task status pending", - "file": "json/mock_data/soil-data/status/get_200_pending.json" + "file": "json/mock_data/soil-data/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/tasks/{task_id}/status/", "status_code": 200, "description": "Soil task status progress", - "file": "json/mock_data/soil-data/status/get_200_progress.json" + "file": "json/mock_data/soil-data/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/tasks/{task_id}/status/", "status_code": 200, "description": "Soil task status success", - "file": "json/mock_data/soil-data/status/get_200_success.json" + "file": "json/mock_data/soil-data/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/soil-data/tasks/{task_id}/status/", "status_code": 200, "description": "Soil task status failure", - "file": "json/mock_data/soil-data/status/get_200_failure.json" + "file": "json/mock_data/soil-data/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/plants/", "status_code": 200, "description": "List plants", - "file": "json/mock_data/plant/list-get_200.json" + "file": "json/mock_data/plant/list-get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/plants/", "status_code": 201, "description": "Create plant", - "file": "json/mock_data/plant/create-post_201.json" + "file": "json/mock_data/plant/create-post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/plants/", "status_code": 400, "description": "Plant create validation error", - "file": "json/mock_data/plant/create-post_400.json" + "file": "json/mock_data/plant/create-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/plants/{pk}/", "status_code": 200, "description": "Plant detail get success", - "file": "json/mock_data/plant/detail-get_200.json" + "file": "json/mock_data/plant/detail-get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/plants/{pk}/", "status_code": 404, "description": "Plant detail get not found", - "file": "json/mock_data/plant/detail-get_404.json" + "file": "json/mock_data/plant/detail-get_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/plants/{pk}/", "status_code": 200, "description": "Plant detail put success", - "file": "json/mock_data/plant/detail-put_200.json" + "file": "json/mock_data/plant/detail-put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/plants/{pk}/", "status_code": 400, "description": "Plant detail put validation error", - "file": "json/mock_data/plant/detail-put_400.json" + "file": "json/mock_data/plant/detail-put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PUT", "path": "/api/plants/{pk}/", "status_code": 404, "description": "Plant detail put not found", - "file": "json/mock_data/plant/detail-put_404.json" + "file": "json/mock_data/plant/detail-put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/plants/{pk}/", "status_code": 200, "description": "Plant detail patch success", - "file": "json/mock_data/plant/detail-patch_200.json" + "file": "json/mock_data/plant/detail-patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/plants/{pk}/", "status_code": 400, "description": "Plant detail patch validation error", - "file": "json/mock_data/plant/detail-patch_400.json" + "file": "json/mock_data/plant/detail-patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "PATCH", "path": "/api/plants/{pk}/", "status_code": 404, "description": "Plant detail patch not found", - "file": "json/mock_data/plant/detail-patch_404.json" + "file": "json/mock_data/plant/detail-patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "DELETE", "path": "/api/plants/{pk}/", "status_code": 200, "description": "Delete plant success", - "file": "json/mock_data/plant/detail-delete_200.json" + "file": "json/mock_data/plant/detail-delete_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "DELETE", "path": "/api/plants/{pk}/", "status_code": 404, "description": "Delete plant not found", - "file": "json/mock_data/plant/detail-delete_404.json" + "file": "json/mock_data/plant/detail-delete_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/plants/fetch-info/", "status_code": 200, "description": "Fetch plant info success", - "file": "json/mock_data/plant/fetch-info-post_200.json" + "file": "json/mock_data/plant/fetch-info-post_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/plants/fetch-info/", "status_code": 400, "description": "Fetch plant info missing name", - "file": "json/mock_data/plant/fetch-info-post_400.json" + "file": "json/mock_data/plant/fetch-info-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/plants/fetch-info/", "status_code": 503, "description": "Fetch plant info service unavailable", - "file": "json/mock_data/plant/fetch-info-post_503.json" + "file": "json/mock_data/plant/fetch-info-post_503.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/rag/chat/", "status_code": 200, "description": "RAG chat streaming response", - "file": "json/mock_data/rag/chat-post_200_stream.json" + "file": "json/mock_data/rag/chat-post_200_stream.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/rag/chat/", "status_code": 400, "description": "Missing query", - "file": "json/mock_data/rag/chat-post_400_missing_query.json" + "file": "json/mock_data/rag/chat-post_400_missing_query.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/rag/chat/", "status_code": 400, "description": "Invalid service id", - "file": "json/mock_data/rag/chat-post_400_invalid_service.json" + "file": "json/mock_data/rag/chat-post_400_invalid_service.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/rag/chat/", "status_code": 400, "description": "Missing user_id for service", - "file": "json/mock_data/rag/chat-post_400_missing_user.json" + "file": "json/mock_data/rag/chat-post_400_missing_user.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "POST", "path": "/api/rag/recommend/irrigation/", "status_code": 202, "description": "RAG irrigation task queued", - "file": "json/mock_data/rag/irrigation/post_202.json" + "file": "json/mock_data/rag/irrigation/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/rag/recommend/irrigation/", "status_code": 400, "description": "RAG irrigation validation error", - "file": "json/mock_data/rag/irrigation/post_400.json" + "file": "json/mock_data/rag/irrigation/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/irrigation/{task_id}/status/", "status_code": 200, "description": "RAG irrigation status pending", - "file": "json/mock_data/rag/irrigation/status/get_200_pending.json" + "file": "json/mock_data/rag/irrigation/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/irrigation/{task_id}/status/", "status_code": 200, "description": "RAG irrigation status progress", - "file": "json/mock_data/rag/irrigation/status/get_200_progress.json" + "file": "json/mock_data/rag/irrigation/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/irrigation/{task_id}/status/", "status_code": 200, "description": "RAG irrigation status success", - "file": "json/mock_data/rag/irrigation/status/get_200_success.json" + "file": "json/mock_data/rag/irrigation/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/irrigation/{task_id}/status/", "status_code": 200, "description": "RAG irrigation status failure", - "file": "json/mock_data/rag/irrigation/status/get_200_failure.json" + "file": "json/mock_data/rag/irrigation/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/rag/recommend/fertilization/", "status_code": 202, "description": "RAG fertilization task queued", - "file": "json/mock_data/rag/fertilization/post_202.json" + "file": "json/mock_data/rag/fertilization/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/rag/recommend/fertilization/", "status_code": 400, "description": "RAG fertilization validation error", - "file": "json/mock_data/rag/fertilization/post_400.json" + "file": "json/mock_data/rag/fertilization/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/fertilization/{task_id}/status/", "status_code": 200, "description": "RAG fertilization status pending", - "file": "json/mock_data/rag/fertilization/status/get_200_pending.json" + "file": "json/mock_data/rag/fertilization/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/fertilization/{task_id}/status/", "status_code": 200, "description": "RAG fertilization status progress", - "file": "json/mock_data/rag/fertilization/status/get_200_progress.json" + "file": "json/mock_data/rag/fertilization/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/fertilization/{task_id}/status/", "status_code": 200, "description": "RAG fertilization status success", - "file": "json/mock_data/rag/fertilization/status/get_200_success.json" + "file": "json/mock_data/rag/fertilization/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/rag/recommend/fertilization/{task_id}/status/", "status_code": 200, "description": "RAG fertilization status failure", - "file": "json/mock_data/rag/fertilization/status/get_200_failure.json" + "file": "json/mock_data/rag/fertilization/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PUT", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update put success", - "file": "json/mock_data/sensor-data/update-put_200.json" + "file": "json/mock_data/sensor-data/update-put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PUT", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update put validation error", - "file": "json/mock_data/sensor-data/update-put_400.json" + "file": "json/mock_data/sensor-data/update-put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PUT", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update put location not found", - "file": "json/mock_data/sensor-data/update-put_404.json" + "file": "json/mock_data/sensor-data/update-put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PATCH", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 200, "description": "Sensor update patch success", - "file": "json/mock_data/sensor-data/update-patch_200.json" + "file": "json/mock_data/sensor-data/update-patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PATCH", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 400, "description": "Sensor update patch validation error", - "file": "json/mock_data/sensor-data/update-patch_400.json" + "file": "json/mock_data/sensor-data/update-patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "PATCH", "path": "/api/sensor-data/{farm_uuid}/", "status_code": 404, "description": "Sensor update patch location not found", - "file": "json/mock_data/sensor-data/update-patch_404.json" + "file": "json/mock_data/sensor-data/update-patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/sensor-data/parameters/", "status_code": 201, "description": "Create sensor parameter", - "file": "json/mock_data/sensor-data/parameters-post_201.json" + "file": "json/mock_data/sensor-data/parameters-post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/sensor-data/parameters/", "status_code": 400, "description": "Sensor parameter validation error", - "file": "json/mock_data/sensor-data/parameters-post_400.json" + "file": "json/mock_data/sensor-data/parameters-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "POST", "path": "/api/tasks/", "status_code": 200, "description": "Task trigger success", - "file": "json/mock_data/tasks/post_200.json" + "file": "json/mock_data/tasks/post_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/tasks/{task_id}/status/", "status_code": 200, "description": "Task status pending", - "file": "json/mock_data/tasks/status/get_200_pending.json" + "file": "json/mock_data/tasks/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/tasks/{task_id}/status/", "status_code": 200, "description": "Task status progress", - "file": "json/mock_data/tasks/status/get_200_progress.json" + "file": "json/mock_data/tasks/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/tasks/{task_id}/status/", "status_code": 200, "description": "Task status success", - "file": "json/mock_data/tasks/status/get_200_success.json" + "file": "json/mock_data/tasks/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/tasks/{task_id}/status/", "status_code": 200, "description": "Task status failure", - "file": "json/mock_data/tasks/status/get_200_failure.json" + "file": "json/mock_data/tasks/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." }, { "method": "GET", "path": "/api/pest-detection/risk-summary/", "status_code": 200, "description": "Pest and disease risk summary success", - "file": "json/ai/pest-detection/risk-summary/get_200_success.json" + "file": "json/ai/pest-detection/risk-summary/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/weather-forecast/card/", "status_code": 200, "description": "Farm weather card data", - "file": "json/ai/weather-forecast/card/get_200_success.json" + "file": "json/ai/weather-forecast/card/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." }, { "method": "GET", "path": "/api/yield-harvest/summary/", "status_code": 200, "description": "Yield prediction card, chart and harvest prediction card", - "file": "json/ai/yield-harvest/summary/get_200_success.json" + "file": "json/ai/yield-harvest/summary/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." } -] \ No newline at end of file +] diff --git a/farm_ai_assistant/defaults.py b/farm_ai_assistant/defaults.py new file mode 100644 index 0000000..ae79129 --- /dev/null +++ b/farm_ai_assistant/defaults.py @@ -0,0 +1,9 @@ +CONTEXT_RESPONSE_TEMPLATE = { + "soilType": None, + "waterEC": None, + "selectedCrop": None, + "growthStage": None, + "lastIrrigationStatus": None, + "status": "success", + "source": "default_template", +} diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index a2d2a24..00048ae 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -18,7 +18,7 @@ from config.swagger import status_response from external_api_adapter import request as external_api_request from external_api_adapter.exceptions import ExternalAPIRequestError from farm_hub.models import FarmHub -from .mock_data import CONTEXT_RESPONSE_DATA +from .defaults import CONTEXT_RESPONSE_TEMPLATE from .models import Conversation, Message from .serializers import ( ChatPostSerializer, @@ -66,7 +66,7 @@ class ContextView(FarmAccessMixin, APIView): ) def get(self, request): farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) - data = deepcopy(CONTEXT_RESPONSE_DATA) + data = deepcopy(CONTEXT_RESPONSE_TEMPLATE) data["farm_uuid"] = self._farm_uuid_or_none(farm) return Response( {"status": "success", "data": data}, diff --git a/farm_alerts/defaults.py b/farm_alerts/defaults.py new file mode 100644 index 0000000..48d2343 --- /dev/null +++ b/farm_alerts/defaults.py @@ -0,0 +1,29 @@ +EMPTY_ALERT_TRACKER = { + "totalAlerts": 0, + "radialBarValue": 0, + "alertStats": [], + "status": "empty", + "source": "db", + "warnings": ["No active farm alerts were found."], +} + +EMPTY_ALERT_TIMELINE = { + "alerts": [], + "status": "empty", + "source": "db", + "warnings": ["No farm alert timeline entries were found."], +} + +EMPTY_ANOMALY_CARD = { + "anomalies": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted anomaly detections were found."], +} + +EMPTY_RECOMMENDATIONS = { + "recommendations": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted farm recommendations were found."], +} diff --git a/farm_alerts/services.py b/farm_alerts/services.py index 83520b2..0ee4fbc 100644 --- a/farm_alerts/services.py +++ b/farm_alerts/services.py @@ -10,12 +10,7 @@ from farm_hub.models import FarmHub from notifications.models import FarmNotification from notifications.services import create_notification_for_farm_uuid, get_recent_notifications_for_farm -from .mock_data import ( - ANOMALY_DETECTION_CARD, - ARM_ALERTS_TRACKER, - FARM_ALERTS_TIMELINE, - RECOMMENDATIONS_LIST, -) +from .defaults import EMPTY_ALERT_TIMELINE, EMPTY_ALERT_TRACKER, EMPTY_ANOMALY_CARD, EMPTY_RECOMMENDATIONS from .models import AnomalyDetection, FarmAlert, FarmAlertTrackerSnapshot, Recommendation @@ -383,11 +378,11 @@ def sync_all_farm_alert_trackers(): return {"processed": len(results), "results": results} def get_alert_tracker_data(farm=None): if farm is None: - return deepcopy(ARM_ALERTS_TRACKER) + return deepcopy(EMPTY_ALERT_TRACKER) alerts = list(FarmAlert.objects.filter(farm=farm, is_active=True)[:20]) if not alerts: - return deepcopy(ARM_ALERTS_TRACKER) + return deepcopy(EMPTY_ALERT_TRACKER) counts = Counter(alert.title for alert in alerts) alert_stats = [] @@ -406,16 +401,19 @@ def get_alert_tracker_data(farm=None): "totalAlerts": len(alerts), "radialBarValue": min(len(alerts) * 10, 100), "alertStats": alert_stats, + "status": "success", + "source": "db", + "warnings": [], } def get_alert_timeline_data(farm=None): if farm is None: - return deepcopy(FARM_ALERTS_TIMELINE) + return deepcopy(EMPTY_ALERT_TIMELINE) alerts = list(FarmAlert.objects.filter(farm=farm)[:10]) if not alerts: - return deepcopy(FARM_ALERTS_TIMELINE) + return deepcopy(EMPTY_ALERT_TIMELINE) return { "alerts": [ @@ -426,17 +424,20 @@ def get_alert_timeline_data(farm=None): "color": alert.color, } for alert in alerts - ] + ], + "status": "success", + "source": "db", + "warnings": [], } def get_anomaly_detection_data(farm=None): if farm is None: - return deepcopy(ANOMALY_DETECTION_CARD) + return deepcopy(EMPTY_ANOMALY_CARD) anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10]) if not anomalies: - return deepcopy(ANOMALY_DETECTION_CARD) + return deepcopy(EMPTY_ANOMALY_CARD) return { "anomalies": [ @@ -448,17 +449,20 @@ def get_anomaly_detection_data(farm=None): "severity": anomaly.severity, } for anomaly in anomalies - ] + ], + "status": "success", + "source": "db", + "warnings": [], } def get_recommendations_list_data(farm=None): if farm is None: - return deepcopy(RECOMMENDATIONS_LIST) + return deepcopy(EMPTY_RECOMMENDATIONS) recommendations = list(Recommendation.objects.filter(farm=farm)[:10]) if not recommendations: - return deepcopy(RECOMMENDATIONS_LIST) + return deepcopy(EMPTY_RECOMMENDATIONS) return { "recommendations": [ @@ -469,5 +473,8 @@ def get_recommendations_list_data(farm=None): "avatarColor": recommendation.avatar_color or "info", } for recommendation in recommendations - ] + ], + "status": "success", + "source": "db", + "warnings": [], } diff --git a/farm_alerts/tests.py b/farm_alerts/tests.py index bb0daf3..a4cc1ec 100644 --- a/farm_alerts/tests.py +++ b/farm_alerts/tests.py @@ -89,6 +89,9 @@ class FarmAlertsTrackerViewTests(TestCase): self.assertEqual(response.data["data"]["status_level"], "warning") self.assertEqual(len(response.data["data"]["notifications"]), 1) self.assertEqual(response.data["data"]["notifications"][0]["endpoint"], "tracker") + self.assertEqual(response.data["meta"]["flow_type"], "cached_snapshot") + self.assertTrue(response.data["meta"]["cached"]) + self.assertEqual(response.data["meta"]["ownership"], "backend") def test_tracker_limits_cached_notifications_to_ten(self): for index in range(12): diff --git a/farm_alerts/views.py b/farm_alerts/views.py index 39ed222..56cac81 100644 --- a/farm_alerts/views.py +++ b/farm_alerts/views.py @@ -6,6 +6,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from config.integration_contract import build_integration_meta from config.swagger import code_response from farm_hub.models import FarmHub @@ -61,4 +62,22 @@ class AlertTrackerView(FarmAlertsBaseView): response_data, ) serializer = AlertTrackerAIResponseSerializer(instance=response_data) - return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK) + snapshot = getattr(farm, "alert_tracker_snapshot", None) + return Response( + { + "code": 200, + "msg": "success", + "data": serializer.data, + "meta": build_integration_meta( + flow_type="cached_snapshot", + source_type="cached_snapshot", + source_service="backend_farm_alerts_snapshot", + ownership="backend", + live=False, + cached=True, + snapshot_at=getattr(snapshot, "updated_at", None), + notes=["Returns persisted tracker snapshot, not live AI inference."], + ), + }, + status=status.HTTP_200_OK, + ) diff --git a/fertilization/defaults.py b/fertilization/defaults.py new file mode 100644 index 0000000..138e1fb --- /dev/null +++ b/fertilization/defaults.py @@ -0,0 +1,35 @@ +CONFIG_RESPONSE_TEMPLATE = { + "farmData": { + "soilType": None, + "organicMatter": None, + "waterEC": None, + }, + "growthStages": [ + {"id": "prePlanting", "icon": "tabler-seedling"}, + {"id": "earlyGrowth", "icon": "tabler-leaf"}, + {"id": "flowering", "icon": "tabler-flower"}, + {"id": "fruiting", "icon": "tabler-apple"}, + {"id": "postHarvest", "icon": "tabler-basket"}, + ], + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], + "status": "success", + "source": "default_template", +} + + +FERTILIZATION_DASHBOARD_TEMPLATE = { + "title": "کود", + "subtitle": "داده توصیه کودهی هنوز ثبت نشده است.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", + "status": "empty", + "source": "db", + "warnings": ["No persisted fertilization recommendation is available for this farm."], +} diff --git a/fertilization/services.py b/fertilization/services.py index 49b55bb..0b89e53 100644 --- a/fertilization/services.py +++ b/fertilization/services.py @@ -1,6 +1,6 @@ from copy import deepcopy -from .mock_data import FERTILIZATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA +from .defaults import FERTILIZATION_DASHBOARD_TEMPLATE from .models import FertilizationPlan, FertilizationRecommendationRequest @@ -77,9 +77,11 @@ def build_active_plan_context(farm): def get_fertilization_dashboard_recommendation(farm=None): - default_item = deepcopy(FERTILIZATION_DASHBOARD_RECOMMENDATION) + default_item = deepcopy(FERTILIZATION_DASHBOARD_TEMPLATE) result = _get_latest_result(farm) - plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {}) + plan = result.get("plan") or {} + if not isinstance(plan, dict) or not plan: + return default_item npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)" amount = plan.get("amountPerHectare") @@ -91,5 +93,8 @@ def get_fertilization_dashboard_recommendation(farm=None): default_item["title"] = f"کود: {npk_ratio}" if subtitle_parts: default_item["subtitle"] = "، ".join(subtitle_parts) + default_item["status"] = "success" + default_item["source"] = "db" + default_item["warnings"] = [] return default_item diff --git a/fertilization/views.py b/fertilization/views.py index eb8dff0..3098b12 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -18,7 +18,7 @@ from farm_hub.models import FarmHub from farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events from .models import FertilizationPlan, FertilizationRecommendationRequest from .services import build_active_plan_context -from .mock_data import CONFIG_RESPONSE_DATA +from .defaults import CONFIG_RESPONSE_TEMPLATE from .serializers import ( FreeTextPlanParserRequestSerializer, FreeTextPlanParserResponseDataSerializer, @@ -81,7 +81,7 @@ class ConfigView(FarmAccessMixin, APIView): ) def get(self, request): farm = self._get_farm(request, request.query_params.get("farm_uuid")) - data = dict(CONFIG_RESPONSE_DATA) + data = dict(CONFIG_RESPONSE_TEMPLATE) data["farm_uuid"] = str(farm.farm_uuid) return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/irrigation/defaults.py b/irrigation/defaults.py new file mode 100644 index 0000000..c98d32d --- /dev/null +++ b/irrigation/defaults.py @@ -0,0 +1,28 @@ +CONFIG_RESPONSE_TEMPLATE = { + "farmInfo": { + "soilType": None, + "waterQuality": None, + "climateZone": None, + }, + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], + "status": "success", + "source": "default_template", +} + + +IRRIGATION_DASHBOARD_TEMPLATE = { + "title": "آبیاری", + "subtitle": "داده توصیه آبیاری هنوز ثبت نشده است.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation recommendation is available for this farm."], +} diff --git a/irrigation/services.py b/irrigation/services.py index c01d8fd..79d4ca5 100644 --- a/irrigation/services.py +++ b/irrigation/services.py @@ -1,12 +1,30 @@ from copy import deepcopy +import logging -from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION +from config.failure_contract import StructuredServiceError + +from .defaults import IRRIGATION_DASHBOARD_TEMPLATE from .models import IrrigationPlan, IrrigationRecommendationRequest +logger = logging.getLogger(__name__) + + +class IrrigationDataUnavailableError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, details: dict | None = None): + super().__init__( + error_code=error_code, + message=message, + source="db", + details=details, + ) + def _extract_result(response_payload): if not isinstance(response_payload, dict): - return {} + raise IrrigationDataUnavailableError( + error_code="invalid_payload", + message="Irrigation recommendation payload must be a JSON object.", + ) data = response_payload.get("data") if isinstance(data, dict): @@ -22,24 +40,47 @@ def _extract_result(response_payload): if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")): return response_payload - return {} + return None def _get_latest_result(farm): if farm is None: - return {} + raise IrrigationDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for irrigation result lookup.", + ) for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"): - result = _extract_result(request.response_payload) + try: + result = _extract_result(request.response_payload) + except IrrigationDataUnavailableError as exc: + logger.error( + "Invalid irrigation response payload for farm_id=%s request_id=%s: %s", + getattr(farm, "id", None), + request.id, + exc, + ) + raise IrrigationDataUnavailableError( + error_code=exc.contract.error_code, + message=f"Invalid irrigation recommendation payload for request_id={request.id}.", + details={"farm_id": getattr(farm, "id", None), "request_id": request.id}, + ) from exc if result: return result - return {} + raise IrrigationDataUnavailableError( + error_code="no_data", + message=f"No irrigation recommendation result found for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) def get_active_plan_payload(farm): if farm is None: - return {} + raise IrrigationDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for active irrigation plan lookup.", + ) plan = ( IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False) @@ -47,15 +88,17 @@ def get_active_plan_payload(farm): .first() ) if plan is None or not isinstance(plan.plan_payload, dict): - return {} + raise IrrigationDataUnavailableError( + error_code="no_active_plan", + message=f"No active irrigation plan payload found for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) return deepcopy(plan.plan_payload) def build_active_plan_context(farm): plan_payload = get_active_plan_payload(farm) - if not plan_payload: - return {} context = {"plan_payload": plan_payload} @@ -200,24 +243,37 @@ def _normalize_sections(raw_sections): def build_recommendation_response(adapter_payload): result = _extract_result(adapter_payload) - fallback_plan = RECOMMEND_RESPONSE_DATA.get("plan", {}) + if not isinstance(result, dict): + raise IrrigationDataUnavailableError( + error_code="no_result", + message="Irrigation recommendation payload did not include a result object.", + ) + if not isinstance(result.get("plan"), dict): + raise IrrigationDataUnavailableError( + error_code="invalid_payload", + message="Irrigation recommendation payload is missing a valid `plan` object.", + ) - return { - "plan": _normalize_plan(result.get("plan") or fallback_plan), + response = { + "plan": _normalize_plan(result.get("plan")), "water_balance": _normalize_water_balance(result.get("water_balance")), "timeline": _normalize_timeline(result.get("timeline")), "sections": _normalize_sections(result.get("sections")), } + return response def get_water_need_prediction_data(farm=None): - default_data = deepcopy(WATER_NEED_PREDICTION) result = _get_latest_result(farm) water_balance = result.get("water_balance", {}) daily = water_balance.get("daily", []) if not daily: - return default_data + raise IrrigationDataUnavailableError( + error_code="empty_daily_data", + message=f"Water need prediction data is missing daily entries for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)] series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily] @@ -231,9 +287,19 @@ def get_water_need_prediction_data(farm=None): def get_irrigation_dashboard_recommendation(farm=None): - default_item = deepcopy(IRRIGATION_DASHBOARD_RECOMMENDATION) - result = _get_latest_result(farm) - plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {}) + default_item = deepcopy(IRRIGATION_DASHBOARD_TEMPLATE) + try: + result = _get_latest_result(farm) + except IrrigationDataUnavailableError as exc: + logger.info( + "Irrigation dashboard recommendation unavailable for farm_id=%s: %s", + getattr(farm, "id", None), + exc, + ) + return default_item + plan = result.get("plan") + if not isinstance(plan, dict): + return default_item best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00" frequency = plan.get("frequencyPerWeek") @@ -252,5 +318,8 @@ def get_irrigation_dashboard_recommendation(farm=None): default_item["title"] = f"آبیاری: {best_time}" if subtitle_parts: default_item["subtitle"] = ". ".join(subtitle_parts) + default_item["status"] = "success" + default_item["source"] = "db" + default_item["warnings"] = [] return default_item diff --git a/irrigation/tests.py b/irrigation/tests.py index b157680..26f6010 100644 --- a/irrigation/tests.py +++ b/irrigation/tests.py @@ -9,6 +9,7 @@ from farm_hub.models import FarmHub, FarmType from farmer_calendar.models import FarmerCalendarEvent from .models import IrrigationPlan, IrrigationRecommendationRequest +from .services import IrrigationDataUnavailableError, build_recommendation_response from .views import ( IrrigationMethodListView, IrrigationPlanDetailView, @@ -22,6 +23,37 @@ from .views import ( ) +class IrrigationServiceFailureTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="irrigation-service-user", + password="secret123", + email="irrigation-service@example.com", + phone_number="09120000009", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Service Farm") + + def test_get_water_need_prediction_raises_structured_error_for_missing_daily_entries(self): + IrrigationRecommendationRequest.objects.create( + farm=self.farm, + response_payload={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}}, + ) + + from .services import get_water_need_prediction_data + + with self.assertRaises(IrrigationDataUnavailableError) as exc_info: + get_water_need_prediction_data(self.farm) + + self.assertEqual(exc_info.exception.contract.error_code, "empty_daily_data") + + def test_build_recommendation_response_rejects_non_object_payload(self): + with self.assertRaises(IrrigationDataUnavailableError) as exc_info: + build_recommendation_response(["not-a-dict"]) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_payload") + + class WaterStressViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() @@ -72,6 +104,8 @@ class WaterStressViewTests(TestCase): self.assertEqual(response.data["data"]["waterStressIndex"], 12) self.assertEqual(response.data["data"]["level"], "پایین") self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24}) + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation_water_stress") mock_external_api_request.assert_called_once_with( "ai", "/api/irrigation/water-stress/", @@ -93,6 +127,27 @@ class WaterStressViewTests(TestCase): self.assertEqual(response.data["code"], 404) self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + @patch("irrigation.views.external_api_request") + def test_post_returns_upstream_failure_without_masking_as_empty(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=503, + data={"message": "AI unavailable", "status": "error"}, + ) + + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["data"]["message"], "AI unavailable") + self.assertNotEqual(response.data.get("data"), []) + self.assertNotEqual(response.data.get("data"), {}) + class IrrigationPlanFromTextViewTests(TestCase): def setUp(self): @@ -136,6 +191,8 @@ class IrrigationPlanFromTextViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["data"]["status"], "completed") + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertEqual(response.data["meta"]["ownership"], "backend") self.assertEqual(IrrigationPlan.objects.count(), 1) plan = IrrigationPlan.objects.get() self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT) @@ -184,6 +241,8 @@ class IrrigationMethodListViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["code"], 200) self.assertEqual(response.data["data"][0]["name"], "Drip") + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertTrue(response.data["meta"]["live"]) mock_external_api_request.assert_called_once_with( "ai", "/api/irrigation/", @@ -208,6 +267,7 @@ class IrrigationMethodListViewTests(TestCase): self.assertEqual(response.status_code, 201) self.assertEqual(response.data["data"]["name"], "Drip") + self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation") mock_external_api_request.assert_called_once_with( "ai", "/api/irrigation/", @@ -319,6 +379,30 @@ class RecommendViewTests(TestCase): self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93) self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1) self.assertEqual(response.data["data"]["sections"][0]["type"], "warning") + + @patch("irrigation.views.external_api_request") + def test_recommend_view_persists_real_response_and_never_returns_fake_success_on_invalid_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}}, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 502) + self.assertEqual(IrrigationRecommendationRequest.objects.count(), 1) + self.assertEqual(IrrigationRecommendationRequest.objects.get().status, IrrigationRecommendationRequest.STATUS_ERROR) mock_external_api_request.assert_called_once_with( "ai", "/api/irrigation/recommend/", diff --git a/irrigation/views.py b/irrigation/views.py index ff1137d..ecff911 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -12,13 +12,14 @@ from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema +from config.integration_contract import build_integration_meta from config.swagger import code_response, status_response from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events from water.serializers import WaterStressIndexSerializer from water.views import WaterStressIndexView -from .mock_data import CONFIG_RESPONSE_DATA +from .defaults import CONFIG_RESPONSE_TEMPLATE from .models import IrrigationPlan, IrrigationRecommendationRequest from .serializers import ( FreeTextPlanParserRequestSerializer, @@ -36,6 +37,7 @@ from .serializers import ( ) from .services import build_recommendation_response from .services import build_active_plan_context +from .services import IrrigationDataUnavailableError logger = logging.getLogger(__name__) @@ -86,7 +88,7 @@ class ConfigView(FarmAccessMixin, APIView): ) def get(self, request): farm = self._get_farm(request, request.query_params.get("farm_uuid")) - data = dict(CONFIG_RESPONSE_DATA) + data = dict(CONFIG_RESPONSE_TEMPLATE) data["farm_uuid"] = str(farm.farm_uuid) return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) @@ -128,7 +130,19 @@ class IrrigationMethodListView(APIView): ) return Response( - {"code": 200, "msg": "success", "data": self._extract_methods(adapter_response.data)}, + { + "code": 200, + "msg": "success", + "data": self._extract_methods(adapter_response.data), + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation", + ownership="ai", + live=True, + cached=False, + ), + }, status=status.HTTP_200_OK, ) @@ -157,7 +171,19 @@ class IrrigationMethodListView(APIView): payload = response_data.get("data", response_data) return Response( - {"code": adapter_response.status_code, "msg": "success", "data": payload}, + { + "code": adapter_response.status_code, + "msg": "success", + "data": payload, + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation", + ownership="ai", + live=True, + cached=False, + ), + }, status=adapter_response.status_code, ) @@ -188,7 +214,10 @@ class RecommendView(FarmAccessMixin, APIView): @staticmethod def _enrich_ai_payload(payload, farm): enriched_payload = payload.copy() - active_plan_context = build_active_plan_context(farm) + try: + active_plan_context = build_active_plan_context(farm) + except IrrigationDataUnavailableError: + active_plan_context = None if active_plan_context: enriched_payload["active_irrigation_plan"] = active_plan_context return enriched_payload @@ -224,16 +253,6 @@ class RecommendView(FarmAccessMixin, APIView): ) response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} - recommendation_data = build_recommendation_response(response_data) - - logger.warning( - "Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", - str(farm.farm_uuid), - adapter_response.status_code, - sorted(response_data.keys()) if isinstance(response_data, dict) else None, - len(recommendation_data["sections"]), - ) - recommendation = IrrigationRecommendationRequest.objects.create( farm=farm, crop_id=payload.get("plant_name", ""), @@ -256,6 +275,23 @@ class RecommendView(FarmAccessMixin, APIView): }, status=adapter_response.status_code, ) + try: + recommendation_data = build_recommendation_response(response_data) + except IrrigationDataUnavailableError as exc: + recommendation.status = IrrigationRecommendationRequest.STATUS_ERROR + recommendation.save(update_fields=["status"]) + return Response( + {"code": 502, "msg": "error", "data": {"detail": str(exc)}}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + logger.warning( + "Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", + str(farm.farm_uuid), + adapter_response.status_code, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(recommendation_data["sections"]), + ) self._create_plan_from_recommendation(recommendation, recommendation_data) @@ -272,6 +308,14 @@ class RecommendView(FarmAccessMixin, APIView): "code": 200, "msg": "success", "data": recommendation_data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="provider", + source_service="ai_irrigation", + ownership="backend", + live=True, + cached=False, + ), }, status=status.HTTP_200_OK, ) @@ -329,7 +373,13 @@ class RecommendationDetailView(FarmAccessMixin, APIView): if recommendation is None: return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND) - data = build_recommendation_response(recommendation.response_payload) + try: + data = build_recommendation_response(recommendation.response_payload) + except IrrigationDataUnavailableError as exc: + return Response( + {"code": 502, "msg": "error", "data": {"detail": str(exc)}}, + status=status.HTTP_502_BAD_GATEWAY, + ) request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {} data["recommendation_uuid"] = str(recommendation.uuid) data["crop_id"] = recommendation.crop_id @@ -338,7 +388,22 @@ class RecommendationDetailView(FarmAccessMixin, APIView): data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "") data["status"] = recommendation.status data["status_label"] = recommendation.get_status_display() - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="backend_irrigation", + ownership="backend", + live=False, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) class WaterStressView(APIView): @@ -392,7 +457,19 @@ class WaterStressView(APIView): stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid) return Response( - {"code": 200, "msg": "success", "data": stress_payload}, + { + "code": 200, + "msg": "success", + "data": stress_payload, + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation_water_stress", + ownership="ai", + live=True, + cached=False, + ), + }, status=status.HTTP_200_OK, ) @@ -471,7 +548,19 @@ class PlanFromTextView(FarmAccessMixin, APIView): sync_plan_events(plan, PLAN_TYPE_IRRIGATION) return Response( - {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, + { + "code": 200, + "msg": response_data.get("msg", "موفق"), + "data": response_data.get("data", response_data), + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation_plan_parser", + ownership="backend" if final_plan and farm_uuid else "ai", + live=True, + cached=False, + ), + }, status=status.HTTP_200_OK, ) diff --git a/plants/tests.py b/plants/tests.py index af70fe3..c7cc62b 100644 --- a/plants/tests.py +++ b/plants/tests.py @@ -3,8 +3,8 @@ from django.test import TestCase from rest_framework.test import APIRequestFactory, force_authenticate from unittest.mock import patch -from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product +from .services import PlantSyncError from .views import PlantListView, PlantNameListView, SelectedPlantListView @@ -19,30 +19,15 @@ class PlantApiTests(TestCase): ) self.farm_type = FarmType.objects.create(name="زراعی") - @patch("plants.services.external_api_request") - def test_list_syncs_plants_from_ai_and_returns_full_payload(self, mock_external_api_request): - mock_external_api_request.return_value = AdapterResponse( - status_code=200, - data={ - "code": 200, - "msg": "success", - "data": [ - { - "name": "Tomato", - "light": "full sun", - "watering": "regular", - "soil": "loam", - "temperature": "20-30", - "growth_stage": "vegetative", - "planting_season": "spring", - "harvest_time": "70-90 days", - "spacing": "45-60 cm", - "fertilizer": "NPK", - "icon": "tomato", - "growth_profile": {"stage_thresholds": {"flowering": 300, "fruiting": 500}}, - } - ], - }, + @patch("plants.views.push_plants_to_ai") + def test_list_returns_backend_catalog_with_sync_metadata(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] + Product.objects.create( + farm_type=self.farm_type, + name="Tomato", + icon="tomato", + growth_stage="vegetative", + growth_profile={"stage_thresholds": {"flowering": 300, "fruiting": 500}}, ) request = self.factory.get("/api/plants/") force_authenticate(request, user=self.user) @@ -54,7 +39,10 @@ class PlantApiTests(TestCase): self.assertEqual(response.data["data"][0]["name"], "Tomato") self.assertEqual(response.data["data"][0]["icon"], "tomato") self.assertIn("flowering", response.data["data"][0]["growth_stages"]) - mock_external_api_request.assert_called_once_with("ai", "/api/plants/", method="GET") + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment") + self.assertEqual(response.data["meta"]["source_type"], "db") + self.assertEqual(response.data["meta"]["sync_status"], "synced") + mock_push_plants_to_ai.assert_called_once() @patch("plants.views.push_plants_to_ai") def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai): @@ -78,6 +66,9 @@ class PlantApiTests(TestCase): product.refresh_from_db() self.assertEqual(product.icon, "leaf") self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"]) + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data") + self.assertEqual(response.data["meta"]["source_type"], "db") + self.assertEqual(response.data["meta"]["sync_status"], "synced") @patch("plants.views.push_plants_to_ai") def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai): @@ -97,3 +88,34 @@ class PlantApiTests(TestCase): self.assertEqual(response.data["data"][0]["name"], "Pepper") self.assertEqual(set(response.data["data"][0].keys()), {"name", "icon", "growth_stages"}) self.assertNotEqual(response.data["data"][0]["name"], tomato.name) + self.assertEqual(response.data["meta"]["ownership"], "backend") + self.assertEqual(response.data["meta"]["sync_status"], "synced") + + @patch("plants.views.push_plants_to_ai") + def test_list_exposes_backend_ownership_even_when_ai_sync_fails(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.side_effect = PlantSyncError("sync failed") + Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) + request = self.factory.get("/api/plants/") + force_authenticate(request, user=self.user) + + response = PlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment") + self.assertEqual(response.data["meta"]["sync_status"], "failed") + + def test_selected_endpoint_reads_seeded_backend_products_without_runtime_mock_data(self): + tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) + pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"]) + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="seeded-farm") + farm.products.add(tomato, pepper) + + request = self.factory.get(f"/api/plants/selected/?farm_uuid={farm.farm_uuid}") + force_authenticate(request, user=self.user) + + with patch("plants.views.push_plants_to_ai", return_value=[]): + response = SelectedPlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertCountEqual([item["name"] for item in response.data["data"]], ["Tomato", "Pepper"]) + self.assertEqual(response.data["meta"]["source_type"], "db") diff --git a/plants/views.py b/plants/views.py index b82cc20..8a40f3e 100644 --- a/plants/views.py +++ b/plants/views.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema +from config.integration_contract import build_integration_meta from config.swagger import code_response, farm_uuid_query_param from farm_hub.models import FarmHub, Product from .serializers import PlantNameSerializer, PlantSerializer @@ -15,12 +16,12 @@ class PlantBaseView(APIView): permission_classes = [IsAuthenticated] @staticmethod - def _sync_plants_if_possible(): + def _attempt_ai_catalog_sync(): try: push_plants_to_ai() except PlantSyncError: - return False - return True + return False, "failed" + return True, "synced" @staticmethod def _get_farm(request, farm_uuid): @@ -39,13 +40,34 @@ class PlantListView(PlantBaseView): ) def get(self, request): products = ensure_plant_defaults(Product.objects.order_by("name")) + sync_attempted = True + sync_status = "synced" try: push_plants_to_ai(products) except PlantSyncError as exc: + sync_status = "failed" if not products: return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) data = PlantSerializer(products, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + notes=["Backend plant catalog is canonical; AI receives sync snapshots only."], + ), + }, + status=status.HTTP_200_OK, + ) class PlantDetailView(PlantBaseView): @@ -74,10 +96,27 @@ class PlantNameListView(PlantBaseView): responses={200: code_response("PlantNameListResponse", data=PlantNameSerializer(many=True))}, ) def get(self, request): - self._sync_plants_if_possible() + sync_attempted, sync_status = self._attempt_ai_catalog_sync() products = ensure_plant_defaults(Product.objects.order_by("name")) data = PlantNameSerializer(products, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + ), + }, + status=status.HTTP_200_OK, + ) class SelectedPlantListView(PlantBaseView): @@ -87,9 +126,26 @@ class SelectedPlantListView(PlantBaseView): responses={200: code_response("SelectedPlantListResponse", data=PlantNameSerializer(many=True))}, ) def get(self, request): - self._sync_plants_if_possible() + sync_attempted, sync_status = self._attempt_ai_catalog_sync() farm = self._get_farm(request, request.query_params.get("farm_uuid")) ensure_plant_defaults(farm.products.all()) products = farm.products.order_by("name") data = PlantNameSerializer(products, many=True).data - return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + ), + }, + status=status.HTTP_200_OK, + ) diff --git a/water/defaults.py b/water/defaults.py new file mode 100644 index 0000000..d7b8bcc --- /dev/null +++ b/water/defaults.py @@ -0,0 +1,36 @@ +EMPTY_FARM_WEATHER_CARD = { + "condition": None, + "temperature": None, + "unit": "°C", + "humidity": None, + "windSpeed": None, + "windUnit": "km/h", + "chartData": {"labels": [], "series": [[]]}, + "status": "empty", + "source": "db", + "warnings": ["No persisted weather data is available for this farm."], +} + +EMPTY_WATER_NEED_PREDICTION = { + "totalNext7Days": 0, + "unit": "mm", + "categories": [], + "series": [{"name": "نیاز آبی", "data": []}], + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation water-balance data is available for this farm."], +} + +EMPTY_WATER_STRESS_INDEX = { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-droplet", + "chipText": "بدون داده", + "chipColor": "secondary", + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation stress data is available for this farm."], +} diff --git a/water/services.py b/water/services.py index 47405e6..351f445 100644 --- a/water/services.py +++ b/water/services.py @@ -2,26 +2,29 @@ from copy import deepcopy from irrigation.models import IrrigationRecommendationRequest -from .mock_data import FARM_WEATHER_CARD, WATER_NEED_PREDICTION, WATER_STRESS_INDEX +from .defaults import EMPTY_FARM_WEATHER_CARD, EMPTY_WATER_NEED_PREDICTION, EMPTY_WATER_STRESS_INDEX from .models import WeatherForecastLog def get_farm_weather_card_data(farm=None): if farm is None: - return deepcopy(FARM_WEATHER_CARD) + return deepcopy(EMPTY_FARM_WEATHER_CARD) log = WeatherForecastLog.objects.filter(farm=farm).first() if log is None: - return deepcopy(FARM_WEATHER_CARD) + return deepcopy(EMPTY_FARM_WEATHER_CARD) return { - "condition": log.condition or FARM_WEATHER_CARD["condition"], - "temperature": log.temperature if log.temperature is not None else FARM_WEATHER_CARD["temperature"], - "unit": log.unit or FARM_WEATHER_CARD["unit"], - "humidity": log.humidity if log.humidity is not None else FARM_WEATHER_CARD["humidity"], - "windSpeed": log.wind_speed if log.wind_speed is not None else FARM_WEATHER_CARD["windSpeed"], - "windUnit": log.wind_unit or FARM_WEATHER_CARD["windUnit"], - "chartData": deepcopy(log.chart_data or FARM_WEATHER_CARD["chartData"]), + "condition": log.condition or None, + "temperature": log.temperature if log.temperature is not None else None, + "unit": log.unit or EMPTY_FARM_WEATHER_CARD["unit"], + "humidity": log.humidity if log.humidity is not None else None, + "windSpeed": log.wind_speed if log.wind_speed is not None else None, + "windUnit": log.wind_unit or EMPTY_FARM_WEATHER_CARD["windUnit"], + "chartData": deepcopy(log.chart_data or EMPTY_FARM_WEATHER_CARD["chartData"]), + "status": "success", + "source": "db", + "warnings": [], } @@ -53,7 +56,7 @@ def _get_latest_irrigation_result(farm): def get_water_need_prediction_data(farm=None): - default_data = deepcopy(WATER_NEED_PREDICTION) + default_data = deepcopy(EMPTY_WATER_NEED_PREDICTION) result = _get_latest_irrigation_result(farm) water_balance = result.get("water_balance", {}) daily = water_balance.get("daily", []) @@ -69,11 +72,14 @@ def get_water_need_prediction_data(farm=None): "unit": "mm", "categories": categories, "series": [{"name": "نیاز آبی", "data": series_data}], + "status": "success", + "source": "db", + "warnings": [], } def get_water_stress_index_data(farm=None): - data = deepcopy(WATER_STRESS_INDEX) + data = deepcopy(EMPTY_WATER_STRESS_INDEX) result = _get_latest_irrigation_result(farm) moisture_level = (result.get("plan") or {}).get("moistureLevel") @@ -95,6 +101,9 @@ def get_water_stress_index_data(farm=None): data["avatarColor"] = "error" data["stats"] = f"{stress_value}%" + data["status"] = "success" + data["source"] = "db" + data["warnings"] = [] return data diff --git a/yield_harvest/defaults.py b/yield_harvest/defaults.py new file mode 100644 index 0000000..942d1c4 --- /dev/null +++ b/yield_harvest/defaults.py @@ -0,0 +1,31 @@ +EMPTY_YIELD_HARVEST_SUMMARY = { + "yield_prediction_card": { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "بدون داده", + "chipColor": "secondary", + "status": "empty", + "source": "db", + }, + "yield_prediction_chart": { + "categories": [], + "series": [], + "summary": [], + "status": "empty", + "source": "db", + }, + "harvest_prediction_card": { + "date": None, + "dateFormatted": None, + "daysUntil": None, + "description": "داده پیش‌بینی برداشت هنوز ثبت نشده است.", + "optimalWindowStart": None, + "optimalWindowEnd": None, + "status": "empty", + "source": "db", + }, +} diff --git a/yield_harvest/services.py b/yield_harvest/services.py index ddb4c80..545098e 100644 --- a/yield_harvest/services.py +++ b/yield_harvest/services.py @@ -1,15 +1,11 @@ from copy import deepcopy -from .mock_data import HARVEST_PREDICTION_CARD, YIELD_PREDICTION_CARD, YIELD_PREDICTION_CHART +from .defaults import EMPTY_YIELD_HARVEST_SUMMARY from .models import YieldHarvestPredictionLog def get_yield_harvest_summary_data(farm=None): - data = { - "yield_prediction_card": deepcopy(YIELD_PREDICTION_CARD), - "yield_prediction_chart": deepcopy(YIELD_PREDICTION_CHART), - "harvest_prediction_card": deepcopy(HARVEST_PREDICTION_CARD), - } + data = deepcopy(EMPTY_YIELD_HARVEST_SUMMARY) if farm is None: return data @@ -18,6 +14,13 @@ def get_yield_harvest_summary_data(farm=None): if log is None: return data + data["yield_prediction_card"]["status"] = "success" + data["yield_prediction_card"]["source"] = "db" + data["yield_prediction_chart"]["status"] = "success" + data["yield_prediction_chart"]["source"] = "db" + data["harvest_prediction_card"]["status"] = "success" + data["harvest_prediction_card"]["source"] = "db" + if log.yield_stats: data["yield_prediction_card"]["stats"] = log.yield_stats if log.yield_chip_text: diff --git a/yield_harvest/tests.py b/yield_harvest/tests.py index e0e3f83..2f9ee4d 100644 --- a/yield_harvest/tests.py +++ b/yield_harvest/tests.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient, APIRequestFactory, force_authenticate +from config.observability import METRICS from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType, Product from fertilization.models import FertilizationPlan @@ -42,6 +43,9 @@ class CropSimulationViewTests(TestCase): self.farm.products.add(self.product) self.api_client.force_authenticate(user=self.user) + def tearDown(self): + METRICS.clear() + @patch("yield_harvest.views.external_api_request") def test_growth_queues_simulation_task(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( @@ -664,6 +668,60 @@ class CropSimulationViewTests(TestCase): self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id) self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id) + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_records_empty_result_metric(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {}}}) + + response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(METRICS["yield_harvest.ai.empty_result|operation=yield_harvest_summary"], 1) + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_persists_seeded_log_from_realistic_ai_contract(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "yield_prediction": {"predicted_yield_tons": 5.1, "unit": "tons"}, + "harvest_prediction_card": { + "harvest_date": "2026-09-28", + "days_until": 152, + "optimalWindowStart": "2026-09-25", + "optimalWindowEnd": "2026-10-01", + }, + "yield_prediction_chart": {"series": [{"name": "yield", "data": []}]}, + } + } + }, + ) + + response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}") + + self.assertEqual(response.status_code, 200) + self.assertTrue(self.farm.yield_harvest_prediction_logs.exists()) + log = self.farm.yield_harvest_prediction_logs.latest("id") + self.assertEqual(log.yield_stats, "5.1") + self.assertEqual(str(log.harvest_date), "2026-09-28") + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_provider_unavailable_returns_explicit_failure(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=503, + data={"message": "provider unavailable"}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["data"]["message"], "provider unavailable") + def test_crop_simulation_rejects_foreign_farm_uuid(self): request = self.factory.post( "/api/yield-harvest/crop-simulation/yield-prediction/", diff --git a/yield_harvest/views.py b/yield_harvest/views.py index 0883c68..bed5c9c 100644 --- a/yield_harvest/views.py +++ b/yield_harvest/views.py @@ -1,11 +1,15 @@ """Yield & Harvest Prediction and Crop Simulation API views.""" +import logging +import time + from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema +from config.observability import classify_exception, log_event, observe_operation, record_metric from config.swagger import code_response, farm_uuid_query_param from external_api_adapter import request as external_api_request from farm_hub.models import FarmHub @@ -23,6 +27,8 @@ from .serializers import ( YieldPredictionSerializer, ) +logger = logging.getLogger(__name__) + class YieldHarvestSummaryView(APIView): """ @@ -110,16 +116,31 @@ class YieldHarvestSummaryView(APIView): return plan_error query.update(ai_payload) - adapter_response = external_api_request( - "ai", - "/api/crop-simulation/yield-harvest-summary/", - method="GET", - query=query, - ) + with observe_operation(source="backend.yield_harvest", provider="ai", operation="yield_harvest_summary"): + started_at = time.monotonic() + adapter_response = external_api_request( + "ai", + "/api/crop-simulation/yield-harvest-summary/", + method="GET", + query=query, + ) if adapter_response.status_code >= 400: + record_metric("yield_harvest.ai.failure", status_code=adapter_response.status_code, operation="yield_harvest_summary") return CropSimulationBaseView._error_response(adapter_response) summary = CropSimulationBaseView._extract_result(adapter_response.data) + if not summary: + record_metric("yield_harvest.ai.empty_result", operation="yield_harvest_summary") + log_event( + level=logging.WARNING, + message="yield harvest summary returned empty result", + source="backend.yield_harvest", + provider="ai", + operation="yield_harvest_summary", + result_status="empty", + duration_ms=(time.monotonic() - started_at) * 1000, + farm_uuid=str(farm.farm_uuid), + ) self._persist_log(farm.farm_uuid, summary) @@ -134,8 +155,21 @@ class YieldHarvestSummaryView(APIView): if farm_uuid: try: farm = FarmHub.objects.get(farm_uuid=farm_uuid) - except (FarmHub.DoesNotExist, Exception): - pass + except FarmHub.DoesNotExist: + logger.warning("yield_harvest log persistence skipped because farm was not found farm_uuid=%s", farm_uuid) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="yield_harvest log persistence failed", + source="backend.yield_harvest", + provider="db", + operation="persist_log", + result_status="error", + error_code=failure.error_code, + farm_uuid=str(farm_uuid), + ) + return yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {} harvest_card = summary.get("harvest_prediction_card", {}) @@ -178,6 +212,7 @@ class CropSimulationBaseView(APIView): @staticmethod def _extract_result(adapter_data): if not isinstance(adapter_data, dict): + record_metric("yield_harvest.ai.invalid_payload", operation="extract_result") return {} data = adapter_data.get("data") @@ -199,6 +234,16 @@ class CropSimulationBaseView(APIView): if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} ) + log_event( + level=logging.ERROR, + message="yield_harvest upstream request failed", + source="backend.yield_harvest", + provider="ai", + operation="external_api", + result_status="error", + error_code="provider_error", + status_code=adapter_response.status_code, + ) return Response( {"code": adapter_response.status_code, "msg": "error", "data": response_data}, status=adapter_response.status_code,