This commit is contained in:
2026-04-05 00:57:25 +03:30
parent 6d5ece1f5d
commit 32dbbed1af
26 changed files with 825 additions and 291 deletions
+38
View File
@@ -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 Djangos built-in test runner (`unittest` style). Place tests in each apps `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.
-115
View File
@@ -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 شده است.
+16
View File
@@ -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"))
+1
View File
@@ -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")),
]
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -1 +0,0 @@
+1
View File
@@ -4,3 +4,4 @@ from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
verbose_name = "Notifications"
+41
View File
@@ -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"],
},
),
]
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -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}"
+17 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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.")
+2 -3
View File
@@ -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
View File
@@ -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)
+261
View File
@@ -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 سیستم است.
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SensorExternalApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "sensor_external_api"
+22
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
from rest_framework import serializers
class SensorExternalRequestSerializer(serializers.Serializer):
payload = serializers.JSONField(required=False, default=dict)
+20
View File
@@ -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
+52
View File
@@ -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()
)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import SensorExternalAPIView
urlpatterns = [
path("", SensorExternalAPIView.as_view(), name="sensor-external-api"),
]
+54
View File
@@ -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)