UPDATE
This commit is contained in:
@@ -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
|
||||
@@ -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/<task_id>/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/<task_id>/status/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
|
||||
| `GET /api/farm-data/<farm_uuid>/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/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
|
||||
| `PUT /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
|
||||
| `PATCH /api/plants/<pk>/` | متصل نیست | فقط spec/mock در `external_api_adapter/json/ai/index.json` دیده شد |
|
||||
| `DELETE /api/plants/<pk>/` | متصل نیست | فقط 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/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
|
||||
| `PUT /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
|
||||
| `PATCH /api/irrigation/<pk>/` | متصل نیست | route detail/call به AI پیدا نشد |
|
||||
| `DELETE /api/irrigation/<pk>/` | متصل نیست | 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.
|
||||
|
||||
@@ -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/<task_id>/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/<task_id>/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/<farm_uuid>/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/<pk>/` | استفاده نمیشود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
|
||||
| `PUT /api/plants/<pk>/` | استفاده نمیشود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
|
||||
| `PATCH /api/plants/<pk>/` | استفاده نمیشود | فقط در mock/spec های adapter دیده شد؛ route محلی ندارد | `external_api_adapter/json/ai/index.json` |
|
||||
| `DELETE /api/plants/<pk>/` | استفاده نمیشود | فقط در 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/<pk>/` | استفاده نمیشود | route detail پیدا نشد | `irrigation/urls.py` |
|
||||
| `PUT /api/irrigation/<pk>/` | استفاده نمیشود | route detail/update پیدا نشد | `irrigation/urls.py` |
|
||||
| `PATCH /api/irrigation/<pk>/` | استفاده نمیشود | route detail/update پیدا نشد | `irrigation/urls.py` |
|
||||
| `DELETE /api/irrigation/<pk>/` | استفاده نمیشود | 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/*` |
|
||||
|
||||
+77
-16
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
]
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
+18
-360
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
+18
-10
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
+29
-18
@@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
+270
-123
@@ -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):
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+37
-13
@@ -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):
|
||||
|
||||
@@ -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 نیست.
|
||||
|
||||
@@ -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."],
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+78
-28
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
CONTEXT_RESPONSE_TEMPLATE = {
|
||||
"soilType": None,
|
||||
"waterEC": None,
|
||||
"selectedCrop": None,
|
||||
"growthStage": None,
|
||||
"lastIrrigationStatus": None,
|
||||
"status": "success",
|
||||
"source": "default_template",
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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."],
|
||||
}
|
||||
+24
-17
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
+20
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."],
|
||||
}
|
||||
+87
-18
@@ -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
|
||||
|
||||
@@ -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/",
|
||||
|
||||
+108
-19
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+48
-26
@@ -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")
|
||||
|
||||
+64
-8
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."],
|
||||
}
|
||||
+21
-12
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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/",
|
||||
|
||||
+53
-8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user