UPDATE
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is a Django REST backend organized by app domains. Core configuration lives in `config/` (`settings.py`, `urls.py`, `celery.py`). Feature apps (for example `account/`, `farm_hub/`, `sensor_catalog/`, `crop_zoning/`, `notifications/`) each contain `models.py`, `serializers.py`, `views.py`, `urls.py`, and app-specific `migrations/`.
|
||||
|
||||
Tests are mostly colocated in each app as `tests.py`. Mock payloads and integration fixtures are stored under `json/mock_data/` and `external_api_adapter/json/`. API collections are under `*/postman/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `python -m venv .venv && source .venv/bin/activate` - create and activate a virtual environment.
|
||||
- `pip install -r requirements.txt` - install runtime and development dependencies.
|
||||
- `python manage.py migrate` - apply database migrations.
|
||||
- `python manage.py runserver` - start the local API server.
|
||||
- `python manage.py test` - run the full Django test suite.
|
||||
- `python manage.py test farm_hub` - run tests for a single app.
|
||||
- `docker compose up --build` - run the backend and dependencies via Docker.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow standard Django/Python conventions:
|
||||
- 4-space indentation, snake_case for functions/variables, PascalCase for classes.
|
||||
- Keep serializers in `serializers.py`, business logic in `services.py`, and routing in `urls.py`.
|
||||
- Name management commands as verbs (for example `seed_admin_user`).
|
||||
- Prefer small, app-scoped modules over cross-app imports unless shared behavior is intentional.
|
||||
|
||||
## Testing Guidelines
|
||||
Use Django’s built-in test runner (`unittest` style). Place tests in each app’s `tests.py` (or `tests/` package if expanded). Name tests as `test_<behavior>` and cover serializers, view responses, and service edge cases. Use `unittest.mock.patch` for external integrations (AI adapters, SMS, or HTTP services).
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Current history uses generic messages (`UPDATE`), but contributors should use clear, imperative commits such as `add sensor catalog seed command`.
|
||||
|
||||
For PRs, include:
|
||||
- concise description of scope and affected apps,
|
||||
- migration notes (`manage.py makemigrations`/`migrate` impact),
|
||||
- test evidence (`python manage.py test ...` output),
|
||||
- linked issue/task ID when available,
|
||||
- request/response examples for API changes (Postman or JSON sample paths).
|
||||
|
||||
## Security & Configuration Tips
|
||||
Copy `.env.example` to `.env` and never commit secrets. Validate CORS/JWT settings in `config/settings.py` per environment. Keep mock JSON and seed data free of production credentials or personal data.
|
||||
@@ -1,115 +0,0 @@
|
||||
# راهنمای استفاده فرانت از سیستم نوتیفیکیشن (SSE + Redis)
|
||||
|
||||
این سند روش اتصال فرانت به سیستم نوتیفیکیشن جدید را توضیح میدهد.
|
||||
|
||||
## 1) APIهای موجود
|
||||
|
||||
- استریم نوتیفیکیشن (SSE):
|
||||
- `GET /api/notifications/stream/?channel=<channel_name>`
|
||||
- ارسال نوتیفیکیشن (برای تست/ادمین):
|
||||
- `POST /api/notifications/publish/`
|
||||
|
||||
هر دو endpoint نیازمند احراز هویت هستند.
|
||||
|
||||
## 2) فرمت پیام دریافتی در SSE
|
||||
|
||||
payload نمونه:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "f6f5d6ca-54f1-4d0e-8d29-5ef5760a3b40",
|
||||
"event": "notification",
|
||||
"title": "آبیاری",
|
||||
"message": "زمان آبیاری مزرعه فرا رسیده است.",
|
||||
"level": "info",
|
||||
"metadata": {
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
"created_at": "2026-04-03T20:00:00.000000+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
`event` بهصورت SSE event name هم ارسال میشود.
|
||||
|
||||
## 3) انتخاب channel
|
||||
|
||||
الگوی پیشنهادی:
|
||||
|
||||
- کانال کاربر: `user-<user_id>`
|
||||
- کانال مزرعه: `farm-<farm_uuid>`
|
||||
|
||||
در وضعیت فعلی backend اگر `channel` نفرستید، پیشفرض روی `user-<current_user_id>` است.
|
||||
|
||||
## 4) اتصال در فرانت
|
||||
|
||||
نکته مهم: چون backend روی JWT (`Authorization: Bearer ...`) است، `EventSource` پیشفرض مرورگر امکان ارسال header سفارشی ندارد.
|
||||
پس یا از polyfill/library استفاده کنید، یا مکانیزم auth مبتنی بر cookie داشته باشید.
|
||||
|
||||
نمونه با `@microsoft/fetch-event-source`:
|
||||
|
||||
```js
|
||||
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||
|
||||
const API_BASE = "https://your-domain.com";
|
||||
const token = localStorage.getItem("access_token");
|
||||
const channel = "user-123";
|
||||
|
||||
await fetchEventSource(`${API_BASE}/api/notifications/stream/?channel=${channel}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "text/event-stream"
|
||||
},
|
||||
onopen(response) {
|
||||
if (!response.ok) throw new Error("SSE connection failed");
|
||||
},
|
||||
onmessage(event) {
|
||||
if (!event.data) return;
|
||||
const payload = JSON.parse(event.data);
|
||||
// نمایش toast / badge / in-app notification
|
||||
console.log("notification:", payload);
|
||||
},
|
||||
onerror(err) {
|
||||
console.error("SSE error", err);
|
||||
// کتابخانه به شکل پیشفرض reconnect میکند
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 5) ارسال نوتیفیکیشن (برای تست از فرانت یا پنل ادمین)
|
||||
|
||||
درخواست:
|
||||
|
||||
```http
|
||||
POST /api/notifications/publish/
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
بدنه:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "user-123",
|
||||
"title": "نمونه نوتیف",
|
||||
"message": "این پیام تستی است",
|
||||
"level": "info",
|
||||
"event": "notification",
|
||||
"metadata": {
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6) پیشنهاد UX در فرانت
|
||||
|
||||
- روی `level`، رنگبندی toast انجام دهید (`info/success/warning/error`).
|
||||
- `metadata` را برای deep-link استفاده کنید (مثلاً رفتن به صفحه مزرعه).
|
||||
- هنگام logout، اتصال SSE را قطع کنید.
|
||||
- هنگام تغییر کاربر یا مزرعه، channel را عوض کنید و اتصال قبلی را ببندید.
|
||||
|
||||
## 7) خطاهای رایج
|
||||
|
||||
- `401 Unauthorized`: توکن نامعتبر/منقضی شده.
|
||||
- `403 Forbidden`: کاربر دسترسی لازم به endpoint ندارد.
|
||||
- اتصال برقرار میشود ولی پیامی نمیآید: channel اشتباه است یا پیام روی channel دیگری publish شده است.
|
||||
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
|
||||
"farm_ai_assistant",
|
||||
"notifications.apps.NotificationsConfig",
|
||||
"external_api_adapter.apps.ExternalApiAdapterConfig",
|
||||
"sensor_external_api.apps.SensorExternalApiConfig",
|
||||
"rest_framework",
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
@@ -134,6 +135,19 @@ SPECTACULAR_SETTINGS = {
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
"SCHEMA_PATH_PREFIX": r"/api/",
|
||||
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
|
||||
"APPEND_COMPONENTS": {
|
||||
"securitySchemes": {
|
||||
"SensorExternalApiKey": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "Use API key 12345 for sensor external API endpoints.",
|
||||
}
|
||||
}
|
||||
},
|
||||
"SWAGGER_UI_SETTINGS": {
|
||||
"persistAuthorization": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +183,8 @@ CROP_ZONE_TASK_STALE_SECONDS = int(os.getenv("CROP_ZONE_TASK_STALE_SECONDS", "30
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
|
||||
NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL)
|
||||
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
|
||||
SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345")
|
||||
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
|
||||
CELERY_TASK_ACKS_LATE = True
|
||||
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
|
||||
|
||||
@@ -21,4 +21,5 @@ urlpatterns = [
|
||||
path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")),
|
||||
path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")),
|
||||
path("api/notifications/", include("notifications.urls")),
|
||||
path("api/sensor-external-api/", include("sensor_external_api.urls")),
|
||||
]
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||
QDRANT_HOST: ${QDRANT_HOST:-qdrant}
|
||||
QDRANT_PORT: ${QDRANT_PORT:-6333}
|
||||
SKIP_MIGRATE: "0"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -68,6 +68,7 @@ services:
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
QDRANT_HOST: qdrant
|
||||
QDRANT_PORT: 6333
|
||||
SKIP_MIGRATE: "0"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ from django.apps import AppConfig
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "notifications"
|
||||
verbose_name = "Notifications"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.1.7 on 2025-02-20 00:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("farm_hub", "0006_seed_expanded_product_catalog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FarmNotification",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("message", models.TextField()),
|
||||
("level", models.CharField(default="info", max_length=32)),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"farm",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_notifications",
|
||||
"ordering": ["-created_at", "-id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid as uuid_lib
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class FarmNotification(models.Model):
|
||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
||||
farm = models.ForeignKey(
|
||||
"farm_hub.FarmHub",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
level = models.CharField(max_length=32, default="info")
|
||||
is_read = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_notifications"
|
||||
ordering = ["-created_at", "-id"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.farm_id}:{self.title}"
|
||||
@@ -1,10 +1,20 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import FarmNotification
|
||||
|
||||
class NotificationPublishSerializer(serializers.Serializer):
|
||||
channel = serializers.CharField(max_length=128)
|
||||
title = serializers.CharField(max_length=255)
|
||||
message = serializers.CharField()
|
||||
level = serializers.ChoiceField(choices=["info", "success", "warning", "error"], default="info")
|
||||
metadata = serializers.DictField(required=False, default=dict)
|
||||
event = serializers.CharField(max_length=64, required=False, default="notification")
|
||||
|
||||
class FarmNotificationSerializer(serializers.ModelSerializer):
|
||||
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = FarmNotification
|
||||
fields = [
|
||||
"uuid",
|
||||
"farm_uuid",
|
||||
"title",
|
||||
"message",
|
||||
"level",
|
||||
"is_read",
|
||||
"metadata",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
+43
-26
@@ -1,33 +1,50 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from redis import Redis
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
from .models import FarmNotification
|
||||
|
||||
|
||||
def get_notifications_redis_client():
|
||||
redis_url = getattr(settings, "NOTIFICATION_REDIS_URL", None) or _default_redis_url()
|
||||
return Redis.from_url(redis_url, decode_responses=True)
|
||||
DEFAULT_POLL_TIMEOUT_SECONDS = 15
|
||||
DEFAULT_POLL_INTERVAL_SECONDS = 1
|
||||
|
||||
|
||||
def publish_notification(channel, title, message, *, level="info", metadata=None, event="notification"):
|
||||
payload = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"event": event,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"level": level,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
redis_client = get_notifications_redis_client()
|
||||
redis_client.publish(channel, json.dumps(payload))
|
||||
return payload
|
||||
def create_notification_for_farm_uuid(*, farm_uuid, title, message, level="info", metadata=None):
|
||||
farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first()
|
||||
if farm is None:
|
||||
raise ValueError("Farm not found.")
|
||||
|
||||
try:
|
||||
return FarmNotification.objects.create(
|
||||
farm=farm,
|
||||
title=title,
|
||||
message=message,
|
||||
level=level,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Notifications table is not migrated.") from exc
|
||||
|
||||
|
||||
def _default_redis_url():
|
||||
broker_url = getattr(settings, "CELERY_BROKER_URL", "")
|
||||
if isinstance(broker_url, str) and broker_url.startswith("redis://"):
|
||||
return broker_url
|
||||
return "redis://127.0.0.1:6379/1"
|
||||
def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]:
|
||||
try:
|
||||
queryset = FarmNotification.objects.filter(farm=farm)
|
||||
if since_id is not None:
|
||||
queryset = queryset.filter(id__gt=since_id)
|
||||
return queryset.order_by("created_at", "id")
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Notifications table is not migrated.") from exc
|
||||
|
||||
|
||||
def long_poll_notifications(*, farm: FarmHub, since_id=None, timeout_seconds=DEFAULT_POLL_TIMEOUT_SECONDS, interval_seconds=DEFAULT_POLL_INTERVAL_SECONDS):
|
||||
deadline = time.monotonic() + max(timeout_seconds, 0)
|
||||
while True:
|
||||
notifications = list(get_notifications_for_farm(farm=farm, since_id=since_id))
|
||||
if notifications:
|
||||
return notifications
|
||||
if time.monotonic() >= deadline:
|
||||
return []
|
||||
time.sleep(max(interval_seconds, 0))
|
||||
|
||||
+160
-71
@@ -1,97 +1,186 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
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 .views import NotificationPublishView, NotificationStreamView
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
|
||||
from .models import FarmNotification
|
||||
from .services import create_notification_for_farm_uuid, long_poll_notifications
|
||||
from .views import ExternalNotificationIngestView, NotificationLongPollView
|
||||
|
||||
|
||||
class NotificationPublishViewTests(TestCase):
|
||||
class NotificationServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="notif-service-user",
|
||||
password="secret123",
|
||||
email="notif-service@example.com",
|
||||
phone_number="09120000011",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="گلخانه")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm A",
|
||||
)
|
||||
|
||||
def test_create_notification_for_farm_uuid_creates_record(self):
|
||||
notification = create_notification_for_farm_uuid(
|
||||
farm_uuid=self.farm.farm_uuid,
|
||||
title="Irrigation alert",
|
||||
message="Soil moisture dropped",
|
||||
level="warning",
|
||||
metadata={"sensor": "soil-1"},
|
||||
)
|
||||
|
||||
self.assertEqual(notification.farm, self.farm)
|
||||
self.assertEqual(notification.level, "warning")
|
||||
self.assertEqual(notification.metadata["sensor"], "soil-1")
|
||||
|
||||
def test_create_notification_for_farm_uuid_raises_for_unknown_farm(self):
|
||||
with self.assertRaisesMessage(ValueError, "Farm not found."):
|
||||
create_notification_for_farm_uuid(
|
||||
farm_uuid="11111111-1111-1111-1111-111111111111",
|
||||
title="x",
|
||||
message="y",
|
||||
)
|
||||
|
||||
def test_long_poll_notifications_returns_new_notifications(self):
|
||||
FarmNotification.objects.create(farm=self.farm, title="A", message="B")
|
||||
|
||||
notifications = long_poll_notifications(farm=self.farm, timeout_seconds=0)
|
||||
|
||||
self.assertEqual(len(notifications), 1)
|
||||
self.assertEqual(notifications[0].title, "A")
|
||||
|
||||
|
||||
class NotificationLongPollViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="notify-user",
|
||||
username="notif-view-user",
|
||||
password="secret123",
|
||||
email="notify@example.com",
|
||||
phone_number="09120000099",
|
||||
email="notif-view@example.com",
|
||||
phone_number="09120000012",
|
||||
)
|
||||
self.other_user = get_user_model().objects.create_user(
|
||||
username="notif-other-user",
|
||||
password="secret123",
|
||||
email="notif-other@example.com",
|
||||
phone_number="09120000013",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="دامداری")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm B",
|
||||
)
|
||||
|
||||
@patch("notifications.views.publish_notification")
|
||||
def test_publish_calls_service_and_returns_payload(self, mock_publish_notification):
|
||||
mock_publish_notification.return_value = {"id": "1", "event": "notification", "message": "hello"}
|
||||
def test_long_poll_view_returns_notifications_for_owned_farm(self):
|
||||
FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor")
|
||||
request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = NotificationLongPollView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(response.data["data"][0]["title"], "Alert")
|
||||
|
||||
def test_long_poll_view_returns_404_for_unowned_farm(self):
|
||||
request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0")
|
||||
force_authenticate(request, user=self.other_user)
|
||||
|
||||
response = NotificationLongPollView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data["msg"], "Farm not found.")
|
||||
|
||||
@patch("notifications.views.long_poll_notifications")
|
||||
def test_long_poll_view_passes_since_id(self, mocked_long_poll):
|
||||
mocked_long_poll.return_value = []
|
||||
request = self.factory.get(
|
||||
f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&since_id=5&timeout=0"
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = NotificationLongPollView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mocked_long_poll.assert_called_once()
|
||||
self.assertEqual(mocked_long_poll.call_args.kwargs["since_id"], 5)
|
||||
|
||||
|
||||
@override_settings(EXTERNAL_NOTIFICATION_API_KEY="12345")
|
||||
class ExternalNotificationIngestViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="notif-external-user",
|
||||
password="secret123",
|
||||
email="notif-external@example.com",
|
||||
phone_number="09120000014",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="آبی")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm C",
|
||||
)
|
||||
|
||||
def test_external_ingest_requires_api_key(self):
|
||||
request = self.factory.post(
|
||||
"/api/notifications/publish/",
|
||||
"/api/notifications/external/ingest/",
|
||||
{
|
||||
"channel": "user-1",
|
||||
"title": "Test",
|
||||
"message": "hello",
|
||||
"level": "info",
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"title": "external",
|
||||
"message": "payload",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = NotificationPublishView.as_view()(request)
|
||||
response = ExternalNotificationIngestView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["code"], 200)
|
||||
mock_publish_notification.assert_called_once()
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
class _FakePubSub:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def subscribe(self, _channel):
|
||||
return None
|
||||
|
||||
def get_message(self, ignore_subscribe_messages=True, timeout=15.0):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return {"type": "message", "data": '{"event":"notification","message":"hi"}'}
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self):
|
||||
self._pubsub = _FakePubSub()
|
||||
|
||||
def pubsub(self):
|
||||
return self._pubsub
|
||||
|
||||
|
||||
class NotificationStreamViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="stream-user",
|
||||
password="secret123",
|
||||
email="stream@example.com",
|
||||
phone_number="09120000098",
|
||||
def test_external_ingest_creates_notification_with_valid_api_key(self):
|
||||
request = self.factory.post(
|
||||
"/api/notifications/external/ingest/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"title": "Pump alert",
|
||||
"message": "Pump disconnected",
|
||||
"level": "critical",
|
||||
"metadata": {"source": "external-service"},
|
||||
},
|
||||
format="json",
|
||||
HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY,
|
||||
)
|
||||
|
||||
@patch("notifications.views.get_notifications_redis_client")
|
||||
def test_stream_returns_event_stream_response(self, mock_redis_client):
|
||||
mock_redis_client.return_value = _FakeRedis()
|
||||
request = self.factory.get("/api/notifications/stream/?channel=user-1")
|
||||
force_authenticate(request, user=self.user)
|
||||
response = ExternalNotificationIngestView.as_view()(request)
|
||||
|
||||
response = NotificationStreamView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["data"]["title"], "Pump alert")
|
||||
self.assertTrue(
|
||||
FarmNotification.objects.filter(farm=self.farm, title="Pump alert", level="critical").exists()
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/event-stream")
|
||||
iterator = iter(response.streaming_content)
|
||||
first_chunk = self._to_text(next(iterator))
|
||||
second_chunk = self._to_text(next(iterator))
|
||||
self.assertIn("connected", first_chunk)
|
||||
self.assertIn("event: notification", second_chunk)
|
||||
def test_external_ingest_returns_404_for_unknown_farm(self):
|
||||
request = self.factory.post(
|
||||
"/api/notifications/external/ingest/",
|
||||
{
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"title": "Pump alert",
|
||||
"message": "Pump disconnected",
|
||||
},
|
||||
format="json",
|
||||
HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_text(value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode()
|
||||
return str(value)
|
||||
response = ExternalNotificationIngestView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data["msg"], "Farm not found.")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import NotificationPublishView, NotificationStreamView
|
||||
from .views import NotificationLongPollView
|
||||
|
||||
urlpatterns = [
|
||||
path("stream/", NotificationStreamView.as_view(), name="notifications-stream"),
|
||||
path("publish/", NotificationPublishView.as_view(), name="notifications-publish"),
|
||||
path("long-poll/", NotificationLongPollView.as_view(), name="notification-long-poll"),
|
||||
]
|
||||
|
||||
+51
-68
@@ -1,84 +1,67 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from config.swagger import code_response
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
from .serializers import NotificationPublishSerializer
|
||||
from .services import get_notifications_redis_client, publish_notification
|
||||
from .serializers import FarmNotificationSerializer
|
||||
from .services import create_notification_for_farm_uuid, long_poll_notifications
|
||||
|
||||
|
||||
def _sse_event(event_name, data):
|
||||
return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
class NotificationLongPollQuerySerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField()
|
||||
since_id = serializers.IntegerField(required=False, min_value=1)
|
||||
timeout = serializers.IntegerField(required=False, min_value=0, max_value=60)
|
||||
|
||||
|
||||
class NotificationStreamView(APIView):
|
||||
class ExternalNotificationCreateSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField()
|
||||
title = serializers.CharField(max_length=255)
|
||||
message = serializers.CharField()
|
||||
level = serializers.CharField(max_length=32, required=False, default="info")
|
||||
metadata = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
class NotificationLongPollView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Notifications"],
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="channel",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Redis channel to subscribe. Default is user-{current_user_id}.",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.STR},
|
||||
parameters=[NotificationLongPollQuerySerializer],
|
||||
responses={
|
||||
200: code_response("NotificationLongPollResponse", data=FarmNotificationSerializer(many=True)),
|
||||
404: code_response("NotificationLongPollNotFoundResponse"),
|
||||
503: code_response("NotificationLongPollNotificationsUnavailableResponse"),
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
channel = request.query_params.get("channel") or f"user-{request.user.id}"
|
||||
|
||||
def stream():
|
||||
redis_client = get_notifications_redis_client()
|
||||
pubsub = redis_client.pubsub()
|
||||
pubsub.subscribe(channel)
|
||||
try:
|
||||
yield ": connected\n\n"
|
||||
while True:
|
||||
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
|
||||
if message and message.get("type") == "message":
|
||||
try:
|
||||
payload = json.loads(message["data"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
payload = {
|
||||
"event": "notification",
|
||||
"message": str(message["data"]),
|
||||
}
|
||||
yield _sse_event(payload.get("event", "notification"), payload)
|
||||
else:
|
||||
yield ": keepalive\n\n"
|
||||
time.sleep(0.1)
|
||||
except GeneratorExit:
|
||||
return
|
||||
finally:
|
||||
pubsub.close()
|
||||
|
||||
response = StreamingHttpResponse(stream(), content_type="text/event-stream")
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
|
||||
|
||||
class NotificationPublishView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Notifications"],
|
||||
request=NotificationPublishSerializer,
|
||||
responses={200: code_response("NotificationPublishResponse", data=OpenApiTypes.OBJECT)},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = NotificationPublishSerializer(data=request.data)
|
||||
serializer = NotificationLongPollQuerySerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = publish_notification(**serializer.validated_data)
|
||||
return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK)
|
||||
|
||||
farm = FarmHub.objects.filter(
|
||||
farm_uuid=serializer.validated_data["farm_uuid"],
|
||||
owner=request.user,
|
||||
).first()
|
||||
if farm is None:
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
notifications = long_poll_notifications(
|
||||
farm=farm,
|
||||
since_id=serializer.validated_data.get("since_id"),
|
||||
timeout_seconds=serializer.validated_data.get("timeout", 15),
|
||||
)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Notifications table is not migrated.":
|
||||
return Response(
|
||||
{"code": 503, "msg": "Notifications table is not ready. Run migrations."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
raise
|
||||
data = FarmNotificationSerializer(notifications, many=True).data
|
||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# گزارش بررسی معماری و پیاده سازی پروژه CropLogic
|
||||
|
||||
## دامنه بررسی
|
||||
|
||||
- این بررسی فقط روی کد موجود در ریپازیتوری انجام شد.
|
||||
- طبق درخواست شما هیچ تغییری در کد اپلیکیشن داده نشد.
|
||||
- برای این گزارش تست اجرایی سراسری اجرا نشد؛ جمع بندی بر پایه بازبینی ساختار، تنظیمات، مدل ها، ویوها، سرویس ها و تست های موجود است.
|
||||
|
||||
## جمع بندی سریع
|
||||
|
||||
پروژه از نظر تفکیک دامنه ها به چند اپ Django ساختار قابل فهمی دارد، اما چند ضعف جدی در مرزهای معماری، امنیت APIها، پایداری در کار با سرویس های خارجی، و یکپارچگی داده ها دیده می شود. مهم ترین ریسک ها این ها هستند:
|
||||
|
||||
1. سیستم نوتیفیکیشن از نظر مجوزدهی ناامن است و کاربر احراز هویت شده می تواند به کانال دلخواه subscribe/publish کند.
|
||||
2. ویرایش سنسورهای مزرعه باعث حذف و بازسازی کامل سنسورها می شود و به خاطر `CASCADE` شدن، تاریخچه داده های سنسور هم از بین می رود.
|
||||
3. ساخت مزرعه و زون بندی، dispatch تسک های async را قبل از commit نهایی تراکنش بالادستی شروع می کند و مستعد race condition است.
|
||||
4. چند API وابسته به سرویس خارجی، خطاهای شبکه/پیکربندی را handle نمی کنند و به احتمال زیاد با 500 خام fail می شوند.
|
||||
|
||||
### 3) dispatch تسک های زون بندی داخل جریان تراکنش بالادستی انجام می شود
|
||||
|
||||
شدت: بالا
|
||||
|
||||
شواهد:
|
||||
|
||||
- در `farm_hub/services.py:19` تا `farm_hub/services.py:23` ساخت مزرعه داخل `transaction.atomic()` انجام می شود.
|
||||
- همانجا `dispatch_farm_zoning` صدا زده می شود.
|
||||
- در `crop_zoning/services.py:897` تا `crop_zoning/services.py:935` بعد از ساخت `CropArea` و `CropZone`ها، تابع `dispatch_zone_processing_tasks` اجرا می شود.
|
||||
|
||||
اثر:
|
||||
|
||||
- `create_zones_and_dispatch` داخل یک atomic داخلی صدا زده می شود، اما هنوز transaction بیرونی `create_farm_with_zoning` commit نشده است.
|
||||
- worker ممکن است قبل از commit نهایی شروع شود و داده های وابسته را نبیند یا وضعیت ناقص ببیند.
|
||||
- این مشکل در محیط های واقعی، intermittent و سخت برای دیباگ خواهد بود.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- dispatch تسک ها باید با `transaction.on_commit(...)` انجام شود.
|
||||
- ساخت entityها و شروع پردازش async باید کاملا از هم جدا شوند.
|
||||
- بهتر است workflow ایجاد مزرعه -> commit -> enqueue zoning -> poll status به صورت صریح طراحی شود.
|
||||
|
||||
### 4) وابستگی به سرویس خارجی در چند endpoint بدون fail-safe مناسب است
|
||||
|
||||
شدت: بالا
|
||||
|
||||
شواهد:
|
||||
|
||||
- `dashboard/views.py:126` تا `dashboard/views.py:134`
|
||||
- `irrigation_recommendation/views.py:63` تا `irrigation_recommendation/views.py:79`
|
||||
- `irrigation_recommendation/views.py:125` تا `irrigation_recommendation/views.py:136`
|
||||
- `fertilization_recommendation/views.py:63` تا `fertilization_recommendation/views.py:80`
|
||||
- `fertilization_recommendation/views.py:94` تا `fertilization_recommendation/views.py:105`
|
||||
- در `external_api_adapter/adapter.py:50` و `external_api_adapter/adapter.py:70` خطاهای config/network به `ExternalAPIRequestError` تبدیل می شوند، اما endpoint های بالا آن را catch نمی کنند.
|
||||
|
||||
اثر:
|
||||
|
||||
- اگر `base_url` تنظیم نشده باشد یا شبکه/سرویس خارجی down باشد، پاسخ API داخلی احتمالا 500 خام می شود.
|
||||
- رفتار endpoint ها ناهمگون است: مثلا `farm_ai_assistant` این خطا را handle می کند، اما dashboard/irrigation/fertilization نه.
|
||||
- این ناهمگونی نگهداری و مانیتورینگ را سخت می کند.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- یک لایه مشترک برای map کردن خطاهای external dependency به 502/503 ایجاد شود.
|
||||
- همه endpoint های adapter-based رفتار یکسان داشته باشند.
|
||||
- timeout، retry policy، circuit breaker و logging ساختاریافته برای این لایه لازم است.
|
||||
|
||||
### 5) GET مربوط به crop zoning دارای side effect است
|
||||
|
||||
شدت: متوسط رو به بالا
|
||||
|
||||
شواهد:
|
||||
|
||||
- در `crop_zoning/views.py:57` تا `crop_zoning/views.py:68` endpoint `GET /area/` فقط read نیست.
|
||||
- در `crop_zoning/services.py:869` تا `crop_zoning/services.py:892` این GET می تواند:
|
||||
- area جدید بسازد
|
||||
- zone بسازد
|
||||
- rule-based data تولید کند
|
||||
- task جدید dispatch کند
|
||||
|
||||
اثر:
|
||||
|
||||
- semantics REST شکسته می شود؛ GET دیگر safe/read-only نیست.
|
||||
- cache کردن، replay، tracing و حتی load-testing این endpoint رفتار غیرمنتظره پیدا می کند.
|
||||
- اگر frontend چند بار polling کند، endpoint read عملا orchestration engine می شود.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- endpoint creation/recompute باید POST باشد.
|
||||
- GET فقط باید status/result را برگرداند.
|
||||
- orchestration crop zoning بهتر است state machine یا task-oriented API شفاف داشته باشد.
|
||||
|
||||
### 7) خطای یکتایی email در update پروفایل می تواند 500 بدهد
|
||||
|
||||
شدت: متوسط
|
||||
|
||||
شواهد:
|
||||
|
||||
- در `account/views.py:53` تا `account/views.py:60` فیلدهای کاربر مستقیم set و save می شوند.
|
||||
- مدل `User` در `account/models.py` ایمیل unique دارد.
|
||||
- در این مسیر هیچ `IntegrityError`ی handle نشده است.
|
||||
|
||||
اثر:
|
||||
|
||||
- اگر کاربر ایمیلی را بگذارد که قبلا ثبت شده، به جای خطای business-level، احتمالا 500 برمی گردد.
|
||||
- تجربه API ناهمگون می شود، چون در `RegisterView` برای uniqueness handling وجود دارد ولی در profile update نه.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- validation uniqueness باید در serializer انجام شود.
|
||||
- `save()` هم باید با handling مناسب برای race condition همراه باشد.
|
||||
|
||||
### 8) ingestion سنسور به availability سرویس نوتیفیکیشن گره خورده است
|
||||
|
||||
شدت: متوسط
|
||||
|
||||
شواهد:
|
||||
|
||||
- در `external_sensor_api/views.py:53` تا `external_sensor_api/views.py:80` ابتدا reading در DB ذخیره می شود و بعد `publish_notification` صدا زده می شود.
|
||||
- در `notifications/services.py:24` تا `notifications/services.py:25` publish بدون try/except انجام می شود.
|
||||
|
||||
اثر:
|
||||
|
||||
- اگر Redis قطع باشد، ingest ممکن است بعد از ذخیره شدن داده با exception fail کند.
|
||||
- نتیجه می تواند پاسخ 500 به sender باشد، در حالی که reading واقعا ثبت شده است.
|
||||
- این باعث رفتار non-idempotent و retry خطرناک می شود: فرستنده دوباره retry می کند و رکورد duplicate می سازد.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- publish notification باید best-effort و جدا از مسیر اصلی ingestion باشد.
|
||||
- برای sensor ingest بهتر است notification async شود.
|
||||
- اگر notification شکست خورد، ثبت reading نباید rollback منطقی API را مخدوش کند.
|
||||
|
||||
### 9) طراحی authentication برای OTP در مقیاس چند پردازه/چند نود پایدار نیست
|
||||
|
||||
شدت: متوسط
|
||||
|
||||
شواهد:
|
||||
|
||||
- در `config/settings.py:110` تا `config/settings.py:115` cache از نوع `LocMemCache` است.
|
||||
- منطق OTP در `auth/views.py` از cache برای نگهداری کد استفاده می کند.
|
||||
- هرچند routeهای OTP در `auth/urls.py:8` تا `auth/urls.py:9` فعلا کامنت شده اند، طراحی فعلی برای production-ready بودن کافی نیست.
|
||||
|
||||
اثر:
|
||||
|
||||
- در deployment چند worker یا چند instance، درخواست `request-otp` و `verify-otp` ممکن است به نودهای مختلف بخورند و verify شکست بخورد.
|
||||
- این یعنی طراحی احراز هویت به process-local memory وابسته است.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- cache/shared store مثل Redis برای OTP استفاده شود.
|
||||
- rate limit، lockout، replay protection و audit logging هم به این flow اضافه شود.
|
||||
|
||||
### 10) نشانه های واضحی از ناتمام بودن مرزهای API و drift بین کد و قرارداد وجود دارد
|
||||
|
||||
شدت: متوسط
|
||||
|
||||
شواهد:
|
||||
|
||||
- routeهای مهمی کامنت شده اند: `auth/urls.py:8` تا `auth/urls.py:9`، `account/urls.py:7` تا `account/urls.py:8`، `crop_zoning/urls.py:16` تا `crop_zoning/urls.py:31`، `farm_ai_assistant/urls.py:15`
|
||||
- کلاس ها و viewهای مرتبط هنوز در کد حضور دارند، اما publicly exposed نیستند.
|
||||
|
||||
اثر:
|
||||
|
||||
- فهمیدن وضعیت واقعی featureها برای توسعه دهنده جدید سخت می شود.
|
||||
- schema و مستندات ممکن است با قابلیت واقعی محصول همگام نباشد.
|
||||
- این وضعیت معمولا نشانه این است که API lifecycle و deprecation strategy شفاف نیست.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- featureهای غیرفعال یا باید حذف شوند، یا با feature flag و مستندات واضح مدیریت شوند.
|
||||
- برای endpointهای deprecated یا موقت، policy مشخص publish/unpublish لازم است.
|
||||
|
||||
## ضعف های معماری کلان
|
||||
|
||||
### 1) تکرار زیاد منطق دسترسی به مزرعه
|
||||
|
||||
در چند اپ مختلف mixin مشابه برای resolve کردن `farm_uuid` و ownership تکرار شده:
|
||||
|
||||
- `dashboard/views.py:20`
|
||||
- `irrigation_recommendation/views.py:24`
|
||||
- `fertilization_recommendation/views.py:24`
|
||||
- `farm_ai_assistant/views.py:28`
|
||||
- `farm_hub/views.py:16`
|
||||
- `access_control/views.py:15`
|
||||
|
||||
اثر:
|
||||
|
||||
- رفتار خطاها یکدست نیست.
|
||||
- هر اپ interpretation متفاوتی از “farm not found / access denied / validation error” دارد.
|
||||
- تغییر policy ownership یا prefetching باید در چند نقطه تکرار شود.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- یک service/mixin/shared permission مشترک برای farm-scoped access ساخته شود.
|
||||
|
||||
### 2) مرز بین دامنه های business و integration واضح نیست
|
||||
|
||||
نمونه ها:
|
||||
|
||||
- viewها مستقیم با `external_api_request(...)` کار می کنند.
|
||||
- persistence، orchestration، و response-shaping در یک متد جمع شده است.
|
||||
- در crop zoning هم business logic سنگین، persistence و async dispatch در یک service بزرگ متمرکز شده است.
|
||||
|
||||
اثر:
|
||||
|
||||
- تست واحد سخت تر می شود.
|
||||
- وابستگی به provider خارجی به لایه API نشت کرده است.
|
||||
- refactor یا جایگزینی provider بیرونی پرهزینه تر می شود.
|
||||
|
||||
پیشنهاد:
|
||||
|
||||
- use-case/service layer مشخص برای هر دامنه تعریف شود.
|
||||
- adapter خارجی فقط در لایه integration بماند.
|
||||
- view صرفا orchestration نهایی HTTP را انجام دهد.
|
||||
|
||||
## وضعیت تست ها
|
||||
|
||||
تست برای بعضی اپ ها وجود دارد، اما پوشش پروژه ناهمگن است. بر اساس ساختار فعلی، این اپ ها فاقد فایل تست قابل مشاهده هستند:
|
||||
|
||||
- `access_control`
|
||||
- `account`
|
||||
- `auth`
|
||||
- `external_api_adapter`
|
||||
- `fertilization_recommendation`
|
||||
- `irrigation_recommendation`
|
||||
- `plant_simulator`
|
||||
- `pest_detection`
|
||||
- `sensor_ingest`
|
||||
|
||||
این شکاف مخصوصا برای بخش های زیر نگران کننده است:
|
||||
|
||||
- مجوزدهی و access control
|
||||
- authentication
|
||||
- integration با سرویس خارجی
|
||||
- رفتار خطا و fallback
|
||||
- data integrity سنسورها و readingها
|
||||
|
||||
## اولویت پیشنهادی برای اصلاح
|
||||
|
||||
### فوری
|
||||
|
||||
1. بستن ضعف امنیتی notification stream/publish
|
||||
2. جلوگیری از delete شدن سنسورها در update و حفظ تاریخچه reading
|
||||
3. انتقال dispatch تسک ها به `transaction.on_commit`
|
||||
4. یکسان سازی خطاهای external service و جلوگیری از 500 خام
|
||||
|
||||
### بعدی
|
||||
|
||||
1. جدا کردن endpointهای mock از API اصلی
|
||||
2. حذف side effect از GETهای crop zoning
|
||||
3. یکپارچه سازی farm access logic
|
||||
4. افزودن تست برای auth, access_control, recommendations, integrations
|
||||
|
||||
## نتیجه نهایی
|
||||
|
||||
پروژه از نظر تقسیم اپ ها و شفاف بودن domain names شروع خوبی دارد، اما هنوز چند بخش مهم آن بیشتر شبیه نسخه integration-heavy و نیمه محصولی است تا یک backend production-hardened. بزرگ ترین ریسک های فعلی مربوط به:
|
||||
|
||||
- امنیت notificationها
|
||||
- از دست رفتن داده در lifecycle سنسورها
|
||||
- race condition در zoning async flow
|
||||
- ناپایداری در برابر خطاهای سرویس های خارجی
|
||||
|
||||
اگر بخواهم فقط یک جمع بندی کوتاه بدهم: مشکل اصلی پروژه نه در syntax یا ساختار فایل ها، بلکه در مرزبندی responsibilityها، lifecycle داده ها، و رفتار fail-safe سیستم است.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SensorExternalApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "sensor_external_api"
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.conf import settings
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
class SensorExternalAPIKeyAuthentication(BaseAuthentication):
|
||||
keyword = "Api-Key"
|
||||
|
||||
def authenticate(self, request):
|
||||
provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization")
|
||||
expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345")
|
||||
|
||||
if not provided_key:
|
||||
raise AuthenticationFailed("API key is required.")
|
||||
|
||||
if provided_key.startswith(f"{self.keyword} "):
|
||||
provided_key = provided_key[len(self.keyword) + 1 :]
|
||||
|
||||
if provided_key != expected_key:
|
||||
raise AuthenticationFailed("Invalid API key.")
|
||||
|
||||
return (None, None)
|
||||
@@ -0,0 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class SensorExternalRequestSerializer(serializers.Serializer):
|
||||
payload = serializers.JSONField(required=False, default=dict)
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import ProgrammingError, OperationalError
|
||||
|
||||
from notifications.services import create_notification_for_farm_uuid
|
||||
|
||||
|
||||
DEFAULT_SENSOR_EXTERNAL_FARM_UUID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def create_sensor_external_notification(*, payload=None):
|
||||
payload = payload or {}
|
||||
try:
|
||||
return create_notification_for_farm_uuid(
|
||||
farm_uuid=DEFAULT_SENSOR_EXTERNAL_FARM_UUID,
|
||||
title="Sensor external API request",
|
||||
message="A request was received by sensor_external_api.",
|
||||
level="info",
|
||||
metadata={"payload": payload},
|
||||
)
|
||||
except (ProgrammingError, OperationalError) as exc:
|
||||
raise ValueError("Notifications table is not migrated.") from exc
|
||||
@@ -0,0 +1,52 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
from notifications.models import FarmNotification
|
||||
|
||||
from .views import SensorExternalAPIView
|
||||
|
||||
|
||||
@override_settings(SENSOR_EXTERNAL_API_KEY="12345")
|
||||
class SensorExternalAPIViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="sensor-external-user",
|
||||
password="secret123",
|
||||
email="sensor-external@example.com",
|
||||
phone_number="09120000015",
|
||||
)
|
||||
self.farm_type = FarmType.objects.create(name="سنسور خارجی")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm External",
|
||||
farm_uuid="11111111-1111-1111-1111-111111111111",
|
||||
)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
request = self.factory.post("/api/sensor-external-api/", {"payload": {"temp": 12}}, format="json")
|
||||
|
||||
response = SensorExternalAPIView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_creates_notification_for_fixed_farm_uuid(self):
|
||||
request = self.factory.post(
|
||||
"/api/sensor-external-api/",
|
||||
{"payload": {"temp": 12}},
|
||||
format="json",
|
||||
HTTP_X_API_KEY="12345",
|
||||
)
|
||||
|
||||
response = SensorExternalAPIView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
FarmNotification.objects.filter(
|
||||
farm=self.farm,
|
||||
title="Sensor external API request",
|
||||
).exists()
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import SensorExternalAPIView
|
||||
|
||||
urlpatterns = [
|
||||
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
|
||||
|
||||
from config.swagger import code_response
|
||||
from notifications.serializers import FarmNotificationSerializer
|
||||
|
||||
from .authentication import SensorExternalAPIKeyAuthentication
|
||||
from .serializers import SensorExternalRequestSerializer
|
||||
from .services import create_sensor_external_notification
|
||||
|
||||
|
||||
class SensorExternalAPIView(APIView):
|
||||
authentication_classes = [SensorExternalAPIKeyAuthentication]
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Sensor External API"],
|
||||
request=SensorExternalRequestSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="X-API-Key",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.HEADER,
|
||||
required=True,
|
||||
default="12345",
|
||||
description="API key for sensor external API.",
|
||||
)
|
||||
],
|
||||
responses={
|
||||
201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()),
|
||||
401: code_response("SensorExternalAPIUnauthorizedResponse"),
|
||||
404: code_response("SensorExternalAPIFarmNotFoundResponse"),
|
||||
503: code_response("SensorExternalAPINotificationsUnavailableResponse"),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = SensorExternalRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
notification = create_sensor_external_notification(payload=serializer.validated_data.get("payload"))
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Notifications table is not migrated.":
|
||||
return Response(
|
||||
{"code": 503, "msg": "Notifications table is not ready. Run migrations."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
data = FarmNotificationSerializer(notification).data
|
||||
return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED)
|
||||
Reference in New Issue
Block a user