diff --git a/docs/dashboard_card_service_map.md b/docs/dashboard_card_service_map.md new file mode 100644 index 0000000..6be2b88 --- /dev/null +++ b/docs/dashboard_card_service_map.md @@ -0,0 +1,192 @@ +# نقشه سرویس کارت های داشبورد + +این فایل فقط برای پیدا کردن منبع داده هر کارت داشبورد نوشته شده است تا قبل از پیاده سازی API تجمیعی بدانیم هر کارت باید از کدام app و کدام service تغذیه شود. + +## نقطه شروع فعلی + +- تجمیع اصلی کارت ها الان در `dashboard/services.py` داخل تابع `get_farm_dashboard_cards` انجام می شود. +- endpoint فعلی ارسال کارت ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد. +- لیست کارت های معتبر در `dashboard/mock_data.py` داخل `VALID_CARD_IDS` نگهداری می شود. + +## جمع بندی سریع + +| Card ID | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح | +| --- | --- | --- | --- | --- | +| `farmOverviewKpis` | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | خودش داده مستقل ندارد و از چند app ساخته می شود | +| `farmWeatherCard` | آب و هوا | `get_farm_weather_card_data` | `water` | از لاگ پیش بینی هوا می آید | +| `farmAlertsTracker` | هشدارها | `get_alert_tracker_data` | `farm_alerts` | شمارش هشدارهای فعال | +| `sensorValuesList` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | لیست آخرین مقادیر سنسور | +| `sensorRadarChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | امتیازدهی راداری وضعیت سنسور | +| `sensorComparisonChart` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | مقایسه تاریخی رطوبت خاک | +| `anomalyDetectionCard` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ناهنجاری های خارج از بازه مجاز | +| `farmAlertsTimeline` | هشدارها | `get_alert_timeline_data` | `farm_alerts` | تایم لاین هشدارها | +| `waterNeedPrediction` | آبیاری | `get_water_need_prediction_data` | `water` | عملا از نتیجه آبیاری می خواند | +| `harvestPredictionCard` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | زمان برداشت و بازه بهینه | +| `yieldPredictionChart` | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | نمودار پیش بینی عملکرد | +| `soilMoistureHeatmap` | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | هیت مپ رطوبت خاک | +| `ndviHealthCard` | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | فعلا mock است | +| `recommendationsList` | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته می شود | +| `economicOverview` | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی و سری نمودار | + +## جزئیات هر کارت + +### 1) `farmOverviewKpis` + +این کارت در `dashboard/services.py` و توسط `_build_overview_kpis` ساخته می شود و داده اش از چند app می آید: + +- `crop_health.services.get_crop_health_summary_data` + - KPI: `farmHealthScore` +- `water.services.get_water_stress_index_data` + - KPI: `water_stress_index` +- `sensor_7_in_1.services.get_sensor_7_in_1_summary_data` + - KPI: `avgSoilMoisture` +- `pest_detection.services.get_risk_summary_data` + - KPI ها: `disease_risk` و `pest_risk` +- `yield_harvest.services.get_yield_harvest_summary_data` + - KPI: `yield_prediction_card` + +نتیجه: این بخش باید در API نهایی به صورت aggregator باقی بماند، چون منبع واحد ندارد. + +### 2) `farmWeatherCard` + +- app: `water` +- service: `water/services.py` -> `get_farm_weather_card_data` +- model/source: `water.models.WeatherForecastLog` +- fallback: `water/mock_data.py` -> `FARM_WEATHER_CARD` + +اگر داده هواشناسی برای مزرعه ثبت نشده باشد، خروجی mock برمی گردد. + +### 3) `farmAlertsTracker` + +- app: `farm_alerts` +- service: `farm_alerts/services.py` -> `get_alert_tracker_data` +- model/source: `farm_alerts.models.FarmAlert` +- منطق: هشدارهای `is_active=True` را می شمارد و top 3 را برمی گرداند. + +### 4) `sensorValuesList` + +- app: `sensor_7_in_1` +- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` +- زیرسرویس واقعی: `get_sensor_7_in_1_values_list_data` +- source dependency: + - `farm.sensors` + - `sensor_external_api.services.get_sensor_external_request_logs_for_farm` + - `sensor_external_api.services.get_farm_sensor_map_for_logs` + +این کارت وابسته به آخرین لاگ سنسور فیزیکی مزرعه است. + +### 5) `sensorRadarChart` + +- app: `sensor_7_in_1` +- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` +- زیرسرویس واقعی: `get_sensor_7_in_1_radar_chart_data` +- source: همان context سنسور 7-in-1 + +### 6) `sensorComparisonChart` + +- app: `sensor_7_in_1` +- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` +- زیرسرویس واقعی: `get_sensor_7_in_1_comparison_chart_data` +- source: history لاگ های سنسور در `sensor_external_api` + +### 7) `anomalyDetectionCard` + +- app: `sensor_7_in_1` +- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` +- زیرسرویس واقعی: `get_sensor_7_in_1_anomaly_detection_card_data` +- منطق: از روی بازه مجاز هر فیلد سنسور anomaly می سازد. + +نکته: در `farm_alerts` هم تابع `get_anomaly_detection_data` وجود دارد، ولی در داشبورد فعلی استفاده نشده و کارت anomaly از `sensor_7_in_1` می آید. + +### 8) `farmAlertsTimeline` + +- app: `farm_alerts` +- service: `farm_alerts/services.py` -> `get_alert_timeline_data` +- model/source: `farm_alerts.models.FarmAlert` +- fallback: `farm_alerts/mock_data.py` -> `FARM_ALERTS_TIMELINE` + +### 9) `waterNeedPrediction` + +- app aggregator call: `water` +- service: `water/services.py` -> `get_water_need_prediction_data` +- source واقعی داده: `irrigation_recommendation.models.IrrigationRecommendationRequest` +- منطق: از `response_payload` آخرین recommendation آبیاری، `water_balance.daily` را می خواند. + +نکته مهم: تابعی با همین نام در `irrigation_recommendation/services.py` هم وجود دارد، اما داشبورد فعلی نسخه `water` را صدا می زند. پس منبع business data عملا app آبیاری است، ولی facade فعلی داخل app `water` قرار دارد. + +### 10) `harvestPredictionCard` + +- app: `yield_harvest` +- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data` +- model/source: `yield_harvest.models.YieldHarvestPredictionLog` +- داده های مهم: `harvest_date`, `days_until_harvest`, `optimal_window_start`, `optimal_window_end` + +### 11) `yieldPredictionChart` + +- app: `yield_harvest` +- service: `yield_harvest/services.py` -> `get_yield_harvest_summary_data` +- model/source: `yield_harvest.models.YieldHarvestPredictionLog` +- داده مهم: `chart_data` + +### 12) `soilMoistureHeatmap` + +- app: `sensor_7_in_1` +- service: `sensor_7_in_1/services.py` -> `get_sensor_7_in_1_summary_data` +- زیرسرویس واقعی: `get_sensor_7_in_1_soil_moisture_heatmap_data` +- source: history رطوبت خاک از لاگ سنسورها + +### 13) `ndviHealthCard` + +- app: `crop_health` +- service: `crop_health/services.py` -> `get_crop_health_summary_data` +- وضعیت فعلی: فعلا مستقیم از mock برمی گردد. +- fallback/source: `crop_health/mock_data.py` + +نکته: این app الان منبع DB-driven در این سرویس ندارد و اگر بخواهیم داده واقعی بدهیم باید مدل یا integration منبع NDVI را همینجا اضافه کنیم. + +### 14) `recommendationsList` + +این کارت در `dashboard/services.py` و توسط `_build_recommendations_list` ساخته می شود. منابع آن: + +- `farm_alerts.services.get_recommendations_list_data` + - model/source: `farm_alerts.models.Recommendation` +- `irrigation_recommendation.services.get_irrigation_dashboard_recommendation` + - model/source: `irrigation_recommendation.models.IrrigationRecommendationRequest` +- `fertilization_recommendation.services.get_fertilization_dashboard_recommendation` + - model/source: `fertilization_recommendation.models.FertilizationRecommendationRequest` +- `yield_harvest.services.get_yield_harvest_summary_data` + - برای ساخت recommendation مرتبط با بازه برداشت + +نتیجه: این کارت هم aggregator است و بهتر است داخل app `dashboard` بماند. + +### 15) `economicOverview` + +- app: `economic_overview` +- service: `economic_overview/services.py` -> `get_economic_overview_data` +- model/source: `economic_overview.models.EconomicOverviewLog` +- فیلدهای مهم: `economic_data`, `chart_series`, `chart_categories` + +## سرویس هایی که الان ماهیت aggregator دارند + +این بخش ها باید در API نهایی dashboard به صورت orchestration بین app ها مدیریت شوند: + +- `farmOverviewKpis` +- `recommendationsList` +- کل تابع `get_farm_dashboard_cards` + +## app هایی که الان بیشتر mock هستند + +این app ها در مسیر داشبورد فعلی هنوز کاملا به داده واقعی وصل نشده اند یا بخشی از خروجی آن ها mock است: + +- `crop_health` +- `pest_detection` +- بعضی fallback های `water`, `farm_alerts`, `yield_harvest`, `sensor_7_in_1` + +## پیشنهاد برای مرحله بعد + +برای ساخت API تجمیعی تمیز، بهتر است این قرارداد را نگه داریم: + +1. هر app فقط یک service کوچک برای data payload کارت خودش بدهد. +2. app `dashboard` فقط orchestration و merge انجام دهد. +3. برای کارت های ترکیبی مثل `farmOverviewKpis` و `recommendationsList` منطق join داخل `dashboard/services.py` بماند. +4. اگر خواستی endpoint جدید بسازی، `dashboard/views.py` بهترین محل برای API نهایی است چون همین حالا هم farm validation و access control آنجا انجام می شود. diff --git a/external_api_adapter/adapter.py b/external_api_adapter/adapter.py index d71540d..35cde4e 100644 --- a/external_api_adapter/adapter.py +++ b/external_api_adapter/adapter.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field +import json import logging import requests from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from .exceptions import ExternalAPIRequestError from .exceptions import MockDirectoryNotFound, MockFileNotFound @@ -72,7 +74,8 @@ class ExternalAPIAdapter: url = f"{base_url}/{str(path).lstrip('/')}" files = None - request_payload = payload + request_payload = self._make_json_safe(payload) + request_query = self._make_json_safe(query) request_headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", @@ -95,7 +98,7 @@ class ExternalAPIAdapter: request_kwargs = { "method": method, "url": url, - "params": query, + "params": request_query, "headers": request_headers, "timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30), } @@ -150,6 +153,14 @@ class ExternalAPIAdapter: if method not in supported_methods: raise ValueError(f"Unsupported HTTP method '{method}'. Supported methods: {sorted(supported_methods)}") + @staticmethod + def _make_json_safe(value): + if value is None: + return None + + # Match Django/DRF JSON rendering so UUID/date-like values can be forwarded safely. + return json.loads(json.dumps(value, cls=DjangoJSONEncoder)) + _default_adapter = ExternalAPIAdapter() diff --git a/external_api_adapter/tests.py b/external_api_adapter/tests.py new file mode 100644 index 0000000..f51a4be --- /dev/null +++ b/external_api_adapter/tests.py @@ -0,0 +1,60 @@ +import uuid +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from .adapter import ExternalAPIAdapter + + +class ExternalAPIAdapterTests(SimpleTestCase): + @override_settings(EXTERNAL_API_TIMEOUT=30) + @patch("external_api_adapter.adapter.requests.request") + def test_request_serializes_uuid_payload_for_json_requests(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = {"ok": True} + farm_uuid = uuid.uuid4() + + adapter = ExternalAPIAdapter( + service_registry=type( + "Registry", + (), + {"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}}, + )() + ) + + adapter.request( + "ai", + "/api/farm-alerts/tracker/", + method="POST", + payload={"farm_uuid": farm_uuid}, + ) + + mock_request.assert_called_once() + request_kwargs = mock_request.call_args.kwargs + self.assertEqual(request_kwargs["json"], {"farm_uuid": str(farm_uuid)}) + + @override_settings(EXTERNAL_API_TIMEOUT=30) + @patch("external_api_adapter.adapter.requests.request") + def test_request_serializes_uuid_payload_for_multipart_requests(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = {"ok": True} + farm_uuid = uuid.uuid4() + + adapter = ExternalAPIAdapter( + service_registry=type( + "Registry", + (), + {"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}}, + )() + ) + + adapter.request( + "ai", + "/api/upload/", + method="POST", + payload={"farm_uuid": farm_uuid, "__files__": {"image": ("leaf.jpg", b"data", "image/jpeg")}}, + ) + + mock_request.assert_called_once() + request_kwargs = mock_request.call_args.kwargs + self.assertEqual(request_kwargs["data"], {"farm_uuid": str(farm_uuid)}) diff --git a/farm_alerts/TRACKER_API_FRONTEND.md b/farm_alerts/TRACKER_API_FRONTEND.md new file mode 100644 index 0000000..3b22d7d --- /dev/null +++ b/farm_alerts/TRACKER_API_FRONTEND.md @@ -0,0 +1,436 @@ +# راهنمای فرانت برای API هشدارهای مزرعه + +این سند برای تیم فرانت نوشته شده تا بداند endpoint `tracker` چه ورودی‌ای می‌گیرد، چه کاری انجام می‌دهد، و response آن را چطور باید در UI مصرف کند. + +## Endpoint + +- `POST /api/farm-alerts/tracker/` + +## احراز هویت + +- این API نیاز به `Bearer Token` دارد. +- کاربر فقط به مزرعه‌های متعلق به خودش دسترسی دارد. + +## کاربرد API + +فرانت با ارسال alertهای جدید مربوط به یک مزرعه: + +- alertها را در بک‌اند ذخیره می‌کند +- notificationهای 3 روز اخیر همان مزرعه را هم به context اضافه می‌کند +- همه داده‌ها را برای AI می‌فرستد +- AI یک جمع‌بندی کوتاه، وضعیت کلی، و notificationهای مهم برمی‌گرداند +- notificationهای خروجی AI هم در دیتابیس ذخیره می‌شوند + +این endpoint هم برای تحلیل وضعیت هشدارها مناسب است، هم برای ساخت کارت summary، هم برای notification center. + +## Request Body + +فیلدهای ورودی: + +- `farm_uuid`: شناسه مزرعه - اجباری +- `alerts`: لیست هشدارهای جدید - اختیاری + +### ساختار هر alert + +هر آیتم داخل `alerts` می‌تواند این فیلدها را داشته باشد: + +- `alert_id`: شناسه یکتای هشدار در سمت منبع یا فرانت +- `level`: شدت هشدار مثل `info`، `warning`، `danger` +- `title`: عنوان هشدار +- `message`: متن هشدار +- `suggested_action`: اقدام پیشنهادی +- `source_metric_type`: نوع شاخص مثل `moisture` +- `timestamp`: زمان هشدار با فرمت datetime - اختیاری +- `payload`: داده تکمیلی JSON - اختیاری + +## نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture" + } + ] +} +``` + +## نمونه curl + +```bash +curl -X POST \ + 'http://localhost:8000/api/farm-alerts/tracker/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture" + } + ] + }' +``` + +## رفتار بک‌اند + +بعد از دریافت request: + +1. مزرعه را با `farm_uuid` پیدا می‌کند و ownership را چک می‌کند. +2. alertهای ارسالی را در جدول `farm_alerts` ذخیره می‌کند. +3. حداکثر 10 notification ثبت‌شده در 3 روز اخیر همان مزرعه را برمی‌دارد. +4. `alerts` جدید + `recent_notifications` را برای AI می‌فرستد. +5. notificationهای مهم تولیدشده توسط AI را در جدول `farm_notifications` ذخیره می‌کند. +6. response نهایی را به فرانت برمی‌گرداند. + +## ساختار response موفق + +response داخل envelope استاندارد برمی‌گردد: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "service_id": "farm_alerts", + "tracker": {}, + "headline": "بررسی رطوبت خاک در مزرعه", + "overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.", + "status_level": "warning", + "notifications": [], + "raw_llm_response": "...", + "structured_context": {} + } +} +``` + +## توضیح فیلدهای اصلی response + +### `farm_uuid` + +شناسه مزرعه‌ای که تحلیل برای آن انجام شده است. + +### `service_id` + +شناسه سرویس. فعلا مقدار آن `farm_alerts` است. + +### `tracker` + +بخش اصلی داده برای ساخت UI هشدارها. + +این بخش ممکن است شامل این فیلدها باشد: + +- `totalAlerts`: تعداد کل alertهای فعلی +- `alerts`: لیست alertهای تحلیل‌شده +- `alertStats`: آمار خلاصه برای کارت‌ها +- `alertClusters`: گروه‌بندی alertها +- `mostCriticalIssue`: مهم‌ترین هشدار فعلی +- `prioritizedAlertSummaries`: خلاصه‌های اولویت‌دار +- `recommendedOperationalActions`: اقدام‌های عملیاتی پیشنهادی +- `humanReadableExplanations`: توضیح‌های متنی ساده برای کاربر + +### `headline` + +تیتر کوتاه برای بالای کارت یا صفحه. + +### `overview` + +جمع‌بندی کوتاه و اجرایی برای کاربر. + +### `status_level` + +وضعیت کلی تحلیل برای رنگ‌بندی UI. + +مقادیر معمول: + +- `info` +- `warning` +- `error` +- `success` + +### `notifications` + +لیست notificationهای مهمی که AI تولید کرده و در دیتابیس ذخیره شده‌اند. + +هر notification ممکن است این فیلدها را داشته باشد: + +- `id`: شناسه دیتابیسی +- `uuid`: شناسه یکتا +- `farm_uuid`: شناسه مزرعه +- `since_id`: همان `id` برای برخی flowهای polling +- `endpoint`: منبع notification، اینجا معمولا `tracker` +- `title`: عنوان +- `message`: متن +- `level`: شدت +- `suggested_action`: اقدام پیشنهادی +- `source_alert_id`: شناسه alert اصلی +- `source_metric_type`: نوع شاخص +- `payload`: داده تکمیلی +- `is_read`: خوانده شده یا نه +- `metadata`: اطلاعات داخلی +- `created_at`: زمان ایجاد +- `updated_at`: زمان آخرین به‌روزرسانی + +### `raw_llm_response` + +پاسخ خام AI برای debug یا audit. + +برای UI اصلی معمولا لازم نیست مستقیم نمایش داده شود. + +### `structured_context` + +context تکمیلی که برای AI ساخته شده. + +ممکن است شامل این بخش‌ها باشد: + +- `farm_profile` +- `tracker` +- `forecasts` +- `incoming_alerts` + +این فیلد بیشتر برای debug، مانیتورینگ، یا صفحه‌های تخصصی مفید است. + +## استفاده پیشنهادی در فرانت + +### هدر صفحه یا کارت summary + +از این فیلدها استفاده کنید: + +- `headline` +- `overview` +- `status_level` + +### لیست هشدارهای فعلی + +از: + +- `tracker.alerts` + +### مهم‌ترین هشدار + +از: + +- `tracker.mostCriticalIssue` + +### کارت آمار هشدار + +از: + +- `tracker.totalAlerts` +- `tracker.alertStats` + +### اقدام‌های پیشنهادی + +از: + +- `tracker.recommendedOperationalActions` + +### توضیح ساده برای کاربر + +از: + +- `tracker.humanReadableExplanations` + +### notification center یا drawer + +از: + +- `notifications` + +## نمونه mapping برای فرانت + +```ts +const result = response.data.data; + +const headerTitle = result.headline; +const headerText = result.overview; +const severity = result.status_level; + +const totalAlerts = result.tracker.totalAlerts; +const alerts = result.tracker.alerts; +const stats = result.tracker.alertStats; +const criticalIssue = result.tracker.mostCriticalIssue; +const suggestedActions = result.tracker.recommendedOperationalActions; +const notifications = result.notifications; +``` + +## نمونه response واقعی + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "service_id": "farm_alerts", + "tracker": { + "totalAlerts": 1, + "alerts": [ + { + "metric_type": "moisture", + "title": "تنش رطوبتی", + "current_value": 42.3, + "threshold_value": 45, + "severity": "low", + "duration_hours": 2.8, + "duration": "3 ساعت", + "timestamp": "2026-04-28T20:31:39.594431+00:00", + "sensor_id": "11111111-1111-1111-1111-111111111111", + "zone_id": null, + "domain": "water_balance", + "direction": "below", + "unit": "%", + "icon": "tabler-droplet-half-2", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.", + "metadata": {} + } + ], + "alertStats": [ + { + "title": "تنش رطوبتی", + "count": "1", + "avatarColor": "info", + "avatarIcon": "tabler-droplet-half-2", + "severity": "low", + "topSummary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد." + } + ], + "alertClusters": [ + { + "domain": "water_balance", + "title": "تعادل آب", + "alert_count": 1, + "highest_severity": "low", + "primary_metric": "moisture", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "alert_ids": [ + "moisture:2026-04-28T20:31:39.594431+00:00" + ] + } + ], + "mostCriticalIssue": { + "metric_type": "moisture", + "title": "تنش رطوبتی", + "current_value": 42.3, + "threshold_value": 45, + "severity": "low", + "duration_hours": 2.8, + "duration": "3 ساعت", + "timestamp": "2026-04-28T20:31:39.594431+00:00", + "sensor_id": "11111111-1111-1111-1111-111111111111", + "zone_id": null, + "domain": "water_balance", + "direction": "below", + "unit": "%", + "icon": "tabler-droplet-half-2", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.", + "metadata": {} + }, + "prioritizedAlertSummaries": [ + "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد." + ], + "recommendedOperationalActions": [ + "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید." + ], + "humanReadableExplanations": [ + "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است." + ] + }, + "headline": "بررسی رطوبت خاک در مزرعه", + "overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.", + "status_level": "warning", + "notifications": [ + { + "id": 1, + "uuid": "640e6187-49d9-4256-ad0d-18927712d496", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "since_id": 1, + "endpoint": "tracker", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "level": "warning", + "suggested_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "source_alert_id": "soil-moisture-001", + "source_metric_type": "moisture", + "payload": {}, + "is_read": false, + "metadata": { + "source": "farm_alerts_tracker_ai" + }, + "created_at": "2026-04-28T23:20:19.750658Z", + "updated_at": "2026-04-28T23:20:19.750719Z" + } + ], + "raw_llm_response": "{...}", + "structured_context": { + "incoming_alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + "timestamp": null, + "payload": {} + } + ] + } + } +} +``` + +## خطاهای متداول + +### مزرعه پیدا نشد + +اگر `farm_uuid` متعلق به کاربر نباشد یا وجود نداشته باشد: + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +### بدنه نامعتبر + +اگر فیلدهای نامعتبر بفرستید: + +```json +{ + "unexpected_field": [ + "This field is not allowed." + ] +} +``` + +### احراز هویت نامعتبر + +- در صورت نبود token یا نامعتبر بودن آن، پاسخ `401 Unauthorized` برمی‌گردد. + +## توصیه برای فرانت + +- برای هر alert یک `alert_id` پایدار بفرستید. +- اگر alert جدیدی ندارید، می‌توانید فقط `farm_uuid` بفرستید. +- از `headline` و `overview` برای summary UI استفاده کنید. +- از `notifications` برای notification list یا toast استفاده کنید. +- از `tracker.mostCriticalIssue` و `tracker.recommendedOperationalActions` برای CTA و نمایش اقدام فوری استفاده کنید. diff --git a/farm_alerts/migrations/0003_farmalert_tracker_fields.py b/farm_alerts/migrations/0003_farmalert_tracker_fields.py new file mode 100644 index 0000000..18f8b63 --- /dev/null +++ b/farm_alerts/migrations/0003_farmalert_tracker_fields.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.15 on 2026-04-28 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("farm_alerts", "0002_alter_anomalydetection_severity_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="farmalert", + name="external_alert_id", + field=models.CharField(blank=True, db_index=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmalert", + name="occurred_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmalert", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmalert", + name="raw_alert", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmalert", + name="source_metric_type", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmalert", + name="suggested_action", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/farm_alerts/models.py b/farm_alerts/models.py index 6b3d8da..cb81f5b 100644 --- a/farm_alerts/models.py +++ b/farm_alerts/models.py @@ -16,9 +16,15 @@ SEVERITY_CHOICES = [ class FarmAlert(models.Model): uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="farm_alerts", null=True, blank=True) + external_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True) title = models.CharField(max_length=255) description = models.TextField(blank=True, default="") color = models.CharField(max_length=32, default="info", choices=SEVERITY_CHOICES) + suggested_action = models.TextField(blank=True, default="") + source_metric_type = models.CharField(max_length=255, blank=True, default="") + occurred_at = models.DateTimeField(null=True, blank=True) + payload = models.JSONField(default=dict, blank=True) + raw_alert = models.JSONField(default=dict, blank=True) avatar_icon = models.CharField(max_length=64, blank=True, default="") avatar_color = models.CharField(max_length=32, blank=True, default="") is_active = models.BooleanField(default=True) diff --git a/farm_alerts/serializers.py b/farm_alerts/serializers.py index e070537..d981380 100644 --- a/farm_alerts/serializers.py +++ b/farm_alerts/serializers.py @@ -1,5 +1,47 @@ from rest_framework import serializers +from notifications.serializers import FarmNotificationSerializer + + +ALLOWED_TRACKER_FIELDS = {"farm_uuid", "alerts"} + + +class FarmAlertInputSerializer(serializers.Serializer): + alert_id = serializers.CharField(required=False, allow_blank=True) + level = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + message = serializers.CharField(required=False, allow_blank=True) + suggested_action = serializers.CharField(required=False, allow_blank=True) + source_metric_type = serializers.CharField(required=False, allow_blank=True) + timestamp = serializers.DateTimeField(required=False, allow_null=True) + payload = serializers.JSONField(required=False) + + +class FarmAlertsTrackerRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه.") + alerts = FarmAlertInputSerializer(many=True, required=False, default=list) + + def validate(self, attrs): + initial_keys = set(getattr(self, "initial_data", {}).keys()) + extra_fields = initial_keys - ALLOWED_TRACKER_FIELDS + if extra_fields: + raise serializers.ValidationError( + {field: ["This field is not allowed."] for field in sorted(extra_fields)} + ) + return attrs + + +class AlertTrackerAIResponseSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(read_only=True) + service_id = serializers.CharField() + tracker = serializers.JSONField() + headline = serializers.CharField(allow_blank=True) + overview = serializers.CharField(allow_blank=True) + status_level = serializers.CharField() + notifications = FarmNotificationSerializer(many=True) + raw_llm_response = serializers.CharField(allow_blank=True) + structured_context = serializers.JSONField() + class AlertStatSerializer(serializers.Serializer): title = serializers.CharField() diff --git a/farm_alerts/services.py b/farm_alerts/services.py index 1435761..3d43a8e 100644 --- a/farm_alerts/services.py +++ b/farm_alerts/services.py @@ -1,8 +1,10 @@ from collections import Counter from copy import deepcopy +import json from farm_hub.models import FarmHub from notifications.models import FarmNotification +from notifications.services import create_notification_for_farm_uuid, get_recent_notifications_for_farm from .mock_data import ( ANOMALY_DETECTION_CARD, @@ -13,7 +15,22 @@ from .mock_data import ( from .models import AnomalyDetection, FarmAlert, Recommendation +LEVEL_ALIAS_MAP = { + "danger": "error", + "critical": "error", + "warn": "warning", +} + + class AlertService: + @staticmethod + def normalize_level(level): + normalized = str(level or "info").strip().lower() + normalized = LEVEL_ALIAS_MAP.get(normalized, normalized) + if normalized not in {"info", "warning", "error", "success"}: + return "info" + return normalized + @staticmethod def create_alert( title: str, @@ -26,7 +43,7 @@ class AlertService: farm = None if farm_uuid: try: - farm = FarmHub.objects.get(uuid=farm_uuid) + farm = FarmHub.objects.get(farm_uuid=farm_uuid) except FarmHub.DoesNotExist: pass @@ -34,7 +51,7 @@ class AlertService: farm=farm, title=title, description=description, - color=color, + color=AlertService.normalize_level(color), avatar_icon=avatar_icon, avatar_color=avatar_color, ) @@ -42,22 +59,148 @@ class AlertService: AlertService._send_notification(alert, farm) return alert + @staticmethod + def persist_incoming_alerts(*, farm, alerts): + saved_alerts = [] + for alert_data in alerts: + title = alert_data.get("title") or alert_data.get("message") or "Incoming alert" + level = AlertService.normalize_level(alert_data.get("level")) + saved_alerts.append( + FarmAlert.objects.create( + farm=farm, + external_alert_id=alert_data.get("alert_id", ""), + title=title[:255], + description=alert_data.get("message", ""), + color=level, + suggested_action=alert_data.get("suggested_action", ""), + source_metric_type=alert_data.get("source_metric_type", ""), + occurred_at=alert_data.get("timestamp"), + payload=alert_data.get("payload") or {}, + raw_alert=alert_data, + is_active=level != "success", + ) + ) + return saved_alerts + @staticmethod def _send_notification(alert: FarmAlert, farm) -> None: if farm is None: return - level_map = {"error": "error", "warning": "warning", "info": "info", "success": "success"} - FarmNotification.objects.create( farm=farm, title=alert.title, message=alert.description, - level=level_map.get(alert.color, "info"), + level=alert.color, + source_alert_id=alert.external_alert_id, + source_metric_type=alert.source_metric_type, + suggested_action=alert.suggested_action, + payload=alert.payload, metadata={"alert_uuid": str(alert.uuid), "color": alert.color}, ) +def serialize_notifications_for_ai(*, farm, since_days=3, limit=10): + notifications = get_recent_notifications_for_farm(farm=farm, since_days=since_days, limit=limit) + return [ + { + "id": notification.id, + "farm_uuid": str(notification.farm.farm_uuid), + "endpoint": notification.endpoint, + "level": notification.level, + "title": notification.title, + "message": notification.message, + "suggested_action": notification.suggested_action, + "source_alert_id": notification.source_alert_id, + "source_metric_type": notification.source_metric_type, + "payload": notification.payload, + "created_at": notification.created_at.isoformat(), + "updated_at": notification.updated_at.isoformat(), + } + for notification in notifications + ] + + +def save_tracker_notifications(*, farm_uuid, notifications): + saved_notifications = [] + for notification_data in notifications: + title = notification_data.get("title") or "" + message = notification_data.get("message") or "" + if not title and not message: + continue + + source_alert_id = notification_data.get("source_alert_id", "") + existing = FarmNotification.objects.filter( + farm__farm_uuid=farm_uuid, + endpoint="tracker", + title=title, + message=message, + source_alert_id=source_alert_id, + ).first() + if existing: + saved_notifications.append(existing) + continue + + saved_notifications.append( + create_notification_for_farm_uuid( + farm_uuid=farm_uuid, + endpoint="tracker", + title=title, + message=message, + level=AlertService.normalize_level(notification_data.get("level")), + suggested_action=notification_data.get("suggested_action", ""), + source_alert_id=source_alert_id, + source_metric_type=notification_data.get("source_metric_type", ""), + payload=notification_data.get("payload") or {}, + metadata={"source": "farm_alerts_tracker_ai"}, + ) + ) + return saved_notifications + + +def build_tracker_context(*, farm, alerts): + recent_notifications = serialize_notifications_for_ai(farm=farm, since_days=3, limit=10) + counts = Counter( + AlertService.normalize_level(alert.get("level")) + for alert in alerts + if alert.get("level") + ) + structured_context = { + "farm_uuid": str(farm.farm_uuid), + "alerts_count": len(alerts), + "recent_notifications_count": len(recent_notifications), + "recent_notifications_window_days": 3, + "recent_notifications_limit": 10, + "alert_levels": dict(counts), + } + return { + "farm_uuid": str(farm.farm_uuid), + "alerts": alerts, + "recent_notifications": recent_notifications, + "structured_context": structured_context, + } + + +def build_tracker_response(*, farm, adapter_payload): + notifications_payload = adapter_payload.get("notifications") or [] + saved_notifications = save_tracker_notifications(farm_uuid=farm.farm_uuid, notifications=notifications_payload) + raw_llm_response = adapter_payload.get("raw_llm_response", "") + if not raw_llm_response: + raw_llm_response = json.dumps(adapter_payload, ensure_ascii=False) + + return { + "farm_uuid": str(farm.farm_uuid), + "service_id": adapter_payload.get("service_id", "farm_alerts"), + "tracker": adapter_payload.get("tracker") or {}, + "headline": adapter_payload.get("headline", ""), + "overview": adapter_payload.get("overview", ""), + "status_level": AlertService.normalize_level(adapter_payload.get("status_level")), + "notifications": saved_notifications, + "raw_llm_response": raw_llm_response, + "structured_context": adapter_payload.get("structured_context") or {}, + } + + def get_alert_tracker_data(farm=None): if farm is None: return deepcopy(ARM_ALERTS_TRACKER) diff --git a/farm_alerts/tests.py b/farm_alerts/tests.py new file mode 100644 index 0000000..ba3d1b8 --- /dev/null +++ b/farm_alerts/tests.py @@ -0,0 +1,197 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import SimpleTestCase, TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from notifications.models import FarmNotification + +from .models import FarmAlert +from .serializers import FarmAlertsTrackerRequestSerializer +from .views import AlertTrackerView + + +class FarmAlertsTrackerRequestSerializerTests(SimpleTestCase): + def test_accepts_farm_uuid_and_optional_alerts(self): + serializer = FarmAlertsTrackerRequestSerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-1", + "level": "warning", + "title": "Low moisture", + } + ], + } + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_rejects_extra_fields(self): + serializer = FarmAlertsTrackerRequestSerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "unexpected": True, + } + ) + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors["unexpected"][0], "This field is not allowed.") + + +class FarmAlertsTrackerViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farm-alerts-user", + password="secret123", + email="farm-alerts@example.com", + phone_number="09120000999", + ) + self.other_user = get_user_model().objects.create_user( + username="farm-alerts-other", + password="secret123", + email="farm-alerts-other@example.com", + phone_number="09120000998", + ) + self.farm_type = FarmType.objects.create(name="مرکبات") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm Alerts") + + @patch("farm_alerts.views.external_api_request") + def test_tracker_persists_incoming_alerts_and_sends_recent_notifications_to_ai(self, mock_external_api_request): + recent_notification = FarmNotification.objects.create( + farm=self.farm, + endpoint="tracker", + title="Recent alert", + message="Recent notification", + level="warning", + ) + old_notification = FarmNotification.objects.create( + farm=self.farm, + endpoint="tracker", + title="Old alert", + message="Old notification", + level="info", + ) + FarmNotification.objects.filter(id=old_notification.id).update(created_at=timezone.now() - timedelta(days=4)) + old_notification.refresh_from_db() + + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "headline": "وضعیت هشدارها", + "overview": "دو مورد نیاز به پیگیری دارد.", + "status_level": "warning", + "tracker": {"active": 2}, + "notifications": [ + { + "title": "افت رطوبت خاک", + "message": "تنش رطوبتی ادامه دارد.", + "level": "warning", + "suggested_action": "آبیاری جبرانی انجام شود.", + "source_alert_id": "soil-1", + "source_metric_type": "moisture", + "payload": {"current_value": 38.5}, + } + ], + "structured_context": {"source": "ai"}, + } + }, + ) + + request = self.factory.post( + "/api/farm-alerts/tracker/", + { + "farm_uuid": str(self.farm.farm_uuid), + "alerts": [ + { + "alert_id": "soil-1", + "level": "danger", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + "payload": {"current_value": 38.5}, + } + ], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(FarmAlert.objects.filter(farm=self.farm).count(), 1) + + saved_alert = FarmAlert.objects.get(farm=self.farm) + self.assertEqual(saved_alert.external_alert_id, "soil-1") + self.assertEqual(saved_alert.color, "error") + self.assertEqual(saved_alert.source_metric_type, "moisture") + + mock_external_api_request.assert_called_once() + outbound_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(outbound_payload["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(len(outbound_payload["alerts"]), 1) + self.assertEqual(len(outbound_payload["recent_notifications"]), 1) + self.assertEqual(outbound_payload["recent_notifications"][0]["id"], recent_notification.id) + + self.assertEqual(response.data["data"]["headline"], "وضعیت هشدارها") + self.assertEqual(response.data["data"]["status_level"], "warning") + self.assertEqual(len(response.data["data"]["notifications"]), 1) + self.assertEqual(response.data["data"]["notifications"][0]["endpoint"], "tracker") + + persisted_notification = FarmNotification.objects.filter( + farm=self.farm, + title="افت رطوبت خاک", + endpoint="tracker", + ).latest("id") + self.assertEqual(persisted_notification.source_alert_id, "soil-1") + self.assertEqual(persisted_notification.suggested_action, "آبیاری جبرانی انجام شود.") + + @patch("farm_alerts.views.external_api_request") + def test_tracker_limits_recent_notifications_to_ten(self, mock_external_api_request): + for index in range(12): + FarmNotification.objects.create( + farm=self.farm, + endpoint="tracker", + title=f"Notification {index}", + message="msg", + ) + + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"headline": "", "overview": "", "status_level": "info", "notifications": []}}, + ) + + request = self.factory.post( + "/api/farm-alerts/tracker/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 200) + outbound_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(len(outbound_payload["recent_notifications"]), 10) + + def test_tracker_rejects_unowned_farm(self): + request = self.factory.post( + "/api/farm-alerts/tracker/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.other_user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "Farm not found.") diff --git a/farm_alerts/urls.py b/farm_alerts/urls.py index ef7cc3a..3667ae0 100644 --- a/farm_alerts/urls.py +++ b/farm_alerts/urls.py @@ -1,8 +1,7 @@ from django.urls import path -from .views import AlertTimelineView, AlertTrackerView +from .views import AlertTrackerView urlpatterns = [ path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"), - path("timeline/", AlertTimelineView.as_view(), name="farm-alerts-timeline"), ] diff --git a/farm_alerts/views.py b/farm_alerts/views.py index d9c0efa..cbf974f 100644 --- a/farm_alerts/views.py +++ b/farm_alerts/views.py @@ -1,13 +1,20 @@ -from rest_framework import status +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from config.swagger import code_response from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub -from .serializers import AlertTimelineSerializer, AlertTrackerSerializer +from .serializers import AlertTrackerAIResponseSerializer, FarmAlertsTrackerRequestSerializer +from .services import AlertService, build_tracker_context, build_tracker_response class FarmAlertsBaseView(APIView): + permission_classes = [IsAuthenticated] + @staticmethod def _extract_result(adapter_data): if not isinstance(adapter_data, dict): @@ -37,39 +44,60 @@ class FarmAlertsBaseView(APIView): status=adapter_response.status_code, ) + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + class AlertTrackerView(FarmAlertsBaseView): + @extend_schema( + tags=["Farm Alerts"], + request=FarmAlertsTrackerRequestSerializer, + examples=[ + OpenApiExample( + "Tracker Request", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + } + ], + }, + request_only=True, + ) + ], + responses={200: code_response("FarmAlertsTrackerResponse", data=AlertTrackerAIResponseSerializer())}, + ) def post(self, request): + request_serializer = FarmAlertsTrackerRequestSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, request_serializer.validated_data["farm_uuid"]) + incoming_alerts = request_serializer.validated_data.get("alerts", []) + AlertService.persist_incoming_alerts(farm=farm, alerts=incoming_alerts) + + tracker_payload = build_tracker_context(farm=farm, alerts=incoming_alerts) adapter_response = external_api_request( "ai", "/api/farm-alerts/tracker/", method="POST", - payload=request.data, + payload=tracker_payload, ) if adapter_response.status_code >= 400: return self._error_response(adapter_response) payload = self._extract_result(adapter_response.data) - serializer = AlertTrackerSerializer(data=payload) - serializer.is_valid(raise_exception=True) - return Response({"code": 200, "msg": "success", "data": serializer.validated_data}, status=status.HTTP_200_OK) - - -class AlertTimelineView(FarmAlertsBaseView): - def post(self, request): - adapter_response = external_api_request( - "ai", - "/api/farm-alerts/timeline/", - method="POST", - payload=request.data, - ) - if adapter_response.status_code >= 400: - return self._error_response(adapter_response) - - payload = self._extract_result(adapter_response.data) - serializer = AlertTimelineSerializer(data=payload) - serializer.is_valid(raise_exception=True) - return Response( - {"code": 200, "msg": "success", "data": serializer.validated_data}, - status=status.HTTP_200_OK, - ) + response_data = build_tracker_response(farm=farm, adapter_payload=payload) + serializer = AlertTrackerAIResponseSerializer(instance=response_data) + return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK) diff --git a/notifications/migrations/0002_farmnotification_tracker_fields.py b/notifications/migrations/0002_farmnotification_tracker_fields.py new file mode 100644 index 0000000..3fbb989 --- /dev/null +++ b/notifications/migrations/0002_farmnotification_tracker_fields.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.15 on 2026-04-28 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="farmnotification", + name="endpoint", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="farmnotification", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmnotification", + name="source_alert_id", + field=models.CharField(blank=True, db_index=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmnotification", + name="source_metric_type", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmnotification", + name="suggested_action", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="farmnotification", + name="updated_at", + field=models.DateTimeField(auto_now=True, default=None), + preserve_default=False, + ), + ] diff --git a/notifications/models.py b/notifications/models.py index a12f748..955788c 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -13,9 +13,15 @@ class FarmNotification(models.Model): title = models.CharField(max_length=255) message = models.TextField() level = models.CharField(max_length=32, default="info") + endpoint = models.CharField(max_length=64, blank=True, default="") + suggested_action = models.TextField(blank=True, default="") + source_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + source_metric_type = models.CharField(max_length=255, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) is_read = models.BooleanField(default=False) metadata = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "farm_notifications" diff --git a/notifications/serializers.py b/notifications/serializers.py index 023bc5e..ab8663c 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -4,19 +4,27 @@ from .models import FarmNotification class FarmNotificationSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(read_only=True) farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True) since_id = serializers.IntegerField(source="id", read_only=True) class Meta: model = FarmNotification fields = [ + "id", "uuid", "farm_uuid", "since_id", + "endpoint", "title", "message", "level", + "suggested_action", + "source_alert_id", + "source_metric_type", + "payload", "is_read", "metadata", "created_at", + "updated_at", ] diff --git a/notifications/services.py b/notifications/services.py index 0f74e9a..184f7b7 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -2,6 +2,7 @@ import time from django.db import OperationalError, ProgrammingError from django.db.models import Case, IntegerField, QuerySet, Value, When +from django.utils import timezone from farm_hub.models import FarmHub @@ -13,7 +14,19 @@ DEFAULT_POLL_TIMEOUT_SECONDS = 15 DEFAULT_POLL_INTERVAL_SECONDS = 1 -def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info", metadata=None): +def create_notification_for_farm_uuid( + *, + farm_uuid, + title, + message, + level="info", + endpoint="", + suggested_action="", + source_alert_id="", + source_metric_type="", + payload=None, + metadata=None, +): farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first() if farm is None: raise ValueError("Farm not found.") @@ -24,12 +37,25 @@ def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info" title=title, message=message, level=level, + endpoint=endpoint, + suggested_action=suggested_action, + source_alert_id=source_alert_id, + source_metric_type=source_metric_type, + payload=payload or {}, metadata=metadata or {}, ) except (ProgrammingError, OperationalError) as exc: raise ValueError("Notifications table is not migrated.") from exc +def get_recent_notifications_for_farm(*, farm: FarmHub, since_days=3, limit=10) -> QuerySet[FarmNotification]: + try: + since = timezone.now() - timezone.timedelta(days=max(since_days, 0)) + return FarmNotification.objects.filter(farm=farm, created_at__gte=since).order_by("-created_at", "-id")[:limit] + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]: try: queryset = FarmNotification.objects.filter(farm=farm)