From f0f2ac34b7848fab5a44cd3828351badab9742cb Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Wed, 29 Apr 2026 01:27:16 +0330 Subject: [PATCH] UPDATE --- config/urls.py | 1 + docs/sensor_frontend_api_reference.md | 1062 +++++++++++++++++ entrypoint.sh | 1 + farm_hub/services.py | 23 + sensor_7_in_1/comparison_urls.py | 10 + sensor_7_in_1/management/__init__.py | 1 + sensor_7_in_1/management/commands/__init__.py | 1 + .../management/commands/seed_sensor_7_in_1.py | 23 + sensor_7_in_1/seeds.py | 174 +++ sensor_7_in_1/serializers.py | 45 +- sensor_7_in_1/services.py | 308 ++++- sensor_7_in_1/tests.py | 167 ++- sensor_7_in_1/urls.py | 10 +- sensor_7_in_1/views.py | 110 +- sensor_catalog/SensorCatalogListView.md | 302 +++++ sensor_external_api/SensorExternalAPIView.md | 356 ++++++ sensor_external_api/serializers.py | 23 +- sensor_external_api/services.py | 143 ++- sensor_external_api/tests.py | 85 +- sensor_external_api/views.py | 60 +- 20 files changed, 2840 insertions(+), 65 deletions(-) create mode 100644 docs/sensor_frontend_api_reference.md create mode 100644 sensor_7_in_1/comparison_urls.py create mode 100644 sensor_7_in_1/management/__init__.py create mode 100644 sensor_7_in_1/management/commands/__init__.py create mode 100644 sensor_7_in_1/management/commands/seed_sensor_7_in_1.py create mode 100644 sensor_7_in_1/seeds.py create mode 100644 sensor_catalog/SensorCatalogListView.md create mode 100644 sensor_external_api/SensorExternalAPIView.md diff --git a/config/urls.py b/config/urls.py index c674a1f..c47d14e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path("api/pest-detection/", include("pest_detection.urls")), path("api/pest-disease/", include("pest_detection.pest_disease_urls")), path("api/sensor-7-in-1/", include("sensor_7_in_1.urls")), + path("api/sensors/", include("sensor_7_in_1.comparison_urls")), path("api/irrigation/", include("irrigation_recommendation.urls")), path("api/weather/", include("water.weather_urls")), diff --git a/docs/sensor_frontend_api_reference.md b/docs/sensor_frontend_api_reference.md new file mode 100644 index 0000000..bdce862 --- /dev/null +++ b/docs/sensor_frontend_api_reference.md @@ -0,0 +1,1062 @@ +# مستند فرانت API سنسورها + +این فایل برای تیم فرانت نوشته شده و رفتار کامل endpointهای زیر را توضیح می‌دهد: + +- `GET /api/sensor-7-in-1/summary/` +- `GET /api/sensors/comparison-chart/` +- `GET /api/sensors/radar-chart/` +- `GET /api/sensors/values-list/` +- `GET /api/sensor-external-api/logs/` + +--- + +## 1) نکات کلی + +### Base URL + +همه مسیرها نسبت به دامنه اصلی backend تعریف می‌شوند. مثال: + +```txt +https://example.com/api/sensor-7-in-1/summary/ +``` + +### نوع احراز هویت + +این endpointها دو مدل احراز هویت دارند: + +1. endpointهای سنسور 7-in-1: + - `GET /api/sensor-7-in-1/summary/` + - `GET /api/sensors/comparison-chart/` + - `GET /api/sensors/radar-chart/` + - `GET /api/sensors/values-list/` + - نیازمند کاربر لاگین‌شده هستند. + - اگر کاربر احراز هویت نشده باشد معمولاً پاسخ `401 Unauthorized` برمی‌گردد. + +2. endpoint لاگ سنسور خارجی: + - `GET /api/sensor-external-api/logs/` + - نیازمند هدر `X-API-Key` است. + - اگر API key ارسال نشود یا اشتباه باشد پاسخ `401 Unauthorized` برمی‌گردد. + +### نکته مهم درباره ساختار response + +این 5 API یکدست نیستند: + +- `summary` پاسخ را داخل ساختار استاندارد `code / msg / data` برمی‌گرداند. +- `comparison-chart`، `radar-chart` و `values-list` داده خام را مستقیم برمی‌گردانند. +- `sensor-external-api/logs` پاسخ را به‌صورت paginated و داخل `code / msg / data` برمی‌گرداند. + +پس فرانت باید برای هر endpoint دقیقاً همان ساختار را هندل کند. + +--- + +## 2) GET /api/sensor-7-in-1/summary/ + +### هدف + +این endpoint یک summary کامل از سنسور 7-in-1 مزرعه برمی‌گرداند و چند ویجت آماده برای UI را یکجا در خروجی قرار می‌دهد: + +- اطلاعات متای سنسور +- لیست مقادیر سنسور +- کارت میانگین رطوبت خاک +- نمودار رادار +- نمودار مقایسه‌ای +- کارت anomaly detection +- heatmap رطوبت خاک + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | `uuid` | بله | شناسه مزرعه | + +### نمونه درخواست + +```http +GET /api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111 +Authorization: Bearer +``` + +### ساختار پاسخ موفق + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "sensor": {}, + "sensorValuesList": {}, + "avgSoilMoisture": {}, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, + "soilMoistureHeatmap": {} + } +} +``` + +### فیلدهای `data` + +#### `sensor` + +متادیتای سنسور اصلی: + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | `string` | نام سنسور | +| `physicalDeviceUuid` | `string \| null` | شناسه فیزیکی دستگاه | +| `sensorCatalogCode` | `string` | کد سنسور در catalog | +| `updatedAt` | `string \| null` | زمان آخرین لاگ به فرمت ISO | + +نمونه: + +```json +{ + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" +} +``` + +#### `sensorValuesList` + +لیست مقادیر فعلی سنسور برای نمایش کارت‌ها یا stat itemها: + +| فیلد | نوع | توضیح | +|---|---|---| +| `sensor` | `object` | همان متادیتای سنسور | +| `sensors` | `array` | لیست سنسورفیلدها | + +ساختار هر آیتم `sensors`: + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `string` | کلید داخلی فیلد مثل `soil_moisture` | +| `title` | `string` | مقدار فرمت‌شده برای نمایش | +| `subtitle` | `string` | عنوان فارسی فیلد | +| `trendNumber` | `number` | مقدار تغییر نسبت به لاگ قبلی | +| `trend` | `positive \| negative` | جهت تغییر | +| `unit` | `string` | واحد | + +نمونه: + +```json +{ + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensors": [ + { + "id": "soil_moisture", + "title": "48.5%", + "subtitle": "رطوبت خاک", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "id": "soil_temperature", + "title": "23.2°C", + "subtitle": "دمای خاک", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + } + ] +} +``` + +#### `avgSoilMoisture` + +کارت KPI برای میانگین رطوبت خاک: + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `string` | شناسه کارت | +| `title` | `string` | عنوان کارت | +| `subtitle` | `string` | زیرعنوان | +| `stats` | `string` | مقدار اصلی برای نمایش | +| `avatarColor` | `string` | رنگ آیکن/اواتار | +| `avatarIcon` | `string` | نام آیکن | +| `chipText` | `string` | متن وضعیت | +| `chipColor` | `string` | رنگ وضعیت | + +نمونه: + +```json +{ + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": "48.5%", + "avatarColor": "warning", + "avatarIcon": "tabler-droplet", + "chipText": "متوسط", + "chipColor": "warning" +} +``` + +#### `sensorRadarChart` + +داده آماده برای radar chart: + +| فیلد | نوع | توضیح | +|---|---|---| +| `labels` | `string[]` | نام محورها | +| `series` | `array` | سری‌های نمودار | + +ساختار هر `series`: + +| فیلد | نوع | +|---|---| +| `name` | `string` | +| `data` | `number[]` | + +نمونه: + +```json +{ + "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], + "series": [ + { + "name": "اکنون", + "data": [86.0, 96.0, 88.0, 84.0, 76.0, 88.0, 44.0] + }, + { + "name": "هدف", + "data": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0] + } + ] +} +``` + +#### `sensorComparisonChart` + +خروجی آماده برای line/area chart: + +| فیلد | نوع | توضیح | +|---|---|---| +| `currentValue` | `number` | مقدار آخر | +| `vsLastWeek` | `string` | درصد تغییر نسبت به اولین نقطه | +| `vsLastWeekValue` | `number` | نسخه عددی تغییر | +| `categories` | `string[]` | برچسب محور X | +| `series` | `array` | سری‌های نمودار | + +نمونه: + +```json +{ + "currentValue": 48.5, + "vsLastWeek": "+18.3%", + "vsLastWeekValue": 18.3, + "categories": ["01/04 08:00", "01/10 08:30"], + "series": [ + { + "name": "رطوبت خاک", + "data": [41.0, 48.5] + }, + { + "name": "بازه هدف", + "data": [55.0, 55.0] + } + ] +} +``` + +#### `anomalyDetectionCard` + +لیست ناهنجاری‌های سنسور: + +| فیلد | نوع | توضیح | +|---|---|---| +| `anomalies` | `array` | لیست anomaly | + +ساختار هر anomaly: + +| فیلد | نوع | توضیح | +|---|---|---| +| `sensor` | `string` | نام سنسور/پارامتر | +| `value` | `string` | مقدار فعلی | +| `expected` | `string` | بازه مورد انتظار | +| `deviation` | `string` | اختلاف با مقدار مورد انتظار | +| `severity` | `success \| warning \| error` | شدت | + +نمونه: + +```json +{ + "anomalies": [ + { + "sensor": "پتاسیم", + "value": "24 mg/kg", + "expected": "15-35 mg/kg", + "deviation": "0", + "severity": "success" + } + ] +} +``` + +نکته: + +- اگر ناهنجاری واقعی وجود نداشته باشد، backend یک آیتم success برمی‌گرداند تا UI حالت خالی نداشته باشد. + +#### `soilMoistureHeatmap` + +خروجی heatmap: + +| فیلد | نوع | توضیح | +|---|---|---| +| `zones` | `string[]` | نام zoneها یا سنسورها | +| `hours` | `string[]` | محور زمان | +| `series` | `array` | داده heatmap | + +نمونه: + +```json +{ + "zones": ["Soil Sensor 7-in-1"], + "hours": ["08:00", "10:00"], + "series": [ + { + "name": "Soil Sensor 7-in-1", + "data": [ + { "x": "08:00", "y": 41.0 }, + { "x": "10:00", "y": 48.5 } + ] + } + ] +} +``` + +### نمونه پاسخ کامل موفق + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensorValuesList": { + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensors": [ + { + "id": "soil_moisture", + "title": "48.5%", + "subtitle": "رطوبت خاک", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "id": "soil_temperature", + "title": "23.2°C", + "subtitle": "دمای خاک", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + }, + { + "id": "soil_ph", + "title": "6.8", + "subtitle": "pH خاک", + "trendNumber": 0.3, + "trend": "positive", + "unit": "pH" + }, + { + "id": "electrical_conductivity", + "title": "1.4 dS/m", + "subtitle": "هدایت الکتریکی", + "trendNumber": 0.4, + "trend": "positive", + "unit": "dS/m" + } + ] + }, + "avgSoilMoisture": { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": "48.5%", + "avatarColor": "warning", + "avatarIcon": "tabler-droplet", + "chipText": "متوسط", + "chipColor": "warning" + }, + "sensorRadarChart": { + "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], + "series": [ + { + "name": "اکنون", + "data": [86.0, 96.0, 88.0, 84.0, 76.0, 88.0, 44.0] + }, + { + "name": "هدف", + "data": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0] + } + ] + }, + "sensorComparisonChart": { + "currentValue": 48.5, + "vsLastWeek": "+18.3%", + "vsLastWeekValue": 18.3, + "categories": ["01/04 08:00", "01/10 08:30"], + "series": [ + { + "name": "رطوبت خاک", + "data": [41.0, 48.5] + }, + { + "name": "بازه هدف", + "data": [55.0, 55.0] + } + ] + }, + "anomalyDetectionCard": { + "anomalies": [ + { + "sensor": "سنسور 7 در 1 خاک", + "value": "نرمال", + "expected": "تمام شاخص‌ها در بازه مجاز هستند", + "deviation": "0", + "severity": "success" + } + ] + }, + "soilMoistureHeatmap": { + "zones": ["Soil Sensor 7-in-1"], + "hours": ["08:00", "10:00"], + "series": [ + { + "name": "Soil Sensor 7-in-1", + "data": [ + { "x": "08:00", "y": 41.0 }, + { "x": "10:00", "y": 48.5 } + ] + } + ] + } + } +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `farm_uuid` ارسال نشود: + +```json +{ + "farm_uuid": ["This field is required."] +} +``` + +اگر مزرعه برای این کاربر پیدا نشود: + +```json +{ + "farm_uuid": ["Farm not found."] +} +``` + +اگر مزرعه سنسور نداشته باشد: + +```json +{ + "farm_uuid": ["No sensor found for this farm."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +### نکات فرانت + +- این endpoint برای ساخت dashboard کامل مناسب است. +- `updatedAt` ممکن است `null` باشد. +- اگر داده واقعی هنوز وارد نشده باشد backend ممکن است fallback/mock structure برگرداند. +- برای UI بهتر است روی وجود نداشتن بعضی فیلدها defensive code داشته باشید. + +--- + +## 3) GET /api/sensors/comparison-chart/ + +### هدف + +این endpoint داده خام نمودار مقایسه‌ای را برمی‌گرداند. خروجی آن برای chart component مناسب است و wrapper `code/msg/data` ندارد. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `7d` یا `30d` | + +### نمونه درخواست + +```http +GET /api/sensors/comparison-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=7d +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "series": [ + { + "name": "moisture", + "data": [41.0, 48.5] + }, + { + "name": "temperature", + "data": [21.0, 23.2] + }, + { + "name": "ph", + "data": [6.5, 6.8] + } + ], + "categories": ["شنبه", "یکشنبه"], + "currentValue": 48.5, + "vsLastWeek": "+18.3%" +} +``` + +### توضیح فیلدها + +| فیلد | نوع | توضیح | +|---|---|---| +| `series` | `array` | تمام پارامترهای عددی موجود در payload | +| `series[].name` | `string` | نام normalized فیلد مثل `moisture`، `temperature`، `ph` | +| `series[].data` | `number[]` | نقاط سری | +| `categories` | `string[]` | برچسب‌های محور X | +| `currentValue` | `number` | آخرین مقدار سری اول | +| `vsLastWeek` | `string` | درصد تغییر آخرین مقدار نسبت به اولین مقدار سری اول | + +### رفتار `range` + +- `7d`: دسته‌بندی روی روزهای هفته فارسی انجام می‌شود. +- `30d`: برچسب‌ها به‌صورت `MM/DD` برمی‌گردند. + +### نکات مهم فرانت + +- نام سری‌ها انگلیسی و normalized هستند، نه label نمایشی. +- `currentValue` و `vsLastWeek` همیشه از سری اول محاسبه می‌شوند. +- اگر هیچ داده‌ای وجود نداشته باشد پاسخ این شکلی است: + +```json +{ + "series": [], + "categories": [], + "currentValue": 0.0, + "vsLastWeek": "+0.0%" +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"14d\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 4) GET /api/sensors/radar-chart/ + +### هدف + +این endpoint داده خام radar chart را برای سنسور مزرعه برمی‌گرداند. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `today`، `7d` یا `30d` | + +### نمونه درخواست + +```http +GET /api/sensors/radar-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=7d +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "labels": ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"], + "series": [ + { + "name": "وضعیت فعلی", + "data": [48.5, 23.2, 6.8, 1.4, 31.0, 24.0] + }, + { + "name": "بازه ایده آل", + "data": [60.0, 26.0, 6.5, 1.3, 42.0, 38.0] + } + ] +} +``` + +### توضیح فیلدها + +| فیلد | نوع | توضیح | +|---|---|---| +| `labels` | `string[]` | محورهای radar chart | +| `series` | `array` | دو سری اصلی | +| `series[0]` | `object` | مقدار فعلی | +| `series[1]` | `object` | مقدار ایده‌آل | + +### نکات مهم فرانت + +- تعداد `labels` و طول `data` هر سری باید برابر باشد. +- فقط فیلدهایی در پاسخ می‌آیند که در آخرین payload وجود داشته باشند. +- اگر داده‌ای پیدا نشود: + +```json +{ + "labels": [], + "series": [] +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"2d\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 5) GET /api/sensors/values-list/ + +### هدف + +این endpoint لیست مقادیر current sensor و trend آن‌ها را برمی‌گرداند. مناسب برای card list، table کوتاه یا stat widgets است. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `1h`، `24h` یا `7d` | + +### نمونه درخواست + +```http +GET /api/sensors/values-list/?farm_uuid=11111111-1111-1111-1111-111111111111&range=24h +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "sensors": [ + { + "title": "Moisture", + "subtitle": "مقدار فعلی: 48.5%", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "title": "Temperature", + "subtitle": "مقدار فعلی: 23.2°C", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + }, + { + "title": "PH", + "subtitle": "مقدار فعلی: 6.8", + "trendNumber": 0.3, + "trend": "positive", + "unit": "pH" + } + ] +} +``` + +### توضیح فیلدهای هر آیتم + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | `string` | نام فیلد | +| `subtitle` | `string` | متن آماده برای نمایش مقدار فعلی | +| `trendNumber` | `number` | اختلاف آخرین مقدار نسبت به اولین مقدار در بازه | +| `trend` | `positive \| negative` | جهت تغییر | +| `unit` | `string` | واحد | + +### نکات مهم فرانت + +- `subtitle` از backend به‌صورت آماده برمی‌گردد. +- اگر در بازه انتخاب‌شده داده‌ای نباشد، backend آخرین لاگ موجود را fallback می‌کند. +- اگر هیچ لاگی وجود نداشته باشد: + +```json +{ + "sensors": [] +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"12h\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 6) GET /api/sensor-external-api/logs/ + +### هدف + +این endpoint تاریخچه لاگ‌های ورودی سنسور خارجی را برمی‌گرداند. این API بیشتر برای: + +- history page +- debugging panel +- raw sensor log table +- trace داده‌های دریافتی از device + +مناسب است. + +### احراز هویت + +- نیازمند هدر `X-API-Key` + +نمونه: + +```http +X-API-Key: 12345 +``` + +### Query Params + +| نام | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | `uuid` | بله | شناسه مزرعه | +| `page` | `int` | بله | شماره صفحه، حداقل `1` | +| `page_size` | `int` | بله | اندازه صفحه، بین `1` تا `100` | +| `physical_device_uuid` | `uuid` | خیر | فیلتر روی دستگاه خاص | +| `sensor_type` | `string` | خیر | فیلتر روی نوع سنسور | +| `date_from` | `date` | خیر | فیلتر از تاریخ | +| `date_to` | `date` | خیر | فیلتر تا تاریخ | + +### نمونه درخواست + +```http +GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20 +X-API-Key: 12345 +``` + +نمونه با فیلتر: + +```http +GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&physical_device_uuid=55555555-5555-5555-5555-555555555555&date_from=2025-05-01&date_to=2025-05-10&page=1&page_size=20 +X-API-Key: 12345 +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "count": 2, + "next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1", + "previous": null, + "data": [ + { + "id": 12, + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "farm_sensor": { + "uuid": "99999999-9999-9999-9999-999999999999", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "External device 2", + "sensor_type": "soil_sensor", + "is_active": true, + "specifications": { + "model": "FH-2" + }, + "power_source": { + "type": "solar" + }, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "sensor_catalog": { + "uuid": "22222222-2222-2222-2222-222222222222", + "code": "ext-sensor-log-2", + "name": "External Sensor Log 2", + "description": "Sensor catalog for second log", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": ["humidity"], + "sample_payload": {}, + "is_active": true, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "payload": { + "temp": 18 + }, + "created_at": "2025-05-02T11:00:00Z" + } + ] +} +``` + +### توضیح فیلدهای ریشه پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | `number` | کد داخلی backend | +| `msg` | `string` | پیام | +| `count` | `number` | تعداد کل آیتم‌ها قبل از pagination | +| `next` | `string \| null` | لینک صفحه بعد | +| `previous` | `string \| null` | لینک صفحه قبل | +| `data` | `array` | داده‌های صفحه فعلی | + +### توضیح هر آیتم داخل `data` + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `number` | شناسه لاگ | +| `farm_uuid` | `string` | UUID مزرعه | +| `sensor_catalog_uuid` | `string \| null` | UUID کاتالوگ سنسور | +| `physical_device_uuid` | `string` | UUID دستگاه | +| `farm_sensor` | `object \| null` | اطلاعات سنسور مزرعه | +| `sensor_catalog` | `object \| null` | اطلاعات catalog | +| `payload` | `object` | داده خام ارسالی از device | +| `created_at` | `string` | زمان ثبت لاگ | + +### ساختار `farm_sensor` + +| فیلد | نوع | +|---|---| +| `uuid` | `string` | +| `sensor_catalog_uuid` | `string \| null` | +| `physical_device_uuid` | `string` | +| `name` | `string` | +| `sensor_type` | `string` | +| `is_active` | `boolean` | +| `specifications` | `object` | +| `power_source` | `object` | +| `created_at` | `string` | +| `updated_at` | `string` | + +### ساختار `sensor_catalog` + +| فیلد | نوع | +|---|---| +| `uuid` | `string` | +| `code` | `string` | +| `name` | `string` | +| `description` | `string` | +| `customizable_fields` | `array` | +| `supported_power_sources` | `array` | +| `returned_data_fields` | `array` | +| `sample_payload` | `object` | +| `is_active` | `boolean` | +| `created_at` | `string` | +| `updated_at` | `string` | + +### فیلترها + +#### فیلتر با `physical_device_uuid` + +فقط لاگ‌های مربوط به یک device خاص برمی‌گردد. + +#### فیلتر با `sensor_type` + +بر اساس `sensor_type` سنسور مزرعه فیلتر می‌کند. + +#### فیلتر با `date_from` و `date_to` + +بر اساس بازه تاریخ فیلتر می‌کند. + +نکته: + +- اگر هر دو ارسال شوند، `date_from` باید کوچک‌تر یا مساوی `date_to` باشد. + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `page` یا `page_size` ارسال نشود: + +```json +{ + "page": ["This field is required."], + "page_size": ["This field is required."] +} +``` + +اگر `date_from > date_to` باشد: + +```json +{ + "date_to": ["date_to must be greater than or equal to date_from."] +} +``` + +اگر `page_size > 100` باشد: + +```json +{ + "page_size": ["Ensure this value is less than or equal to 100."] +} +``` + +#### `401 Unauthorized` + +اگر API key وجود نداشته باشد: + +```json +{ + "detail": "API key is required." +} +``` + +اگر API key اشتباه باشد: + +```json +{ + "detail": "Invalid API key." +} +``` + +#### `503 Service Unavailable` + +اگر migrationهای جدول‌های لازم اجرا نشده باشند: + +```json +{ + "code": 503, + "msg": "Required tables are not ready. Run migrations." +} +``` + +### نکات فرانت + +- داده‌ها descending هستند؛ جدیدترین لاگ اول می‌آید. +- `farm_sensor` و `sensor_catalog` ممکن است `null` باشند. +- `payload` داینامیک است و ساختار ثابتی ندارد؛ UI باید generic باشد. +- برای table view بهتر است `payload` را stringify نکنید و فیلدهای مهم را extract کنید. +- برای pagination از `count`، `next` و `previous` استفاده کنید. + +--- + +## 7) تفاوت مهم بین این APIها + +| Endpoint | ساختار پاسخ | نیازمندی auth | +|---|---|---| +| `/api/sensor-7-in-1/summary/` | wrapped: `code/msg/data` | Bearer token / session | +| `/api/sensors/comparison-chart/` | raw json | Bearer token / session | +| `/api/sensors/radar-chart/` | raw json | Bearer token / session | +| `/api/sensors/values-list/` | raw json | Bearer token / session | +| `/api/sensor-external-api/logs/` | wrapped + paginated | `X-API-Key` | + +--- + +## 8) پیشنهاد برای استفاده در فرانت + +### برای dashboard اصلی + +از `GET /api/sensor-7-in-1/summary/` استفاده کنید، چون بیشتر widgetها را یکجا می‌دهد. + +### برای chartهای مستقل + +- اگر chart جداگانه و lightweight می‌خواهید از: + - `GET /api/sensors/comparison-chart/` + - `GET /api/sensors/radar-chart/` + +### برای stat card list + +- از `GET /api/sensors/values-list/` استفاده کنید. + +### برای page تاریخچه یا debug + +- از `GET /api/sensor-external-api/logs/` استفاده کنید. + +--- + +## 9) فایل‌های بک‌اند مرتبط + +- `sensor_7_in_1/views.py` +- `sensor_7_in_1/serializers.py` +- `sensor_7_in_1/services.py` +- `sensor_7_in_1/tests.py` +- `sensor_external_api/views.py` +- `sensor_external_api/serializers.py` +- `sensor_external_api/tests.py` +- `sensor_external_api/authentication.py` + diff --git a/entrypoint.sh b/entrypoint.sh index 9094d79..2fda445 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,6 +7,7 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then if [ "${DOCKER_VERSION}" = "develop" ]; then echo "Running develop seeders..." python manage.py seed_admin_farm + python manage.py seed_sensor_7_in_1 echo "Develop seeders done." fi fi diff --git a/farm_hub/services.py b/farm_hub/services.py index 9ba6207..a281ce1 100644 --- a/farm_hub/services.py +++ b/farm_hub/services.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.db import transaction @@ -11,6 +13,9 @@ from external_api_adapter import request as external_api_request from external_api_adapter.exceptions import ExternalAPIRequestError +logger = logging.getLogger(__name__) + + class FarmDataSyncError(Exception): pass @@ -86,8 +91,18 @@ def sync_farm_data( api_key = getattr(settings, "FARM_DATA_API_KEY", "") if not api_key: + logger.error("Farm data sync failed: FARM_DATA_API_KEY missing for farm_uuid=%s", farm.farm_uuid) raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.") + logger.warning( + "Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s irrigation_method_id=%s boundary_type=%s", + farm.farm_uuid, + request_payload.get("sensor_key"), + "sensor_payload" in request_payload, + request_payload.get("plant_ids"), + request_payload.get("irrigation_method_id"), + request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None, + ) try: response = external_api_request( "ai", @@ -102,12 +117,20 @@ def sync_farm_data( }, ) except ExternalAPIRequestError as exc: + logger.exception("Farm data sync request exception: farm_uuid=%s", farm.farm_uuid) raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc if response.status_code >= 400: response_body = response.data + logger.error( + "Farm data sync rejected: farm_uuid=%s status_code=%s response=%s", + farm.farm_uuid, + response.status_code, + response_body, + ) raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}") + logger.warning("Farm data sync success: farm_uuid=%s status_code=%s", farm.farm_uuid, response.status_code) return request_payload diff --git a/sensor_7_in_1/comparison_urls.py b/sensor_7_in_1/comparison_urls.py new file mode 100644 index 0000000..8bc575a --- /dev/null +++ b/sensor_7_in_1/comparison_urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView + + +urlpatterns = [ + path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"), + path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"), + path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"), +] diff --git a/sensor_7_in_1/management/__init__.py b/sensor_7_in_1/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sensor_7_in_1/management/__init__.py @@ -0,0 +1 @@ + diff --git a/sensor_7_in_1/management/commands/__init__.py b/sensor_7_in_1/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sensor_7_in_1/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/sensor_7_in_1/management/commands/seed_sensor_7_in_1.py b/sensor_7_in_1/management/commands/seed_sensor_7_in_1.py new file mode 100644 index 0000000..fc48790 --- /dev/null +++ b/sensor_7_in_1/management/commands/seed_sensor_7_in_1.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand, CommandError + +from sensor_7_in_1.seeds import seed_sensor_7_in_1_demo_data + + +class Command(BaseCommand): + help = "Create or refresh demo sensor 7 in 1 data for summary and chart endpoints." + + def handle(self, *args, **options): + try: + result = seed_sensor_7_in_1_demo_data() + except ValueError as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write( + self.style.SUCCESS( + "Sensor 7 in 1 demo data seeded: " + f"farm_uuid={result['farm'].farm_uuid}, " + f"sensor_catalog={result['sensor_catalog'].code}, " + f"physical_device_uuid={result['sensor'].physical_device_uuid}, " + f"logs={result['log_count']}" + ) + ) diff --git a/sensor_7_in_1/seeds.py b/sensor_7_in_1/seeds.py new file mode 100644 index 0000000..b93aea9 --- /dev/null +++ b/sensor_7_in_1/seeds.py @@ -0,0 +1,174 @@ +from datetime import timedelta +import uuid + +from django.db import transaction +from django.utils import timezone + +from farm_hub.models import FarmSensor +from farm_hub.seeds import seed_admin_farm +from sensor_catalog.models import SensorCatalog +from sensor_external_api.models import SensorExternalRequestLog + + +SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1" +SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777") +SENSOR_7_IN_1_LOG_SERIES = [ + { + "days_ago": 6, + "payload": { + "soil_moisture": 44.0, + "soil_temperature": 20.6, + "soil_ph": 6.3, + "electrical_conductivity": 1.0, + "nitrogen": 25.0, + "phosphorus": 13.0, + "potassium": 21.0, + }, + }, + { + "days_ago": 5, + "payload": { + "soil_moisture": 45.5, + "soil_temperature": 21.1, + "soil_ph": 6.4, + "electrical_conductivity": 1.1, + "nitrogen": 26.0, + "phosphorus": 13.8, + "potassium": 21.8, + }, + }, + { + "days_ago": 4, + "payload": { + "soil_moisture": 46.8, + "soil_temperature": 21.7, + "soil_ph": 6.5, + "electrical_conductivity": 1.1, + "nitrogen": 27.4, + "phosphorus": 14.2, + "potassium": 22.5, + }, + }, + { + "days_ago": 3, + "payload": { + "soil_moisture": 48.2, + "soil_temperature": 22.0, + "soil_ph": 6.6, + "electrical_conductivity": 1.2, + "nitrogen": 28.9, + "phosphorus": 15.1, + "potassium": 23.3, + }, + }, + { + "days_ago": 2, + "payload": { + "soil_moisture": 49.6, + "soil_temperature": 22.4, + "soil_ph": 6.6, + "electrical_conductivity": 1.2, + "nitrogen": 29.7, + "phosphorus": 15.7, + "potassium": 24.1, + }, + }, + { + "days_ago": 1, + "payload": { + "soil_moisture": 50.9, + "soil_temperature": 22.8, + "soil_ph": 6.7, + "electrical_conductivity": 1.3, + "nitrogen": 30.8, + "phosphorus": 16.2, + "potassium": 24.8, + }, + }, + { + "days_ago": 0, + "payload": { + "soil_moisture": 52.4, + "soil_temperature": 23.1, + "soil_ph": 6.8, + "electrical_conductivity": 1.3, + "nitrogen": 32.0, + "phosphorus": 16.8, + "potassium": 25.6, + }, + }, +] + + +def seed_sensor_7_in_1_catalog(): + sensor_catalog, created = SensorCatalog.objects.update_or_create( + code=SENSOR_7_IN_1_CATALOG_CODE, + defaults={ + "name": "Sensor 7 in 1 Soil Sensor", + "description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.", + "customizable_fields": [], + "supported_power_sources": ["solar", "battery", "direct_power"], + "returned_data_fields": [ + "soil_moisture", + "soil_temperature", + "soil_ph", + "electrical_conductivity", + "nitrogen", + "phosphorus", + "potassium", + ], + "sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"], + "is_active": True, + }, + ) + return sensor_catalog, created + + +@transaction.atomic +def seed_sensor_7_in_1_demo_data(): + farm, _ = seed_admin_farm() + sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog() + + sensor, sensor_created = FarmSensor.objects.update_or_create( + farm=farm, + physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID, + defaults={ + "sensor_catalog": sensor_catalog, + "name": "Sensor 7 in 1 Demo", + "sensor_type": "soil_7_in_1", + "is_active": True, + "specifications": { + "capabilities": sensor_catalog.returned_data_fields, + "demo_seed": True, + }, + "power_source": {"type": "solar"}, + }, + ) + + SensorExternalRequestLog.objects.filter( + farm_uuid=farm.farm_uuid, + physical_device_uuid=sensor.physical_device_uuid, + ).delete() + + base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0) + created_logs = [] + for item in SENSOR_7_IN_1_LOG_SERIES: + log = SensorExternalRequestLog.objects.create( + farm_uuid=farm.farm_uuid, + sensor_catalog_uuid=sensor_catalog.uuid, + physical_device_uuid=sensor.physical_device_uuid, + payload=item["payload"], + ) + created_at = base_time - timedelta(days=item["days_ago"]) + SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at) + log.created_at = created_at + created_logs.append(log) + + return { + "farm": farm, + "sensor_catalog": sensor_catalog, + "sensor": sensor, + "catalog_created": catalog_created, + "sensor_created": sensor_created, + "log_count": len(created_logs), + } diff --git a/sensor_7_in_1/serializers.py b/sensor_7_in_1/serializers.py index 52bffa2..090a040 100644 --- a/sensor_7_in_1/serializers.py +++ b/sensor_7_in_1/serializers.py @@ -30,6 +30,50 @@ class Sensor7In1ValuesListSerializer(serializers.Serializer): sensors = Sensor7In1ValueSerializer(many=True, required=False) +class SensorComparisonChartQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["7d", "30d"], required=False, default="7d") + + +class SensorValuesListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["1h", "24h", "7d"], required=False, default="7d") + + +class SensorRadarChartQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["today", "7d", "30d"], required=False, default="7d") + + +class SensorComparisonChartSeriesSerializer(serializers.Serializer): + name = serializers.CharField() + data = serializers.ListField(child=serializers.FloatField()) + + +class SensorComparisonChartResponseSerializer(serializers.Serializer): + series = SensorComparisonChartSeriesSerializer(many=True) + categories = serializers.ListField(child=serializers.CharField()) + currentValue = serializers.FloatField() + vsLastWeek = serializers.CharField() + + +class SensorValuesListItemSerializer(serializers.Serializer): + title = serializers.CharField() + subtitle = serializers.CharField() + trendNumber = serializers.FloatField() + trend = serializers.ChoiceField(choices=["positive", "negative"]) + unit = serializers.CharField() + + +class SensorValuesListResponseSerializer(serializers.Serializer): + sensors = SensorValuesListItemSerializer(many=True) + + +class SensorRadarChartResponseSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField()) + series = SensorComparisonChartSeriesSerializer(many=True) + + class Sensor7In1SummarySerializer(serializers.Serializer): sensor = Sensor7In1MetaSerializer(required=False) sensorValuesList = Sensor7In1ValuesListSerializer(required=False) @@ -38,4 +82,3 @@ class Sensor7In1SummarySerializer(serializers.Serializer): sensorComparisonChart = SoilComparisonChartSerializer(required=False) anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) - diff --git a/sensor_7_in_1/services.py b/sensor_7_in_1/services.py index 2221042..fb2ca19 100644 --- a/sensor_7_in_1/services.py +++ b/sensor_7_in_1/services.py @@ -1,6 +1,8 @@ from copy import deepcopy +from datetime import timedelta from sensor_external_api.services import get_farm_sensor_map_for_logs, get_sensor_external_request_logs_for_farm +from django.utils import timezone from .mock_data import ( ANOMALY_DETECTION_CARD, @@ -81,6 +83,59 @@ SENSOR_FIELDS = [ MIN_REQUIRED_SENSOR_FIELDS = 4 MAX_HISTORY_ITEMS = 20 MAX_CHART_POINTS = 7 +COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30} +VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)} +RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)} +PERSIAN_WEEKDAYS = { + 0: "دوشنبه", + 1: "سه شنبه", + 2: "چهارشنبه", + 3: "پنج شنبه", + 4: "جمعه", + 5: "شنبه", + 6: "یکشنبه", +} +COMPARISON_CHART_FIELD_ALIASES = { + "soil_moisture": "moisture", + "soilMoisture": "moisture", + "moisture": "moisture", + "soil_temperature": "temperature", + "soilTemperature": "temperature", + "temperature": "temperature", + "humidity": "humidity", + "soil_ph": "ph", + "soilPh": "ph", + "ph": "ph", + "electrical_conductivity": "ec", + "electricalConductivity": "ec", + "ec": "ec", + "nitrogen": "nitrogen", + "n": "nitrogen", + "phosphorus": "phosphorus", + "p": "phosphorus", + "potassium": "potassium", + "k": "potassium", +} +COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium") +VALUES_LIST_FIELDS = [ + ("moisture", "Moisture", "%"), + ("temperature", "Temperature", "°C"), + ("humidity", "Humidity", "%"), + ("ph", "pH", "pH"), + ("ec", "EC", "dS/m"), + ("nitrogen", "Nitrogen", "mg/kg"), + ("phosphorus", "Phosphorus", "mg/kg"), + ("potassium", "Potassium", "mg/kg"), +] +RADAR_CHART_FIELDS = [ + ("moisture", "Moisture", 60.0), + ("temperature", "Temperature", 26.0), + ("humidity", "Humidity", 55.0), + ("ph", "PH", 6.5), + ("ec", "EC", 1.3), + ("nitrogen", "Nitrogen", 42.0), + ("potassium", "Potassium", 38.0), +] def _to_float(value): @@ -107,6 +162,16 @@ def _extract_payload(payload): return payload +def _extract_numeric_payload(payload): + payload = _extract_payload(payload) + numeric_payload = {} + for key, value in payload.items(): + numeric_value = _to_float(value) + if numeric_value is not None: + numeric_payload[key] = numeric_value + return numeric_payload + + def _extract_readings(payload): payload = _extract_payload(payload) readings = {} @@ -151,46 +216,69 @@ def _get_sensor_context(farm=None): if farm is None: return None + primary_sensor = get_primary_soil_sensor(farm=farm) + if primary_sensor is None: + return None + try: - logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid) + 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 - candidate_log = None - candidate_readings = {} - for log in logs_queryset[:MAX_HISTORY_ITEMS]: - readings = _extract_readings(log.payload) - if len(readings) >= MIN_REQUIRED_SENSOR_FIELDS: - candidate_log = log - candidate_readings = readings - break - - if candidate_log is None: - return None - history = [] - for log in logs_queryset.filter(physical_device_uuid=candidate_log.physical_device_uuid)[:MAX_HISTORY_ITEMS]: + for log in logs_queryset[:MAX_HISTORY_ITEMS]: readings = _extract_readings(log.payload) if readings: history.append((log, readings)) if not history: - history = [(candidate_log, candidate_readings)] + return None - farm_sensor_map = get_farm_sensor_map_for_logs(logs=[candidate_log]) + latest_log, latest_readings = history[0] + farm_sensor_map = get_farm_sensor_map_for_logs(logs=[latest_log]) farm_sensor = farm_sensor_map.get( - (candidate_log.farm_uuid, candidate_log.sensor_catalog_uuid, candidate_log.physical_device_uuid) - ) + (latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid) + ) or primary_sensor return { "farm_sensor": farm_sensor, - "latest_log": history[0][0], - "latest_readings": history[0][1], + "latest_log": latest_log, + "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history, } +def get_primary_soil_sensor(*, farm): + soil_sensors = list( + farm.sensors.select_related("sensor_catalog") + .order_by("created_at", "id") + ) + + def _sensor_priority(sensor): + sensor_type = (sensor.sensor_type or "").lower() + catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower() + catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower() + sensor_name = (sensor.name or "").lower() + haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name]) + + if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type: + return 0 + if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack: + return 1 + if "soil" in haystack: + return 2 + return 3 + + prioritized_sensors = sorted(soil_sensors, key=_sensor_priority) + if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3: + return prioritized_sensors[0] + return soil_sensors[0] if soil_sensors else None + + def _build_sensor_meta(context, fallback_sensor): sensor = deepcopy(fallback_sensor) if not context: @@ -434,3 +522,183 @@ def get_sensor_7_in_1_summary_data(farm=None): "anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context), "soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context), } + + +def _normalize_comparison_chart_field(field_name): + return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name) + + +def _format_comparison_category(bucket_date, range_value): + if range_value == "7d": + return PERSIAN_WEEKDAYS[bucket_date.weekday()] + return bucket_date.strftime("%m/%d") + + +def _format_percent_change(current_value, baseline_value): + if not baseline_value: + return "+0.0%" + percent_change = ((current_value - baseline_value) / baseline_value) * 100 + return f"{percent_change:+.1f}%" + + +def _format_current_value_subtitle(title, value, unit): + rendered_value = _format_value(value, unit) + return f"مقدار فعلی: {rendered_value or title}" + + +def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value): + days = COMPARISON_CHART_RANGES[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%"} + + grouped_logs = {} + for log in reversed(list(logs_queryset[: days * 24])): + bucket_date = timezone.localtime(log.created_at).date() + numeric_payload = _extract_numeric_payload(log.payload) + if not numeric_payload: + continue + grouped_logs[bucket_date] = numeric_payload + + if not grouped_logs: + return {"series": [], "categories": [], "currentValue": 0.0, "vsLastWeek": "+0.0%"} + + sorted_dates = sorted(grouped_logs.keys()) + categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] + + series_map = {} + for bucket_date in sorted_dates: + payload = grouped_logs[bucket_date] + normalized_payload = {} + for key, value in payload.items(): + normalized_key = _normalize_comparison_chart_field(key) + normalized_payload[normalized_key] = value + for key, value in normalized_payload.items(): + series_map.setdefault(key, []).append(round(value, 2)) + + ordered_field_names = [ + field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map + ] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS) + + series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names] + primary_field = ordered_field_names[0] + primary_data = series_map[primary_field] + + return { + "series": series, + "categories": categories, + "currentValue": round(primary_data[-1], 2), + "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]), + } + + +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": []} + + 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": []} + logs = [latest_log] + + earliest_payload = {} + latest_payload = {} + for log in logs: + numeric_payload = { + _normalize_comparison_chart_field(key): value + for key, value in _extract_numeric_payload(log.payload).items() + } + if not numeric_payload: + continue + if not earliest_payload: + earliest_payload = numeric_payload + latest_payload = numeric_payload + + if not latest_payload: + return {"sensors": []} + + sensors = [] + for field_name, title, unit in VALUES_LIST_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + + previous_value = earliest_payload.get(field_name, current_value) + delta = round(current_value - previous_value, 2) + sensors.append( + { + "title": title, + "subtitle": _format_current_value_subtitle(title, current_value, unit), + "trendNumber": abs(delta), + "trend": "positive" if delta >= 0 else "negative", + "unit": unit, + } + ) + + return {"sensors": sensors} + + +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": []} + + 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": []} + logs = [latest_log] + + latest_payload = {} + for log in logs: + numeric_payload = { + _normalize_comparison_chart_field(key): value + for key, value in _extract_numeric_payload(log.payload).items() + } + if numeric_payload: + latest_payload = numeric_payload + + if not latest_payload: + return {"labels": [], "series": []} + + labels = [] + current_data = [] + ideal_data = [] + for field_name, label, ideal_value in RADAR_CHART_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + labels.append(label) + current_data.append(round(current_value, 2)) + ideal_data.append(round(ideal_value, 2)) + + return { + "labels": labels, + "series": [ + {"name": "وضعیت فعلی", "data": current_data}, + {"name": "بازه ایده آل", "data": ideal_data}, + ], + } diff --git a/sensor_7_in_1/tests.py b/sensor_7_in_1/tests.py index 57fde42..b88e10d 100644 --- a/sensor_7_in_1/tests.py +++ b/sensor_7_in_1/tests.py @@ -1,5 +1,7 @@ +from datetime import datetime, timedelta, timezone as dt_timezone + from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate from farm_hub.models import FarmHub, FarmSensor, FarmType @@ -8,8 +10,20 @@ from sensor_external_api.models import SensorExternalRequestLog from dashboard.services import get_farm_dashboard_cards -from .services import get_sensor_7_in_1_summary_data -from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView +from .seeds import seed_sensor_7_in_1_demo_data +from .services import ( + get_sensor_7_in_1_summary_data, + get_sensor_comparison_chart_data, + get_primary_soil_sensor, + get_sensor_radar_chart_data, + get_sensor_values_list_data, +) +from .views import ( + Sensor7In1SummaryView, + SensorComparisonChartView, + SensorRadarChartView, + SensorValuesListView, +) class Sensor7In1BaseTestCase(TestCase): @@ -48,6 +62,13 @@ class Sensor7In1BaseTestCase(TestCase): name="Soil Sensor 7-in-1", sensor_type="soil_7_in_1", ) + self.chart_sensor = FarmSensor.objects.create( + farm=self.farm, + sensor_catalog=self.sensor_catalog, + physical_device_uuid="44444444-4444-4444-4444-444444444444", + name="Comparison Sensor 7-in-1", + sensor_type="soil_7_in_1", + ) SensorExternalRequestLog.objects.create( farm_uuid=self.farm.farm_uuid, sensor_catalog_uuid=self.sensor_catalog.uuid, @@ -76,9 +97,31 @@ class Sensor7In1BaseTestCase(TestCase): "potassium": 24.0, }, ) + now_utc = datetime.now(dt_timezone.utc) + base_time = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) + for index, moisture in enumerate([56, 58, 55, 60, 62, 61, 59]): + log = SensorExternalRequestLog.objects.create( + farm_uuid=self.farm.farm_uuid, + sensor_catalog_uuid=self.sensor_catalog.uuid, + physical_device_uuid=self.chart_sensor.physical_device_uuid, + payload={ + "moisture": moisture, + "temperature": round(26.2 + (index * 0.2), 1), + "humidity": 50 + index, + }, + ) + SensorExternalRequestLog.objects.filter(id=log.id).update( + created_at=base_time - timedelta(days=6 - index) + ) class Sensor7In1ServiceTests(Sensor7In1BaseTestCase): + def test_primary_sensor_prefers_7_in_1_sensor_over_generic_soil_probe(self): + sensor = get_primary_soil_sensor(farm=self.farm) + + self.assertEqual(sensor.id, self.sensor.id) + self.assertEqual(str(sensor.physical_device_uuid), str(self.sensor.physical_device_uuid)) + def test_summary_returns_latest_specific_sensor_data(self): data = get_sensor_7_in_1_summary_data(self.farm) @@ -98,6 +141,52 @@ class Sensor7In1ServiceTests(Sensor7In1BaseTestCase): self.assertEqual(cards["soilMoistureHeatmap"]["series"][0]["name"], "Soil Sensor 7-in-1") self.assertEqual(cards["farmOverviewKpis"]["kpis"][2]["stats"], "48.5%") + def test_comparison_chart_service_returns_raw_chart_data(self): + data = get_sensor_comparison_chart_data( + farm=self.farm, + physical_device_uuid=self.sensor.physical_device_uuid, + range_value="7d", + ) + + self.assertEqual(data["series"][0]["name"], "moisture") + self.assertEqual(data["series"][0]["data"], [41.0, 48.5]) + self.assertEqual(data["currentValue"], 48.5) + self.assertEqual(data["vsLastWeek"], "+18.3%") + self.assertEqual(len(data["categories"]), 2) + + def test_values_list_service_returns_formatted_sensor_items(self): + data = get_sensor_values_list_data( + farm=self.farm, + physical_device_uuid=self.sensor.physical_device_uuid, + range_value="7d", + ) + + self.assertEqual(data["sensors"][0]["title"], "Moisture") + self.assertEqual(data["sensors"][0]["subtitle"], "مقدار فعلی: 48.5%") + self.assertEqual(data["sensors"][0]["trendNumber"], 7.5) + self.assertEqual(data["sensors"][0]["trend"], "positive") + self.assertEqual(data["sensors"][1]["title"], "Temperature") + self.assertEqual(data["sensors"][1]["subtitle"], "مقدار فعلی: 23.2°C") + self.assertEqual(data["sensors"][1]["trend"], "positive") + + def test_radar_chart_service_returns_aligned_labels_and_series(self): + data = get_sensor_radar_chart_data( + farm=self.farm, + physical_device_uuid=self.sensor.physical_device_uuid, + range_value="7d", + ) + + self.assertEqual( + data["labels"], + ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"], + ) + self.assertEqual(data["series"][0]["name"], "وضعیت فعلی") + self.assertEqual(data["series"][0]["data"], [48.5, 23.2, 6.8, 1.4, 31.0, 24.0]) + self.assertEqual(data["series"][1]["name"], "بازه ایده آل") + self.assertEqual(data["series"][1]["data"], [60.0, 26.0, 6.5, 1.3, 42.0, 38.0]) + self.assertEqual(len(data["labels"]), len(data["series"][0]["data"])) + self.assertEqual(len(data["labels"]), len(data["series"][1]["data"])) + class Sensor7In1ViewTests(Sensor7In1BaseTestCase): def test_summary_view_returns_sensor_cards(self): @@ -119,22 +208,74 @@ class Sensor7In1ViewTests(Sensor7In1BaseTestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.data["farm_uuid"][0], "This field is required.") - def test_radar_chart_view_returns_sensor_chart(self): - request = self.factory.get(f"/api/sensor-7-in-1/sensor-radar-chart/?farm_uuid={self.farm.farm_uuid}") + def test_sensor_comparison_chart_view_returns_raw_payload(self): + request = self.factory.get( + ( + "/api/sensors/comparison-chart/" + f"?farm_uuid={self.farm.farm_uuid}" + ) + ) force_authenticate(request, user=self.user) - response = Sensor7In1RadarChartView.as_view()(request) + response = SensorComparisonChartView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["code"], 200) - self.assertEqual(response.data["data"]["series"][0]["name"], "اکنون") + self.assertIn("series", response.data) + self.assertNotIn("code", response.data) + self.assertEqual(response.data["currentValue"], 48.5) + self.assertEqual(response.data["vsLastWeek"], "+18.3%") - def test_comparison_chart_view_returns_sensor_chart(self): - request = self.factory.get(f"/api/sensor-7-in-1/sensor-comparison-chart/?farm_uuid={self.farm.farm_uuid}") + def test_sensor_values_list_view_returns_raw_payload(self): + request = self.factory.get( + ( + "/api/sensors/values-list/" + f"?farm_uuid={self.farm.farm_uuid}" + ) + ) force_authenticate(request, user=self.user) - response = Sensor7In1ComparisonChartView.as_view()(request) + response = SensorValuesListView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["code"], 200) - self.assertEqual(response.data["data"]["currentValue"], 48.5) + self.assertEqual(response.data["sensors"][0]["title"], "Moisture") + self.assertEqual(response.data["sensors"][0]["trendNumber"], 7.5) + self.assertEqual(response.data["sensors"][0]["trend"], "positive") + + def test_sensor_radar_chart_view_returns_raw_payload(self): + request = self.factory.get( + ( + "/api/sensors/radar-chart/" + f"?farm_uuid={self.farm.farm_uuid}" + ) + ) + force_authenticate(request, user=self.user) + + response = SensorRadarChartView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["labels"], ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"]) + self.assertEqual(response.data["series"][0]["name"], "وضعیت فعلی") + self.assertEqual(response.data["series"][1]["name"], "بازه ایده آل") + self.assertEqual(len(response.data["labels"]), len(response.data["series"][0]["data"])) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class Sensor7In1SeedTests(TestCase): + def test_seed_sensor_7_in_1_demo_data_creates_idempotent_sensor_logs(self): + first_result = seed_sensor_7_in_1_demo_data() + second_result = seed_sensor_7_in_1_demo_data() + + sensor = second_result["sensor"] + logs = SensorExternalRequestLog.objects.filter( + farm_uuid=second_result["farm"].farm_uuid, + physical_device_uuid=sensor.physical_device_uuid, + ) + + self.assertTrue(SensorCatalog.objects.filter(code="sensor-7-in-1").exists()) + self.assertEqual(first_result["farm"].id, second_result["farm"].id) + self.assertEqual(first_result["sensor"].id, second_result["sensor"].id) + self.assertEqual(logs.count(), 7) + self.assertEqual(logs.first().payload["soil_moisture"], 52.4) diff --git a/sensor_7_in_1/urls.py b/sensor_7_in_1/urls.py index b4e8fca..d30047a 100644 --- a/sensor_7_in_1/urls.py +++ b/sensor_7_in_1/urls.py @@ -1,14 +1,10 @@ from django.urls import path -from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView +from .views import ( + Sensor7In1SummaryView, +) urlpatterns = [ path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), - path("sensor-radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"), - path( - "sensor-comparison-chart/", - Sensor7In1ComparisonChartView.as_view(), - name="sensor-7-in-1-comparison-chart", - ), ] diff --git a/sensor_7_in_1/views.py b/sensor_7_in_1/views.py index b34ffd9..6764064 100644 --- a/sensor_7_in_1/views.py +++ b/sensor_7_in_1/views.py @@ -1,18 +1,30 @@ from rest_framework import serializers, status +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import extend_schema from config.swagger import code_response, farm_uuid_query_param from farm_hub.models import FarmHub from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer -from .serializers import Sensor7In1SummarySerializer +from .serializers import ( + Sensor7In1SummarySerializer, + SensorComparisonChartQuerySerializer, + SensorComparisonChartResponseSerializer, + SensorRadarChartQuerySerializer, + SensorRadarChartResponseSerializer, + SensorValuesListQuerySerializer, + SensorValuesListResponseSerializer, +) from .services import ( + get_sensor_comparison_chart_data, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, + get_primary_soil_sensor, + get_sensor_radar_chart_data, + get_sensor_values_list_data, ) @@ -30,6 +42,13 @@ class Sensor7In1SummaryView(APIView): except FarmHub.DoesNotExist as exc: raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + @staticmethod + def _get_primary_sensor(*, farm): + sensor = get_primary_soil_sensor(farm=farm) + if sensor is None: + raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]}) + return sensor + @extend_schema( tags=["Sensor 7 in 1"], parameters=[ @@ -75,3 +94,90 @@ class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): {"code": 200, "msg": "OK", "data": get_sensor_7_in_1_comparison_chart_data(farm)}, status=status.HTTP_200_OK, ) + + +class SensorComparisonChartView(Sensor7In1SummaryView): + @extend_schema( + tags=["Sensor 7 in 1"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm."), + OpenApiParameter( + name="range", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description="Chart range, supported values: 7d, 30d. Defaults to 7d.", + ), + ], + responses={200: SensorComparisonChartResponseSerializer}, + ) + def get(self, request): + serializer = SensorComparisonChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + data = get_sensor_comparison_chart_data( + farm=farm, + physical_device_uuid=sensor.physical_device_uuid, + range_value=serializer.validated_data["range"], + ) + return Response(data, status=status.HTTP_200_OK) + + +class SensorValuesListView(Sensor7In1SummaryView): + @extend_schema( + tags=["Sensor 7 in 1"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm."), + OpenApiParameter( + name="range", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.", + ), + ], + responses={200: SensorValuesListResponseSerializer}, + ) + def get(self, request): + serializer = SensorValuesListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + data = get_sensor_values_list_data( + farm=farm, + physical_device_uuid=sensor.physical_device_uuid, + range_value=serializer.validated_data["range"], + ) + return Response(data, status=status.HTTP_200_OK) + + +class SensorRadarChartView(Sensor7In1SummaryView): + @extend_schema( + tags=["Sensor 7 in 1"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm."), + OpenApiParameter( + name="range", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.", + ), + ], + responses={200: SensorRadarChartResponseSerializer}, + ) + def get(self, request): + serializer = SensorRadarChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + data = get_sensor_radar_chart_data( + farm=farm, + physical_device_uuid=sensor.physical_device_uuid, + range_value=serializer.validated_data["range"], + ) + return Response(data, status=status.HTTP_200_OK) diff --git a/sensor_catalog/SensorCatalogListView.md b/sensor_catalog/SensorCatalogListView.md new file mode 100644 index 0000000..3644b0e --- /dev/null +++ b/sensor_catalog/SensorCatalogListView.md @@ -0,0 +1,302 @@ +# مستند API کاتالوگ سنسورها + +این فایل API ثبت‌شده در `sensor_catalog/urls.py` را به‌صورت کامل توضیح می‌دهد. + +## فایل route + +فایل route این app: + +`sensor_catalog/urls.py` + +محتوای آن: + +```python +from django.urls import path + +from .views import SensorCatalogListView + +urlpatterns = [ + path("", SensorCatalogListView.as_view(), name="sensor-catalog-list"), +] +``` + +## آدرس نهایی endpoint + +این route در `config/urls.py` این‌طور mount شده است: + +```python +path("api/sensor-catalog/", include("sensor_catalog.urls")) +``` + +پس آدرس نهایی API این است: + +`GET /api/sensor-catalog/` + +## هدف API + +این endpoint برای گرفتن لیست کاتالوگ سنسورها استفاده می‌شود. + +منظور از کاتالوگ سنسور، تعریف مرجع هر نوع سنسور است؛ مثلا: + +- کد سنسور +- نام سنسور +- توضیحات +- فیلدهای خروجی سنسور +- نمونه payload +- منبع تغذیه‌های پشتیبانی‌شده + +این API بیشتر برای frontend یا تنظیمات سیستم مفید است تا بداند چه نوع سنسورهایی در سیستم تعریف شده‌اند و هر سنسور چه ساختاری دارد. + +## View مربوطه + +این endpoint در فایل `sensor_catalog/views.py` پیاده‌سازی شده است: + +```python +class SensorCatalogListView(APIView): + permission_classes = [IsAuthenticated] +``` + +این یعنی: + +- فقط متد `GET` پشتیبانی می‌شود +- کاربر باید authenticated باشد + +## احراز هویت و دسترسی + +این View از: + +```python +permission_classes = [IsAuthenticated] +``` + +استفاده می‌کند. + +در این پروژه به‌صورت پیش‌فرض authentication از طریق JWT انجام می‌شود، چون در `config/settings.py` مقدار زیر تعریف شده: + +```python +"DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", +] +``` + +پس اگر کاربر توکن معتبر نداشته باشد، این API پاسخ `401 Unauthorized` برمی‌گرداند. + +## رفتار endpoint + +در متد `get` این View: + +```python +def get(self, request): + sensors = SensorCatalog.objects.order_by("code") + data = SensorCatalogSerializer(sensors, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) +``` + +این منطق اجرا می‌شود: + +1. همه رکوردهای `SensorCatalog` از دیتابیس خوانده می‌شوند +2. خروجی بر اساس `code` مرتب می‌شود +3. داده‌ها با serializer به JSON تبدیل می‌شوند +4. پاسخ استاندارد با `code` و `msg` و `data` برگردانده می‌شود + +## مدل دیتابیس + +مدل این API در `sensor_catalog/models.py` قرار دارد: + +```python +class SensorCatalog(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + code = models.CharField(max_length=255, unique=True, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) + description = models.TextField(blank=True, default="") + customizable_fields = models.JSONField(default=list, blank=True) + supported_power_sources = models.JSONField(default=list, blank=True) + returned_data_fields = models.JSONField(default=list, blank=True) + sample_payload = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) +``` + +### معنی فیلدها + +- `uuid`: شناسه یکتا برای هر کاتالوگ +- `code`: کد یکتا و فنی سنسور +- `name`: نام قابل نمایش سنسور +- `description`: توضیح سنسور +- `customizable_fields`: فیلدهایی که موقع ساخت/پیکربندی سنسور ممکن است قابل تنظیم باشند +- `supported_power_sources`: نوع منبع تغذیه‌های پشتیبانی‌شده +- `returned_data_fields`: فیلدهایی که این سنسور در payload خود برمی‌گرداند +- `sample_payload`: یک نمونه payload برای درک ساختار داده +- `is_active`: فعال یا غیرفعال بودن این کاتالوگ +- `created_at` و `updated_at`: زمان ایجاد و آخرین بروزرسانی + +## Serializer خروجی + +serializer این endpoint در `sensor_catalog/serializers.py` تعریف شده است: + +```python +class SensorCatalogSerializer(serializers.ModelSerializer): + class Meta: + model = SensorCatalog + fields = [ + "uuid", + "code", + "name", + "description", + "customizable_fields", + "supported_power_sources", + "returned_data_fields", + "sample_payload", + "is_active", + ] + read_only_fields = fields +``` + +### نکته مهم + +این serializer فقط این فیلدها را در خروجی برمی‌گرداند: + +- `uuid` +- `code` +- `name` +- `description` +- `customizable_fields` +- `supported_power_sources` +- `returned_data_fields` +- `sample_payload` +- `is_active` + +پس فیلدهای `created_at` و `updated_at` در پاسخ این API نیستند. + +## ورودی API + +این endpoint ورودی body یا query param خاصی ندارد. + +فقط کافی است کاربر authenticated باشد. + +### نمونه درخواست + +```http +GET /api/sensor-catalog/ +Authorization: Bearer +``` + +## خروجی موفق + +نمونه پاسخ موفق: + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "code": "sensor_7_soil_moisture_sensor_v1_2", + "name": "Sensor 7 - Soil Moisture Sensor v1.2", + "description": "Measures only soil moisture using electrical resistance between two metal probes.", + "customizable_fields": [], + "supported_power_sources": ["solar", "direct_power"], + "returned_data_fields": ["soil_moisture", "analog_output", "digital_output"], + "sample_payload": { + "soil_moisture": 42, + "analog_output": 610, + "digital_output": 1 + }, + "is_active": true + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "code": "legacy_sensor", + "name": "Legacy Sensor", + "description": "", + "customizable_fields": [], + "supported_power_sources": ["direct_power"], + "returned_data_fields": ["status"], + "sample_payload": { + "status": "offline" + }, + "is_active": false + } + ] +} +``` + +## ترتیب خروجی + +خروجی با این دستور مرتب می‌شود: + +```python +SensorCatalog.objects.order_by("code") +``` + +یعنی لیست همیشه بر اساس `code` به صورت صعودی برگردانده می‌شود. + +## سناریوهای کاربردی + +این API معمولا برای این موارد استفاده می‌شود: + +- ساخت dropdown برای انتخاب نوع سنسور +- نمایش ساختار داده قابل انتظار از یک سنسور +- فهمیدن اینکه هر سنسور چه فیلدهایی برمی‌گرداند +- ساخت فرم‌های داینامیک برای پیکربندی سنسور +- نمایش `sample_payload` در Swagger یا UI مدیریتی + +## وضعیت‌های خطا + +### 401 Unauthorized + +اگر کاربر login نباشد یا توکن معتبر نداشته باشد. + +### 200 با لیست خالی + +اگر هیچ رکوردی در جدول `sensor_catalogs` وجود نداشته باشد، پاسخ موفق است اما `data` خالی خواهد بود: + +```json +{ + "code": 200, + "msg": "success", + "data": [] +} +``` + +## تست موجود + +برای این endpoint تست در فایل `sensor_catalog/tests.py` وجود دارد. + +تست اصلی بررسی می‌کند که: + +- کاربر authenticated بتواند endpoint را صدا بزند +- پاسخ `200` باشد +- همه سنسورهای موجود برگردانده شوند + +نمونه assertion: + +```python +self.assertEqual(response.status_code, 200) +self.assertEqual(response.data["code"], 200) +self.assertEqual(len(response.data["data"]), 2) +``` + +## خلاصه + +API موجود در `sensor_catalog/urls.py` فقط یک endpoint دارد: + +- `GET /api/sensor-catalog/` + +این endpoint: + +- نیاز به احراز هویت دارد +- همه کاتالوگ‌های سنسور را از دیتابیس می‌خواند +- آن‌ها را بر اساس `code` مرتب می‌کند +- اطلاعات ساختاری سنسورها را برای frontend یا پنل مدیریتی برمی‌گرداند + +## فایل‌های مرتبط + +- `sensor_catalog/urls.py` +- `sensor_catalog/views.py` +- `sensor_catalog/serializers.py` +- `sensor_catalog/models.py` +- `sensor_catalog/tests.py` +- `config/urls.py` diff --git a/sensor_external_api/SensorExternalAPIView.md b/sensor_external_api/SensorExternalAPIView.md new file mode 100644 index 0000000..acc8eef --- /dev/null +++ b/sensor_external_api/SensorExternalAPIView.md @@ -0,0 +1,356 @@ +# مستند API دریافت داده سنسور خارجی + +این فایل رفتار endpoint زیر را توضیح می‌دهد: + +`POST /api/sensor-external-api/` + +این API برای دریافت payload از یک سنسور فیزیکی، ثبت آن داخل دیتابیس، ساخت نوتیفیکیشن برای مزرعه، و سپس ارسال همان داده به سرویس AI/Farm Data استفاده می‌شود. + +## هدف API + +این endpoint وقتی صدا زده می‌شود که یک سنسور خارجی داده جدیدی تولید کرده باشد. بک‌اند در این مسیر چند کار پشت سر هم انجام می‌دهد: + +1. اعتبارسنجی API key +2. اعتبارسنجی `uuid` و `payload` +3. پیدا کردن سنسور بر اساس `physical_device_uuid` +4. ذخیره لاگ درخواست در جدول `sensor_external_request_logs` +5. ساخت notification برای مزرعه +6. ارسال داده به سرویس AI در endpoint مربوط به farm data + +## مسیر و View + +این endpoint در فایل `sensor_external_api/urls.py` ثبت شده است: + +```python +path("", SensorExternalAPIView.as_view(), name="sensor-external-api") +``` + +پیاده‌سازی view در فایل `sensor_external_api/views.py` قرار دارد: + +```python +class SensorExternalAPIView(APIView): + authentication_classes = [SensorExternalAPIKeyAuthentication] + permission_classes = [AllowAny] +``` + +## احراز هویت + +این API از هدر `X-API-Key` استفاده می‌کند. + +کلاس احراز هویت: + +`sensor_external_api/authentication.py` + +رفتار آن: + +- اگر `X-API-Key` یا `Authorization` ارسال نشود، پاسخ `401` می‌دهد. +- اگر مقدار کلید اشتباه باشد، پاسخ `401` می‌دهد. +- مقدار مورد انتظار از `SENSOR_EXTERNAL_API_KEY` خوانده می‌شود. + +## ورودی درخواست + +serializer ورودی در فایل `sensor_external_api/serializers.py` تعریف شده است: + +```python +class SensorExternalRequestSerializer(serializers.Serializer): + uuid = serializers.UUIDField() + payload = serializers.JSONField(required=False, default=dict) +``` + +### بدنه نمونه درخواست + +```json +{ + "uuid": "22222222-2222-2222-2222-222222222222", + "payload": { + "moisture_percent": 32.5, + "temperature_c": 21.3, + "ph": 6.7, + "ec_ds_m": 1.1, + "nitrogen_mg_kg": 42, + "phosphorus_mg_kg": 18, + "potassium_mg_kg": 210 + } +} +``` + +نکته: + +- `uuid` در این API همان `physical_device_uuid` سنسور است. +- `payload` به همان شکلی که از سنسور می‌آید ذخیره و forward می‌شود. + +## روند اجرای API + +### 1) اعتبارسنجی request + +در متد `post` ابتدا داده ورودی validate می‌شود: + +```python +serializer = SensorExternalRequestSerializer(data=request.data) +serializer.is_valid(raise_exception=True) +``` + +اگر `uuid` معتبر نباشد یا ساختار body خراب باشد، DRF خطای `400` برمی‌گرداند. + +### 2) ثبت لاگ و ساخت نوتیفیکیشن + +سپس این سرویس صدا زده می‌شود: + +```python +notification = create_sensor_external_notification( + physical_device_uuid=serializer.validated_data["uuid"], + payload=serializer.validated_data.get("payload"), +) +``` + +این تابع در فایل `sensor_external_api/services.py` قرار دارد. + +کارهایی که انجام می‌دهد: + +- سنسور را از جدول `FarmSensor` با `physical_device_uuid` پیدا می‌کند. +- اگر سنسور پیدا نشود، `ValueError("Physical device not found.")` می‌دهد. +- یک رکورد در جدول `sensor_external_request_logs` می‌سازد. +- یک notification برای مزرعه می‌سازد. + +### رکوردی که در دیتابیس ذخیره می‌شود + +مدل ذخیره‌سازی: + +`sensor_external_api/models.py` + +```python +class SensorExternalRequestLog(models.Model): + farm_uuid = models.UUIDField(db_index=True) + sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) + physical_device_uuid = models.UUIDField(db_index=True) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) +``` + +یعنی payload خام سنسور برای گزارش‌گیری و استفاده‌های بعدی نگه داشته می‌شود. + +### 3) ارسال داده به سرویس AI / Farm Data + +بعد از ثبت لاگ، این سرویس صدا زده می‌شود: + +```python +forward_sensor_payload_to_farm_data( + physical_device_uuid=serializer.validated_data["uuid"], + payload=serializer.validated_data.get("payload"), +) +``` + +این قسمت مهم‌ترین call خارجی endpoint است. + +## این API چه آدرسی از AI را صدا می‌زند؟ + +سرویس خارجی از طریق `external_api_adapter.request` صدا زده می‌شود: + +```python +response = external_api_request( + "ai", + _get_farm_data_path(), + method="POST", + payload=request_payload, + headers={...}, +) +``` + +### service name + +مقدار service برابر است با: + +`"ai"` + +یعنی این درخواست به سرویسی می‌رود که در تنظیمات به عنوان AI service تعریف شده است. + +### base URL سرویس AI + +در `config/settings.py`: + +```python +"ai": { + "base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"), + "api_key": os.getenv("AI_SERVICE_API_KEY", ""), + "host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"), +} +``` + +پس base URL به‌صورت پیش‌فرض این است: + +`http://ai-web:8000` + +### path مقصد + +path از این تنظیم خوانده می‌شود: + +```python +FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/") +``` + +پس path پیش‌فرض این است: + +`/api/farm-data/` + +### آدرس نهایی که صدا زده می‌شود + +در حالت پیش‌فرض، آدرس نهایی به این صورت است: + +`POST http://ai-web:8000/api/farm-data/` + +اگر متغیرهای environment تغییر کرده باشند، این آدرس هم تغییر می‌کند. + +## چرا این آدرس صدا زده می‌شود؟ + +هدف از این call این است که داده سنسور خام فقط در بک‌اند ذخیره نشود، بلکه برای پردازش downstream هم به سرویس AI/Farm Data فرستاده شود. + +این سرویس AI احتمالا برای کارهای زیر استفاده می‌شود: + +- تحلیل داده سنسورها در سطح مزرعه +- ساخت داده تجمیعی farm data +- تغذیه dashboardها و مدل‌های AI +- محاسبه شاخص‌ها یا توصیه‌های بعدی + +خود این endpoint در این پروژه فقط داده را forward می‌کند و پردازش AI داخل همین اپ انجام نمی‌شود. + +## چه payloadی به AI ارسال می‌شود؟ + +قبل از ارسال، بک‌اند این ساختار را می‌سازد: + +```python +request_payload = { + "farm_uuid": str(sensor.farm.farm_uuid), + "farm_boundary": farm_boundary, + "sensor_payload": { + sensor.name or str(sensor.physical_device_uuid): payload, + }, +} +``` + +یعنی payload ارسال‌شده به AI دقیقا body اولیه کاربر نیست، بلکه این wrapper را دارد: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "farm_boundary": { + "type": "Polygon", + "coordinates": [[[51.39, 35.7], [51.41, 35.7], [51.41, 35.72], [51.39, 35.72], [51.39, 35.7]]] + }, + "sensor_payload": { + "Soil Sensor 7-in-1": { + "moisture_percent": 32.5, + "temperature_c": 21.3, + "ph": 6.7, + "ec_ds_m": 1.1, + "nitrogen_mg_kg": 42, + "phosphorus_mg_kg": 18, + "potassium_mg_kg": 210 + } + } +} +``` + +## farm_boundary از کجا می‌آید؟ + +سرویس `_get_farm_boundary` این منطق را دارد: + +- اگر `farm.current_crop_area` وجود داشته باشد، از آن استفاده می‌کند. +- اگر وجود نداشته باشد، آخرین crop area مزرعه را برمی‌دارد. +- اگر هیچ boundary وجود نداشته باشد، خطا می‌دهد. +- اگر geometry از نوع `Polygon` نباشد، خطا می‌دهد. + +پس سرویس AI فقط وقتی صدا زده می‌شود که مرز مزرعه معتبر وجود داشته باشد. + +## هدرهایی که به AI ارسال می‌شوند + +در زمان forward کردن، این هدرها ارسال می‌شوند: + +```python +headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", +} +``` + +`api_key` از این setting می‌آید: + +`FARM_DATA_API_KEY` + +اگر این مقدار ست نشده باشد، پاسخ `503` برمی‌گردد. + +## پاسخ موفق + +اگر همه چیز درست باشد: + +- لاگ ذخیره می‌شود +- notification ساخته می‌شود +- داده به AI forward می‌شود +- پاسخ `201` برمی‌گردد + +نمونه ساختار پاسخ: + +```json +{ + "code": 201, + "msg": "success", + "data": { + "...": "serialized notification object" + } +} +``` + +نکته: + +data خروجی این endpoint نتیجه AI نیست. خروجی، notification ساخته‌شده در سیستم خود بک‌اند است. + +## خطاهای ممکن + +### 401 Unauthorized + +اگر API key ارسال نشود یا اشتباه باشد. + +### 404 Not Found + +اگر `physical_device_uuid` در جدول `FarmSensor` پیدا نشود. + +پاسخ: + +```json +{ + "code": 404, + "msg": "Physical device not found." +} +``` + +### 503 Service Unavailable + +در چند حالت: + +- migration جدول‌ها انجام نشده باشد +- `FARM_DATA_API_KEY` تنظیم نشده باشد +- مرز مزرعه موجود نباشد +- geometry مزرعه `Polygon` نباشد +- سرویس AI در دسترس نباشد +- سرویس AI پاسخ خطای 4xx/5xx بدهد + +نمونه خطا: + +```json +{ + "code": 503, + "msg": "Farm data API request failed: connection error" +} +``` + +## خلاصه رفتاری endpoint + +`POST /api/sensor-external-api/` این کارها را انجام می‌دهد: + +1. داده سنسور را از بیرون می‌گیرد. +2. سنسور را با `physical_device_uuid` پیدا می‌کند. +3. payload را در جدول لاگ ذخیره می‌کند. +4. برای مزرعه notification می‌سازد. +5. داده را به سرویس AI در آدرس پیش‌فرض `POST http://ai-web:8000/api/farm-data/` می‌فرستد. +6. در نهایت نتیجه موفقیت را با notification برمی‌گرداند. diff --git a/sensor_external_api/serializers.py b/sensor_external_api/serializers.py index c3a9d60..7550a7a 100644 --- a/sensor_external_api/serializers.py +++ b/sensor_external_api/serializers.py @@ -13,8 +13,19 @@ class SensorExternalRequestSerializer(serializers.Serializer): class SensorExternalRequestLogQuerySerializer(serializers.Serializer): farm_uuid = serializers.UUIDField() - page = serializers.IntegerField(required=False, min_value=1, default=1) - page_size = serializers.IntegerField(required=False, min_value=1, max_value=100, default=20) + page = serializers.IntegerField(min_value=1) + page_size = serializers.IntegerField(min_value=1, max_value=100) + physical_device_uuid = serializers.UUIDField(required=False) + sensor_type = serializers.CharField(required=False, allow_blank=False) + date_from = serializers.DateField(required=False) + date_to = serializers.DateField(required=False) + + def validate(self, attrs): + date_from = attrs.get("date_from") + date_to = attrs.get("date_to") + if date_from and date_to and date_from > date_to: + raise serializers.ValidationError({"date_to": "date_to must be greater than or equal to date_from."}) + return attrs class SensorExternalRequestLogSerializer(serializers.ModelSerializer): @@ -36,14 +47,18 @@ class SensorExternalRequestLogSerializer(serializers.ModelSerializer): def get_farm_sensor(self, obj): farm_sensor_map = self.context.get("farm_sensor_map", {}) - farm_sensor = farm_sensor_map.get((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)) + farm_sensor = farm_sensor_map.get( + (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) + ) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) if farm_sensor is None: return None return FarmSensorLogSerializer(farm_sensor).data def get_sensor_catalog(self, obj): farm_sensor_map = self.context.get("farm_sensor_map", {}) - farm_sensor = farm_sensor_map.get((obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid)) + farm_sensor = farm_sensor_map.get( + (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) + ) or farm_sensor_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) if farm_sensor is None or farm_sensor.sensor_catalog is None: return None return SensorCatalogLogSerializer(farm_sensor.sensor_catalog).data diff --git a/sensor_external_api/services.py b/sensor_external_api/services.py index cff8d30..c765ee9 100644 --- a/sensor_external_api/services.py +++ b/sensor_external_api/services.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.db import OperationalError, ProgrammingError, transaction @@ -9,13 +11,41 @@ from notifications.services import create_notification_for_farm_uuid from .models import SensorExternalRequestLog +logger = logging.getLogger(__name__) + + class FarmDataForwardError(Exception): pass -def get_sensor_external_request_logs_for_farm(*, farm_uuid): +def get_sensor_external_request_logs_for_farm( + *, + farm_uuid, + physical_device_uuid=None, + sensor_type=None, + date_from=None, + date_to=None, +): try: - return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid).order_by("-created_at", "-id") + queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid) + + if physical_device_uuid: + queryset = queryset.filter(physical_device_uuid=physical_device_uuid) + + if sensor_type: + physical_device_uuids = FarmSensor.objects.filter( + farm__farm_uuid=farm_uuid, + sensor_type=sensor_type, + ).values_list("physical_device_uuid", flat=True) + queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids) + + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + + return queryset.order_by("-created_at", "-id") except (ProgrammingError, OperationalError) as exc: raise ValueError("Sensor external API tables are not migrated.") from exc @@ -37,12 +67,18 @@ def get_farm_sensor_map_for_logs(*, logs): farm_sensor_map = {} for farm_sensor in farm_sensor_queryset: - key = ( + exact_key = ( farm_sensor.farm.farm_uuid, farm_sensor.sensor_catalog.uuid if farm_sensor.sensor_catalog else None, farm_sensor.physical_device_uuid, ) - farm_sensor_map.setdefault(key, farm_sensor) + fallback_key = ( + farm_sensor.farm.farm_uuid, + None, + farm_sensor.physical_device_uuid, + ) + farm_sensor_map.setdefault(exact_key, farm_sensor) + farm_sensor_map.setdefault(fallback_key, farm_sensor) return farm_sensor_map except (ProgrammingError, OperationalError) as exc: @@ -66,12 +102,22 @@ def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, ph def create_sensor_external_notification(*, physical_device_uuid, payload=None): payload = payload or {} + logger.warning( + "Sensor external notification start: physical_device_uuid=%s payload_type=%s payload_keys=%s", + physical_device_uuid, + type(payload).__name__, + sorted(payload.keys()) if isinstance(payload, dict) else None, + ) sensor = ( FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog") .filter(physical_device_uuid=physical_device_uuid) .first() ) if sensor is None: + logger.error( + "Sensor external notification failed: physical device not found for uuid=%s", + physical_device_uuid, + ) raise ValueError("Physical device not found.") try: @@ -82,7 +128,7 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None): physical_device_uuid=sensor.physical_device_uuid, payload=payload, ) - return create_notification_for_farm_uuid( + notification = create_notification_for_farm_uuid( farm_uuid=sensor.farm.farm_uuid, title="Sensor external API request", message=f"Payload received from device {sensor.physical_device_uuid}.", @@ -94,32 +140,62 @@ def create_sensor_external_notification(*, physical_device_uuid, payload=None): "payload": payload, }, ) + logger.warning( + "Sensor external notification created: farm_uuid=%s sensor_catalog_uuid=%s physical_device_uuid=%s", + sensor.farm.farm_uuid, + sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, + sensor.physical_device_uuid, + ) + return notification except (ProgrammingError, OperationalError) as exc: + logger.exception( + "Sensor external notification failed due to database readiness: physical_device_uuid=%s", + physical_device_uuid, + ) raise ValueError("Sensor external API tables are not migrated.") from exc def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): payload = payload or {} sensor = ( - FarmSensor.objects.select_related("farm", "farm__current_crop_area") + FarmSensor.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog") .filter(physical_device_uuid=physical_device_uuid) .first() ) if sensor is None: + logger.error( + "Farm data forward failed: physical device not found for uuid=%s", + physical_device_uuid, + ) raise ValueError("Physical device not found.") farm_boundary = _get_farm_boundary(sensor=sensor) api_key = getattr(settings, "FARM_DATA_API_KEY", "") if not api_key: + logger.error( + "Farm data forward failed: FARM_DATA_API_KEY missing for farm_uuid=%s physical_device_uuid=%s", + sensor.farm.farm_uuid, + physical_device_uuid, + ) raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") + sensor_key = _get_sensor_key(sensor=sensor) + normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload) request_payload = { "farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, - "sensor_payload": { - sensor.name or str(sensor.physical_device_uuid): payload, - }, + "sensor_key": sensor_key, + "sensor_payload": normalized_sensor_payload, } + logger.warning( + "Farm data forward start: farm_uuid=%s physical_device_uuid=%s sensor_key=%s payload_keys=%s boundary_type=%s boundary_points=%s", + sensor.farm.farm_uuid, + physical_device_uuid, + sensor_key, + sorted(normalized_sensor_payload.keys()) if isinstance(normalized_sensor_payload, dict) else None, + farm_boundary.get("type") if isinstance(farm_boundary, dict) else None, + len(farm_boundary.get("coordinates", [[]])[0]) if isinstance(farm_boundary, dict) and farm_boundary.get("coordinates") else None, + ) try: response = external_api_request( @@ -135,20 +211,46 @@ def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): }, ) except ExternalAPIRequestError as exc: + logger.exception( + "Farm data forward request exception: farm_uuid=%s physical_device_uuid=%s sensor_key=%s", + sensor.farm.farm_uuid, + physical_device_uuid, + sensor_key, + ) raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc if response.status_code >= 400: response_body = response.data + logger.error( + "Farm data forward rejected: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s response=%s", + sensor.farm.farm_uuid, + physical_device_uuid, + sensor_key, + response.status_code, + response_body, + ) raise FarmDataForwardError( f"Farm data API returned status {response.status_code}: {response_body}" ) + logger.warning( + "Farm data forward success: farm_uuid=%s physical_device_uuid=%s sensor_key=%s status_code=%s", + sensor.farm.farm_uuid, + physical_device_uuid, + sensor_key, + response.status_code, + ) return request_payload def _get_farm_boundary(*, sensor): crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first() if crop_area is None: + logger.error( + "Farm data forward failed: no farm boundary configured for farm_uuid=%s physical_device_uuid=%s", + sensor.farm.farm_uuid, + sensor.physical_device_uuid, + ) raise FarmDataForwardError("Farm boundary is not configured for this farm.") geometry = crop_area.geometry or {} @@ -156,10 +258,33 @@ def _get_farm_boundary(*, sensor): geometry = geometry.get("geometry") or {} if geometry.get("type") != "Polygon": + logger.error( + "Farm data forward failed: invalid boundary geometry type=%s for farm_uuid=%s physical_device_uuid=%s", + geometry.get("type"), + sensor.farm.farm_uuid, + sensor.physical_device_uuid, + ) raise FarmDataForwardError("Farm boundary geometry must be a Polygon.") return geometry +def _normalize_sensor_payload(*, sensor_key, sensor_payload): + if not sensor_payload: + return {} + if not isinstance(sensor_payload, dict): + raise FarmDataForwardError("`payload` must be a JSON object.") + + if all(isinstance(value, dict) for value in sensor_payload.values()): + return sensor_payload + return {sensor_key: sensor_payload} + + +def _get_sensor_key(*, sensor): + if sensor.sensor_catalog and sensor.sensor_catalog.code: + return sensor.sensor_catalog.code + return "sensor-7-1" + + def _get_farm_data_path(): return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") diff --git a/sensor_external_api/tests.py b/sensor_external_api/tests.py index 7cd35f2..ec2fc10 100644 --- a/sensor_external_api/tests.py +++ b/sensor_external_api/tests.py @@ -1,6 +1,9 @@ +from datetime import datetime, timezone as dt_timezone + from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory +from rest_framework_simplejwt.tokens import AccessToken from unittest.mock import patch from external_api_adapter.adapter import AdapterResponse @@ -118,8 +121,9 @@ class SensorExternalAPIViewTests(TestCase): payload={ "farm_uuid": str(self.farm.farm_uuid), "farm_boundary": self.crop_area.geometry, + "sensor_key": self.sensor_catalog.code, "sensor_payload": { - "sensor-7-1": {"temp": 12}, + self.sensor_catalog.code: {"temp": 12}, }, }, headers={ @@ -202,6 +206,7 @@ class SensorExternalRequestLogListAPIViewTests(TestCase): email="sensor-external-log@example.com", phone_number="09120000016", ) + self.access_token = str(AccessToken.for_user(self.user)) self.farm_type = FarmType.objects.create(name="لاگ سنسور خارجی") self.farm = FarmHub.objects.create( owner=self.user, @@ -261,17 +266,31 @@ class SensorExternalRequestLogListAPIViewTests(TestCase): payload={"temp": 24}, ) - def test_requires_api_key(self): - request = self.factory.get(f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}") + def test_requires_bearer_token(self): + request = self.factory.get( + f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=20" + ) response = SensorExternalRequestLogListAPIView.as_view()(request) self.assertEqual(response.status_code, 401) + def test_requires_page_and_page_size(self): + request = self.factory.get( + f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}", + HTTP_AUTHORIZATION=f"Bearer {self.access_token}", + ) + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("page", response.data) + self.assertIn("page_size", response.data) + def test_returns_paginated_logs_for_farm_uuid(self): request = self.factory.get( f"/api/sensor-external-api/logs/?farm_uuid={self.farm_uuid}&page=1&page_size=1", - HTTP_X_API_KEY="12345", + HTTP_AUTHORIZATION=f"Bearer {self.access_token}", ) response = SensorExternalRequestLogListAPIView.as_view()(request) @@ -301,3 +320,61 @@ class SensorExternalRequestLogListAPIViewTests(TestCase): response.data["data"][0]["farm_sensor"]["physical_device_uuid"], str(self.second_sensor.physical_device_uuid), ) + self.assertEqual(response.data["data"][0]["payload"]["temp"], 18) + self.assertIsInstance(response.data["data"][0]["payload"]["temp"], int) + + def test_filters_logs_by_physical_device_uuid(self): + request = self.factory.get( + ( + "/api/sensor-external-api/logs/" + f"?farm_uuid={self.farm_uuid}" + f"&physical_device_uuid={self.first_sensor.physical_device_uuid}" + "&page=1&page_size=20" + ), + HTTP_AUTHORIZATION=f"Bearer {self.access_token}", + ) + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["data"][0]["id"], self.first_log.id) + + def test_filters_logs_by_sensor_type(self): + request = self.factory.get( + ( + "/api/sensor-external-api/logs/" + f"?farm_uuid={self.farm_uuid}" + "&sensor_type=soil_sensor" + "&page=1&page_size=20" + ), + HTTP_AUTHORIZATION=f"Bearer {self.access_token}", + ) + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["data"][0]["id"], self.second_log.id) + + def test_filters_logs_by_date_range(self): + older_timestamp = datetime(2025, 5, 1, 10, 0, tzinfo=dt_timezone.utc) + newer_timestamp = datetime(2025, 5, 2, 11, 0, tzinfo=dt_timezone.utc) + SensorExternalRequestLog.objects.filter(id=self.first_log.id).update(created_at=older_timestamp) + SensorExternalRequestLog.objects.filter(id=self.second_log.id).update(created_at=newer_timestamp) + + request = self.factory.get( + ( + "/api/sensor-external-api/logs/" + f"?farm_uuid={self.farm_uuid}" + "&date_from=2025-05-02&date_to=2025-05-02" + "&page=1&page_size=20" + ), + HTTP_AUTHORIZATION=f"Bearer {self.access_token}", + ) + + response = SensorExternalRequestLogListAPIView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["data"][0]["id"], self.second_log.id) diff --git a/sensor_external_api/views.py b/sensor_external_api/views.py index e0f0358..3757e7f 100644 --- a/sensor_external_api/views.py +++ b/sensor_external_api/views.py @@ -1,10 +1,12 @@ +import logging + from rest_framework import serializers, status from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema -from rest_framework.permissions import IsAuthenticated +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema from config.swagger import code_response from notifications.serializers import FarmNotificationSerializer @@ -24,6 +26,9 @@ from .services import ( ) +logger = logging.getLogger(__name__) + + class SensorExternalRequestLogPagination(PageNumberPagination): page_size = 20 page_size_query_param = "page_size" @@ -37,6 +42,24 @@ class SensorExternalAPIView(APIView): @extend_schema( tags=["Sensor External API"], request=SensorExternalRequestSerializer, + examples=[ + OpenApiExample( + "Sensor External API Request", + value={ + "uuid": "22222222-2222-2222-2222-222222222222", + "payload": { + "moisture_percent": 32.5, + "temperature_c": 21.3, + "ph": 6.7, + "ec_ds_m": 1.1, + "nitrogen_mg_kg": 42, + "phosphorus_mg_kg": 18, + "potassium_mg_kg": 210, + }, + }, + request_only=True, + ) + ], parameters=[ OpenApiParameter( name="X-API-Key", @@ -57,6 +80,13 @@ class SensorExternalAPIView(APIView): def post(self, request): serializer = SensorExternalRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) + logger.warning( + "Sensor external API POST received: uuid=%s payload_keys=%s", + serializer.validated_data["uuid"], + sorted(serializer.validated_data.get("payload", {}).keys()) + if isinstance(serializer.validated_data.get("payload"), dict) + else None, + ) try: notification = create_sensor_external_notification( @@ -69,18 +99,31 @@ class SensorExternalAPIView(APIView): ) except ValueError as exc: if "not migrated" in str(exc): + logger.exception( + "Sensor external API POST failed due to missing migrations: uuid=%s", + serializer.validated_data["uuid"], + ) return Response( {"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) + logger.exception( + "Sensor external API POST failed due to missing physical device: uuid=%s", + serializer.validated_data["uuid"], + ) return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) except FarmDataForwardError as exc: + logger.exception( + "Sensor external API POST failed while forwarding to farm data: uuid=%s", + serializer.validated_data["uuid"], + ) return Response( {"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) data = FarmNotificationSerializer(notification).data + logger.warning("Sensor external API POST succeeded: uuid=%s", serializer.validated_data["uuid"]) return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) @@ -93,9 +136,12 @@ class SensorExternalRequestLogListAPIView(APIView): tags=["Sensor External API"], parameters=[ OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), - OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False), - OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False), - + OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), + OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), + OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), ], responses={ 200: code_response( @@ -118,6 +164,10 @@ class SensorExternalRequestLogListAPIView(APIView): try: queryset = get_sensor_external_request_logs_for_farm( farm_uuid=serializer.validated_data["farm_uuid"], + physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), + sensor_type=serializer.validated_data.get("sensor_type"), + date_from=serializer.validated_data.get("date_from"), + date_to=serializer.validated_data.get("date_to"), ) except ValueError: return Response(