This commit is contained in:
2026-04-29 02:58:56 +03:30
parent f0f2ac34b7
commit 27784ee8b9
15 changed files with 1277 additions and 36 deletions
+436
View File
@@ -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 <access_token>' \
-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 و نمایش اقدام فوری استفاده کنید.
@@ -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=""),
),
]
+6
View File
@@ -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)
+42
View File
@@ -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()
+148 -5
View File
@@ -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)
+197
View File
@@ -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.")
+1 -2
View File
@@ -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"),
]
+54 -26
View File
@@ -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)