From ecb42c6895c89cbcb5f0491093b821137ed18b0f Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Fri, 3 Apr 2026 23:51:00 +0330 Subject: [PATCH] UPDATE --- ACCESS_CONTROL_FRONTEND_CHANGES_FA.md | 194 ++++ NOTIFICATIONS_FRONTEND_USAGE_FA.md | 115 +++ access_control/__init__.py | 1 + access_control/apps.py | 7 + access_control/catalog.py | 69 ++ access_control/migrations/0001_initial.py | 109 +++ .../0002_link_subscription_plan_to_farm.py | 12 + .../0003_seed_default_access_rules.py | 93 ++ access_control/migrations/__init__.py | 1 + access_control/models.py | 108 +++ access_control/permissions.py | 60 ++ access_control/serializers.py | 34 + access_control/services.py | 139 +++ access_control/urls.py | 10 + access_control/views.py | 57 ++ config/settings.py | 5 +- config/urls.py | 2 + dashboard/tests.py | 28 + dashboard/views.py | 2 + farm_hub/API_REFERENCE_FA.md | 865 ++++++++++++++++++ .../0007_farmhub_subscription_plan.py | 25 + farm_hub/models.py | 7 + farm_hub/serializers.py | 28 + farm_hub/tests.py | 122 ++- farm_hub/views.py | 7 +- notifications/__init__.py | 1 + notifications/apps.py | 6 + notifications/serializers.py | 10 + notifications/services.py | 33 + notifications/tests.py | 97 ++ notifications/urls.py | 8 + notifications/views.py | 84 ++ 32 files changed, 2336 insertions(+), 3 deletions(-) create mode 100644 ACCESS_CONTROL_FRONTEND_CHANGES_FA.md create mode 100644 NOTIFICATIONS_FRONTEND_USAGE_FA.md create mode 100644 access_control/__init__.py create mode 100644 access_control/apps.py create mode 100644 access_control/catalog.py create mode 100644 access_control/migrations/0001_initial.py create mode 100644 access_control/migrations/0002_link_subscription_plan_to_farm.py create mode 100644 access_control/migrations/0003_seed_default_access_rules.py create mode 100644 access_control/migrations/__init__.py create mode 100644 access_control/models.py create mode 100644 access_control/permissions.py create mode 100644 access_control/serializers.py create mode 100644 access_control/services.py create mode 100644 access_control/urls.py create mode 100644 access_control/views.py create mode 100644 farm_hub/API_REFERENCE_FA.md create mode 100644 farm_hub/migrations/0007_farmhub_subscription_plan.py create mode 100644 notifications/__init__.py create mode 100644 notifications/apps.py create mode 100644 notifications/serializers.py create mode 100644 notifications/services.py create mode 100644 notifications/tests.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py diff --git a/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md b/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md new file mode 100644 index 0000000..ab801db --- /dev/null +++ b/ACCESS_CONTROL_FRONTEND_CHANGES_FA.md @@ -0,0 +1,194 @@ +# تغییرات سطح دسترسی برای فرانت + +این سند توضیح می‌دهد فرانت چطور از سیستم `access_control` استفاده کند، چه `feature code` هایی وجود دارند، و plan `gold` دقیقاً چه دسترسی‌هایی می‌دهد. + +## 1) API پروفایل دسترسی مزرعه + +برای گرفتن دسترسی موثر هر مزرعه از این endpoint استفاده کنید: + +- `GET /api/access-control/farms/{farm_uuid}/profile/` +- نیازمند `Authorization: Bearer ` + +نمونه پاسخ: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "subscription_plan": { + "uuid": "22222222-2222-2222-2222-222222222222", + "code": "gold", + "name": "Gold" + }, + "features": { + "greenhouse-dashboard": { + "enabled": true, + "type": "page", + "name": "Greenhouse Dashboard", + "description": "", + "metadata": {}, + "source": "gold-full-access" + }, + "sensor-page": { + "enabled": true, + "type": "page", + "name": "صفحه سنسور", + "description": "", + "metadata": {}, + "source": "sensor-7-page-access" + } + }, + "groups": { + "pages": { + "greenhouse-dashboard": { + "enabled": true, + "type": "page", + "name": "Greenhouse Dashboard", + "description": "", + "metadata": {}, + "source": "gold-full-access" + } + } + }, + "matched_rules": [ + { + "code": "gold-full-access", + "name": "Gold Full Access", + "effect": "allow", + "priority": 10 + }, + { + "code": "sensor-7-page-access", + "name": "Sensor 7 Page Access", + "effect": "allow", + "priority": 20 + } + ], + "resolved_from_profile": true + } +} +``` + +## 2) رفتار پیش‌فرض plan + +- plan پیش‌فرض فارم‌های جدید: `gold` +- اگر فرانت هنگام ساخت فارم `subscription_plan_uuid` نفرستد، backend به‌صورت پیش‌فرض plan `gold` را ست می‌کند. +- بنابراین در اکثر سناریوهای فعلی، کاربر جدید بعد از ساخت فارم باید دسترسی‌های Gold را داشته باشد. + +## 3) feature code ها + +این `code` ها کلید اصلی برای بررسی دسترسی در فرانت هستند: + +- `dashboards` : سکشن داشبوردها +- `data-section` : سکشن بخش داده‌ها +- `water-data` : صفحه دیتاهای آب +- `soil-information` : صفحه اطلاعات خاک +- `crop-zoning` : صفحه زون‌بندی کشت +- `simulator` : سکشن شبیه‌ساز +- `plant-growth-simulator` : صفحه شبیه‌ساز رشد گیاه +- `recommendations` : سکشن توصیه‌ها +- `irrigation-recommendation` : صفحه توصیه آبیاری +- `fertilization-recommendation` : صفحه توصیه کوددهی +- `smart-assistant` : سکشن دستیار هوشمند +- `farm-ai-assistant` : صفحه دستیار هوشمند مزرعه +- `pest-detection` : صفحه تشخیص آفات گیاهی +- `sensor-page` : صفحه سنسور +- `greenhouse-dashboard` : صفحه/داشبورد گلخانه + +## 4) دسترسی‌های plan `gold` + +اگر `subscription_plan.code === "gold"` باشد، این featureها باید فعال باشند: + +- `dashboards` +- `data-section` +- `water-data` +- `soil-information` +- `crop-zoning` +- `simulator` +- `plant-growth-simulator` +- `recommendations` +- `irrigation-recommendation` +- `fertilization-recommendation` +- `smart-assistant` +- `farm-ai-assistant` +- `pest-detection` +- `greenhouse-dashboard` + +## 5) قانون سنسور + +یک rule جدا هم برای سنسور تعریف شده است: + +- rule code: `sensor-7-page-access` +- شرط: مزرعه سنسور `Sensor 7 - Soil Moisture Sensor v1.2` را داشته باشد +- نتیجه: feature `sensor-page` فعال می‌شود + +پس برای نمایش صفحه سنسور بهتر است فقط این را چک کنید: + +- `features["sensor-page"]?.enabled === true` + +و لازم نیست منطق نام سنسور را داخل فرانت تکرار کنید. + +## 6) source و matched_rules یعنی چه؟ + +- `features[code].source` نشان می‌دهد این دسترسی از کدام rule آمده است. +- `matched_rules` لیست ruleهایی است که روی این مزرعه match شده‌اند. +- برای UI معمولی معمولاً فقط `enabled` کافی است. +- برای debug یا پنل ادمین، `source` و `matched_rules` مفید هستند. + +## 7) گارد دسترسی روی APIها + +علاوه بر `IsAuthenticated`، گارد مبتنی بر feature هم اضافه شده است. +اگر API نیازمند یک feature خاص باشد و آن feature برای مزرعه فعال نباشد، پاسخ: + +- `403 Forbidden` + +نمونه: + +```json +{ + "detail": "Access to feature `greenhouse-dashboard` is denied." +} +``` + +## 8) APIهایی که فعلاً به feature وصل شده‌اند + +فعلاً این endpointها نیازمند feature `greenhouse-dashboard` هستند: + +- `GET /api/farm-dashboard/` +- `GET /api/farm-dashboard-config/` +- `PATCH /api/farm-dashboard-config/` + +نکته: + +- در این endpointها باید `farm_uuid` معتبر و متعلق به همان کاربر ارسال شود. + +## 9) پیشنهاد پیاده‌سازی در فرانت + +- بعد از انتخاب مزرعه، یک‌بار `access profile` را fetch کنید. +- نمایش منوها، سکشن‌ها و صفحه‌ها را با `features[feature_code].enabled` کنترل کنید. +- اگر کاربر مستقیم وارد صفحه شد و backend `403` داد، صفحه `عدم دسترسی` یا fallback مناسب نمایش دهید. +- در سوئیچ مزرعه، access profile را دوباره بگیرید. +- منبع نهایی حقیقت همیشه backend است، نه فقط hide/show در UI. + +## 10) الگوی پیشنهادی در کد فرانت + +نمونه: + +```ts +const canAccess = (profile: any, featureCode: string) => { + return profile?.features?.[featureCode]?.enabled === true; +}; + +const canShowDashboard = canAccess(profile, "greenhouse-dashboard"); +const canShowSensorPage = canAccess(profile, "sensor-page"); +const canShowPestDetection = canAccess(profile, "pest-detection"); +``` + +## 11) جمع‌بندی قرارداد فرانت + +- کلید پایدار بررسی: `feature code` +- منبع معتبر: `GET /api/access-control/farms/{farm_uuid}/profile/` +- تصمیم UI: بر اساس `features[code].enabled` +- تصمیم نهایی backend: بر اساس ruleهای access control diff --git a/NOTIFICATIONS_FRONTEND_USAGE_FA.md b/NOTIFICATIONS_FRONTEND_USAGE_FA.md new file mode 100644 index 0000000..e4d3010 --- /dev/null +++ b/NOTIFICATIONS_FRONTEND_USAGE_FA.md @@ -0,0 +1,115 @@ +# راهنمای استفاده فرانت از سیستم نوتیفیکیشن (SSE + Redis) + +این سند روش اتصال فرانت به سیستم نوتیفیکیشن جدید را توضیح می‌دهد. + +## 1) APIهای موجود + +- استریم نوتیفیکیشن (SSE): + - `GET /api/notifications/stream/?channel=` +- ارسال نوتیفیکیشن (برای تست/ادمین): + - `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-` +- کانال مزرعه: `farm-` + +در وضعیت فعلی backend اگر `channel` نفرستید، پیش‌فرض روی `user-` است. + +## 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 +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 شده است. diff --git a/access_control/__init__.py b/access_control/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/access_control/__init__.py @@ -0,0 +1 @@ + diff --git a/access_control/apps.py b/access_control/apps.py new file mode 100644 index 0000000..484e262 --- /dev/null +++ b/access_control/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccessControlConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "access_control" + diff --git a/access_control/catalog.py b/access_control/catalog.py new file mode 100644 index 0000000..1e3af4b --- /dev/null +++ b/access_control/catalog.py @@ -0,0 +1,69 @@ +GOLD_PLAN_CODE = "gold" +SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2" + + +DEFAULT_SUBSCRIPTION_PLANS = [ + { + "code": "gold", + "name": "Gold", + "description": "Default premium subscription plan with full CropLogic access.", + "metadata": {"is_default": True}, + }, +] + + +DEFAULT_ACCESS_FEATURES = [ + {"code": "dashboards", "name": "داشبوردها", "feature_type": "section"}, + {"code": "data-section", "name": "بخش داده ها", "feature_type": "section"}, + {"code": "water-data", "name": "دیتاهای آب", "feature_type": "page"}, + {"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page"}, + {"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page"}, + {"code": "simulator", "name": "شبیه ساز", "feature_type": "section"}, + {"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page"}, + {"code": "recommendations", "name": "توصیه ها", "feature_type": "section"}, + {"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page"}, + {"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page"}, + {"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section"}, + {"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page"}, + {"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page"}, + {"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page"}, + {"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page"}, +] + + +DEFAULT_ACCESS_RULES = [ + { + "code": "gold-full-access", + "name": "Gold Full Access", + "description": "Enables all core product features for gold subscribers.", + "effect": "allow", + "priority": 10, + "subscription_plans": ["gold"], + "features": [ + "dashboards", + "data-section", + "water-data", + "soil-information", + "crop-zoning", + "simulator", + "plant-growth-simulator", + "recommendations", + "irrigation-recommendation", + "fertilization-recommendation", + "smart-assistant", + "farm-ai-assistant", + "pest-detection", + "greenhouse-dashboard", + ], + }, + { + "code": "sensor-7-page-access", + "name": "Sensor 7 Page Access", + "description": "Adds sensor page access when Sensor 7 is attached to the farm.", + "effect": "allow", + "priority": 20, + "features": ["sensor-page"], + "sensor_catalogs": [SENSOR_7_NAME], + "metadata": {"sensor_catalog_names": [SENSOR_7_NAME]}, + }, +] diff --git a/access_control/migrations/0001_initial.py b/access_control/migrations/0001_initial.py new file mode 100644 index 0000000..dd84816 --- /dev/null +++ b/access_control/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 5.2.12 on 2026-03-19 16:40 + +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"), + ("sensor_catalog", "0002_sensorcatalog_supported_power_sources"), + ] + + operations = [ + migrations.CreateModel( + name="AccessFeature", + 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)), + ("code", models.SlugField(db_index=True, max_length=128, unique=True)), + ("name", models.CharField(max_length=255)), + ( + "feature_type", + models.CharField( + choices=[("page", "Page"), ("action", "Action"), ("widget", "Widget"), ("section", "Section")], + default="page", + max_length=32, + ), + ), + ("description", models.TextField(blank=True, default="")), + ("default_enabled", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "access_features", "ordering": ["feature_type", "code"]}, + ), + migrations.CreateModel( + name="SubscriptionPlan", + 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)), + ("code", models.SlugField(db_index=True, max_length=64, unique=True)), + ("name", models.CharField(db_index=True, max_length=255, unique=True)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "subscription_plans", "ordering": ["name"]}, + ), + migrations.CreateModel( + name="AccessRule", + 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)), + ("code", models.SlugField(db_index=True, max_length=128, unique=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)), + ("priority", models.PositiveIntegerField(default=100)), + ("is_active", models.BooleanField(default=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")), + ("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")), + ("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")), + ( + "sensor_catalogs", + models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog"), + ), + ( + "subscription_plans", + models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan"), + ), + ], + options={"db_table": "access_rules", "ordering": ["priority", "code"]}, + ), + migrations.CreateModel( + name="FarmAccessProfile", + fields=[ + ( + "farm", + models.OneToOneField( + db_column="farm_uuid", + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="access_profile", + serialize=False, + to="farm_hub.farmhub", + to_field="farm_uuid", + ), + ), + ("cached_features", models.JSONField(blank=True, default=dict)), + ("cached_groups", models.JSONField(blank=True, default=dict)), + ("matched_rules", models.JSONField(blank=True, default=list)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("last_resolved_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "farm_access_profiles"}, + ), + ] + diff --git a/access_control/migrations/0002_link_subscription_plan_to_farm.py b/access_control/migrations/0002_link_subscription_plan_to_farm.py new file mode 100644 index 0000000..8d37e44 --- /dev/null +++ b/access_control/migrations/0002_link_subscription_plan_to_farm.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.12 on 2026-03-19 16:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0001_initial"), + ("farm_hub", "0006_seed_expanded_product_catalog"), + ] + + operations = [] diff --git a/access_control/migrations/0003_seed_default_access_rules.py b/access_control/migrations/0003_seed_default_access_rules.py new file mode 100644 index 0000000..52db4c0 --- /dev/null +++ b/access_control/migrations/0003_seed_default_access_rules.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.12 on 2026-04-03 + +from django.db import migrations + + +def seed_default_access_rules(apps, schema_editor): + AccessFeature = apps.get_model("access_control", "AccessFeature") + AccessRule = apps.get_model("access_control", "AccessRule") + SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan") + SensorCatalog = apps.get_model("sensor_catalog", "SensorCatalog") + + from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS + + features_by_code = {} + for feature_data in DEFAULT_ACCESS_FEATURES: + feature, _created = AccessFeature.objects.update_or_create( + code=feature_data["code"], + defaults={ + "name": feature_data["name"], + "feature_type": feature_data["feature_type"], + "description": feature_data.get("description", ""), + "metadata": feature_data.get("metadata", {}), + "default_enabled": feature_data.get("default_enabled", False), + }, + ) + features_by_code[feature.code] = feature + + plans_by_code = {} + for plan_data in DEFAULT_SUBSCRIPTION_PLANS: + plan, _created = SubscriptionPlan.objects.update_or_create( + code=plan_data["code"], + defaults={ + "name": plan_data["name"], + "description": plan_data.get("description", ""), + "metadata": plan_data.get("metadata", {}), + "is_active": True, + }, + ) + plans_by_code[plan.code] = plan + + sensor_catalogs_by_name = { + sensor.name: sensor for sensor in SensorCatalog.objects.filter(name__in=_sensor_names(DEFAULT_ACCESS_RULES)) + } + + for rule_data in DEFAULT_ACCESS_RULES: + rule, _created = AccessRule.objects.update_or_create( + code=rule_data["code"], + defaults={ + "name": rule_data["name"], + "description": rule_data.get("description", ""), + "effect": rule_data.get("effect", "allow"), + "priority": rule_data.get("priority", 100), + "metadata": rule_data.get("metadata", {}), + "is_active": True, + }, + ) + rule.features.set([features_by_code[code] for code in rule_data.get("features", []) if code in features_by_code]) + rule.subscription_plans.set( + [plans_by_code[code] for code in rule_data.get("subscription_plans", []) if code in plans_by_code] + ) + rule.sensor_catalogs.set( + [sensor_catalogs_by_name[name] for name in rule_data.get("sensor_catalogs", []) if name in sensor_catalogs_by_name] + ) + + +def unseed_default_access_rules(apps, schema_editor): + AccessFeature = apps.get_model("access_control", "AccessFeature") + AccessRule = apps.get_model("access_control", "AccessRule") + SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan") + + from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS + + AccessRule.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_RULES]).delete() + AccessFeature.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_FEATURES]).delete() + SubscriptionPlan.objects.filter(code__in=[item["code"] for item in DEFAULT_SUBSCRIPTION_PLANS]).delete() + + +def _sensor_names(rule_data_list): + names = [] + for rule_data in rule_data_list: + names.extend(rule_data.get("sensor_catalogs", [])) + return names + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0002_link_subscription_plan_to_farm"), + ("sensor_catalog", "0002_sensorcatalog_supported_power_sources"), + ] + + operations = [ + migrations.RunPython(seed_default_access_rules, unseed_default_access_rules), + ] diff --git a/access_control/migrations/__init__.py b/access_control/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/access_control/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/access_control/models.py b/access_control/models.py new file mode 100644 index 0000000..68e5aa5 --- /dev/null +++ b/access_control/models.py @@ -0,0 +1,108 @@ +import uuid as uuid_lib + +from django.db import models + + +class SubscriptionPlan(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.SlugField(max_length=64, unique=True, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "subscription_plans" + ordering = ["name"] + + def __str__(self): + return self.name + + +class AccessFeature(models.Model): + PAGE = "page" + ACTION = "action" + WIDGET = "widget" + SECTION = "section" + FEATURE_TYPES = [ + (PAGE, "Page"), + (ACTION, "Action"), + (WIDGET, "Widget"), + (SECTION, "Section"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.SlugField(max_length=128, unique=True, db_index=True) + name = models.CharField(max_length=255) + feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE) + description = models.TextField(blank=True, default="") + default_enabled = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "access_features" + ordering = ["feature_type", "code"] + + def __str__(self): + return self.code + + +class AccessRule(models.Model): + ALLOW = "allow" + DENY = "deny" + EFFECTS = [ + (ALLOW, "Allow"), + (DENY, "Deny"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.SlugField(max_length=128, unique=True, db_index=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW) + priority = models.PositiveIntegerField(default=100) + is_active = models.BooleanField(default=True) + metadata = models.JSONField(default=dict, blank=True) + features = models.ManyToManyField(AccessFeature, related_name="rules", blank=True) + subscription_plans = models.ManyToManyField(SubscriptionPlan, related_name="access_rules", blank=True) + farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True) + products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True) + sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "access_rules" + ordering = ["priority", "code"] + + def __str__(self): + return self.code + + +class FarmAccessProfile(models.Model): + farm = models.OneToOneField( + "farm_hub.FarmHub", + to_field="farm_uuid", + db_column="farm_uuid", + on_delete=models.CASCADE, + related_name="access_profile", + primary_key=True, + ) + cached_features = models.JSONField(default=dict, blank=True) + cached_groups = models.JSONField(default=dict, blank=True) + matched_rules = models.JSONField(default=list, blank=True) + metadata = models.JSONField(default=dict, blank=True) + last_resolved_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_access_profiles" + + def __str__(self): + return str(self.farm_id) + diff --git a/access_control/permissions.py b/access_control/permissions.py new file mode 100644 index 0000000..0cb4c50 --- /dev/null +++ b/access_control/permissions.py @@ -0,0 +1,60 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework.permissions import BasePermission + +from farm_hub.models import FarmHub + +from .services import is_feature_enabled_for_farm + + +class FeatureAccessPermission(BasePermission): + message = "You do not have access to this API." + + def has_permission(self, request, view): + feature_code = getattr(view, "required_feature_code", None) + if not feature_code: + return True + + farm_uuid = self._extract_farm_uuid(request, view) + if not farm_uuid: + return True + + farm = self._resolve_owned_farm(request, farm_uuid) + if farm is None: + return True + + if is_feature_enabled_for_farm(farm, feature_code): + return True + + self.message = f"Access to feature `{feature_code}` is denied." + return False + + @staticmethod + def _extract_farm_uuid(request, view): + for key in ("farm_uuid", "farmUuid"): + farm_uuid = view.kwargs.get(key) + if farm_uuid: + return str(farm_uuid) + + for key in ("farm_uuid", "farmUuid"): + farm_uuid = request.query_params.get(key) + if farm_uuid: + return farm_uuid + + if isinstance(request.data, dict): + for key in ("farm_uuid", "farmUuid"): + farm_uuid = request.data.get(key) + if farm_uuid: + return farm_uuid + return None + + @staticmethod + def _resolve_owned_farm(request, farm_uuid): + try: + return ( + FarmHub.objects.select_related("access_profile") + .prefetch_related("products", "sensors") + .filter(farm_uuid=farm_uuid, owner=request.user) + .first() + ) + except (ValueError, TypeError, DjangoValidationError): + return None diff --git a/access_control/serializers.py b/access_control/serializers.py new file mode 100644 index 0000000..085941e --- /dev/null +++ b/access_control/serializers.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + +from .models import FarmAccessProfile, SubscriptionPlan + + +class SubscriptionPlanSerializer(serializers.ModelSerializer): + class Meta: + model = SubscriptionPlan + fields = ["uuid", "code", "name", "description", "metadata", "is_active"] + + +class FarmAccessProfileSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + subscription_plan = SubscriptionPlanSerializer(allow_null=True) + features = serializers.DictField() + groups = serializers.DictField() + matched_rules = serializers.ListField() + resolved_from_profile = serializers.BooleanField() + + +class FarmAccessProfileCacheSerializer(serializers.ModelSerializer): + class Meta: + model = FarmAccessProfile + fields = [ + "farm", + "cached_features", + "cached_groups", + "matched_rules", + "metadata", + "last_resolved_at", + "created_at", + "updated_at", + ] + diff --git a/access_control/services.py b/access_control/services.py new file mode 100644 index 0000000..f3a5127 --- /dev/null +++ b/access_control/services.py @@ -0,0 +1,139 @@ +from django.utils import timezone + +from .models import AccessFeature, AccessRule, FarmAccessProfile + + +def _manager_id_set(manager): + return {obj.id for obj in manager.all()} + + +def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None): + if not rule.is_active: + return False + + subscription_plan_ids = _manager_id_set(rule.subscription_plans) + if subscription_plan_ids: + if farm.subscription_plan_id is None or farm.subscription_plan_id not in subscription_plan_ids: + return False + + farm_type_ids = _manager_id_set(rule.farm_types) + if farm_type_ids and farm.farm_type_id not in farm_type_ids: + return False + + product_rule_ids = _manager_id_set(rule.products) + if product_rule_ids: + product_ids = product_ids if product_ids is not None else set(farm.products.values_list("id", flat=True)) + if not product_ids or product_rule_ids.isdisjoint(product_ids): + return False + + sensor_catalog_rule_ids = _manager_id_set(rule.sensor_catalogs) + if sensor_catalog_rule_ids: + sensor_catalog_ids = ( + sensor_catalog_ids + if sensor_catalog_ids is not None + else set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True)) + ) + if not sensor_catalog_ids or sensor_catalog_rule_ids.isdisjoint(sensor_catalog_ids): + return False + + sensor_catalog_rule_names = set(rule.metadata.get("sensor_catalog_names", [])) if isinstance(rule.metadata, dict) else set() + if sensor_catalog_rule_names: + farm_sensor_catalog_names = set( + farm.sensors.exclude(sensor_catalog__name__isnull=True).values_list("sensor_catalog__name", flat=True) + ) + if not farm_sensor_catalog_names or sensor_catalog_rule_names.isdisjoint(farm_sensor_catalog_names): + return False + + return True + + +def build_farm_access_profile(farm): + features = AccessFeature.objects.all().order_by("feature_type", "code") + resolved = { + feature.code: { + "enabled": feature.default_enabled, + "type": feature.feature_type, + "name": feature.name, + "description": feature.description, + "metadata": feature.metadata, + "source": "default", + } + for feature in features + } + + product_ids = set(farm.products.values_list("id", flat=True)) + sensor_catalog_ids = set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True)) + + rules = ( + AccessRule.objects.filter(is_active=True, features__isnull=False) + .distinct() + .prefetch_related("features", "subscription_plans", "farm_types", "products", "sensor_catalogs") + .order_by("priority", "id") + ) + + matched_rules = [] + for rule in rules: + if not rule_matches_farm(rule, farm, product_ids=product_ids, sensor_catalog_ids=sensor_catalog_ids): + continue + + matched_rules.append( + { + "code": rule.code, + "name": rule.name, + "effect": rule.effect, + "priority": rule.priority, + } + ) + is_enabled = rule.effect == AccessRule.ALLOW + for feature in rule.features.all(): + resolved[feature.code] = { + "enabled": is_enabled, + "type": feature.feature_type, + "name": feature.name, + "description": feature.description, + "metadata": feature.metadata, + "source": rule.code, + } + + grouped = {} + for code, payload in resolved.items(): + grouped.setdefault(f"{payload['type']}s", {})[code] = payload + + profile, _created = FarmAccessProfile.objects.update_or_create( + farm=farm, + defaults={ + "cached_features": resolved, + "cached_groups": grouped, + "matched_rules": matched_rules, + "last_resolved_at": timezone.now(), + }, + ) + + return { + "farm_uuid": str(farm.farm_uuid), + "subscription_plan": { + "uuid": str(farm.subscription_plan.uuid), + "code": farm.subscription_plan.code, + "name": farm.subscription_plan.name, + } + if farm.subscription_plan_id + else None, + "features": profile.cached_features, + "groups": profile.cached_groups, + "matched_rules": profile.matched_rules, + "resolved_from_profile": True, + } + + +def is_feature_enabled_for_farm(farm, feature_code): + profile = getattr(farm, "access_profile", None) + if profile and isinstance(profile.cached_features, dict): + feature_payload = profile.cached_features.get(feature_code) + if feature_payload is not None: + return bool(feature_payload.get("enabled")) + + profile_data = build_farm_access_profile(farm) + feature_payload = profile_data["features"].get(feature_code) + if feature_payload is None: + return False + return bool(feature_payload.get("enabled")) diff --git a/access_control/urls.py b/access_control/urls.py new file mode 100644 index 0000000..3db4847 --- /dev/null +++ b/access_control/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import FarmAccessProfileView, SubscriptionPlanListView + + +urlpatterns = [ + path("subscription-plans/", SubscriptionPlanListView.as_view(), name="subscription-plan-list"), + path("farms//profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"), +] + diff --git a/access_control/views.py b/access_control/views.py new file mode 100644 index 0000000..e20d90f --- /dev/null +++ b/access_control/views.py @@ -0,0 +1,57 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from config.swagger import code_response +from farm_hub.models import FarmHub + +from .models import SubscriptionPlan +from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer +from .services import build_farm_access_profile + + +class AccessControlBaseView(APIView): + permission_classes = [IsAuthenticated] + + def _get_farm(self, request, farm_uuid): + try: + return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related( + "farm_type", + "subscription_plan", + ).get( + farm_uuid=farm_uuid, + owner=request.user, + ) + except FarmHub.DoesNotExist: + return None + + +class SubscriptionPlanListView(AccessControlBaseView): + @extend_schema( + tags=["Access Control"], + responses={200: code_response("SubscriptionPlanListResponse", data=SubscriptionPlanSerializer(many=True))}, + ) + def get(self, request): + plans = SubscriptionPlan.objects.filter(is_active=True).order_by("name") + data = SubscriptionPlanSerializer(plans, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class FarmAccessProfileView(AccessControlBaseView): + @extend_schema( + tags=["Access Control"], + responses={ + 200: code_response("FarmAccessProfileResponse", data=FarmAccessProfileSerializer()), + 404: code_response("FarmAccessProfileNotFoundResponse"), + }, + ) + def get(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + data = build_farm_access_profile(farm) + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + diff --git a/config/settings.py b/config/settings.py index f8bcfb4..6c4bab8 100644 --- a/config/settings.py +++ b/config/settings.py @@ -28,6 +28,7 @@ INSTALLED_APPS = [ "auth.apps.AuthConfig", "account.apps.AccountConfig", "farm_hub.apps.FarmHubConfig", + "access_control.apps.AccessControlConfig", "sensor_catalog.apps.SensorCatalogConfig", "dashboard", "crop_zoning", @@ -36,6 +37,7 @@ INSTALLED_APPS = [ "irrigation_recommendation", "fertilization_recommendation", "farm_ai_assistant", + "notifications.apps.NotificationsConfig", "external_api_adapter.apps.ExternalApiAdapterConfig", "rest_framework", "drf_spectacular", @@ -114,6 +116,7 @@ CACHES = { REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", + "access_control.permissions.FeatureAccessPermission", ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", @@ -153,7 +156,6 @@ EXTERNAL_SERVICES = { }, } - SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(days=7), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), @@ -166,6 +168,7 @@ 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) 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")) diff --git a/config/urls.py b/config/urls.py index 9f79a53..ed27d18 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("api/auth/", include("auth.urls")), path("api/account/", include("account.urls")), path("api/farm-hub/", include("farm_hub.urls")), + path("api/access-control/", include("access_control.urls")), path("api/sensor-catalog/", include("sensor_catalog.urls")), path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard/", include("dashboard.urls")), @@ -19,4 +20,5 @@ urlpatterns = [ path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")), path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")), + path("api/notifications/", include("notifications.urls")), ] diff --git a/dashboard/tests.py b/dashboard/tests.py index 9706c93..5ec8520 100644 --- a/dashboard/tests.py +++ b/dashboard/tests.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIRequestFactory, force_authenticate +from access_control.models import AccessFeature, AccessRule from farm_hub.models import FarmHub, FarmType from .mock_data import DEFAULT_CONFIG @@ -30,6 +31,17 @@ class DashboardBaseTestCase(TestCase): self.farm_type = FarmType.objects.create(name="زراعی") self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + self.dashboard_feature = AccessFeature.objects.create( + code="greenhouse-dashboard", + name="Greenhouse Dashboard", + feature_type=AccessFeature.PAGE, + ) + self.allow_dashboard_rule = AccessRule.objects.create( + code="allow-greenhouse-dashboard", + name="Allow Greenhouse Dashboard", + priority=10, + ) + self.allow_dashboard_rule.features.add(self.dashboard_feature) class FarmDashboardConfigViewTests(DashboardBaseTestCase): @@ -150,3 +162,19 @@ class FarmDashboardCardsViewTests(DashboardBaseTestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.data["farm_uuid"][0], "This field is required.") + + def test_get_denies_access_when_feature_is_blocked(self): + deny_rule = AccessRule.objects.create( + code="deny-greenhouse-dashboard", + name="Deny Greenhouse Dashboard", + priority=20, + effect=AccessRule.DENY, + ) + deny_rule.features.add(self.dashboard_feature) + + request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 403) diff --git a/dashboard/views.py b/dashboard/views.py index 50768f0..74ad3ec 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -70,6 +70,7 @@ class FarmDashboardConfigView(FarmAccessMixin, APIView): """ permission_classes = [IsAuthenticated] + required_feature_code = "greenhouse-dashboard" def get(self, request): farm = self._get_farm(request, request.query_params.get("farm_uuid")) @@ -120,6 +121,7 @@ class FarmDashboardCardsView(FarmAccessMixin, APIView): """ permission_classes = [IsAuthenticated] + required_feature_code = "greenhouse-dashboard" def get(self, request): farm = self._get_farm(request, request.query_params.get("farm_uuid")) diff --git a/farm_hub/API_REFERENCE_FA.md b/farm_hub/API_REFERENCE_FA.md new file mode 100644 index 0000000..c9e8db3 --- /dev/null +++ b/farm_hub/API_REFERENCE_FA.md @@ -0,0 +1,865 @@ +# مستندات کامل API های `farm_hub` + +این فایل بر اساس پیاده‌سازی واقعی اپ `farm_hub` در فایل‌های `farm_hub/urls.py`, `farm_hub/views.py`, `farm_hub/serializers.py`, `farm_hub/models.py` و `farm_hub/services.py` تهیه شده است. + +نکته مهم: فایل `farm_hub/apps.py` فقط برای ثبت Django app استفاده می‌شود و خودِ APIها داخل آن تعریف نشده‌اند. APIهای این ماژول در `farm_hub/urls.py` و `farm_hub/views.py` قرار دارند. + +## مشخصات کلی + +- Base path: + +```text +/api/farm-hub/ +``` + +- احراز هویت: + +تمام endpointهای این ماژول نیاز به کاربر لاگین‌شده دارند. + +```http +Authorization: Bearer +``` + +- فرمت کلی پاسخ موفق: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +- فرمت کلی پاسخ خطا: + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +یا در خطاهای validation: + +```json +{ + "field_name": [ + "error message" + ] +} +``` + +## لیست endpointها + +| Method | URL | توضیح | +|---|---|---| +| GET | `/api/farm-hub/` | دریافت لیست مزارع کاربر جاری | +| POST | `/api/farm-hub/` | ساخت مزرعه جدید | +| GET | `/api/farm-hub/farm-types/` | دریافت لیست نوع مزرعه‌ها | +| GET | `/api/farm-hub/farm-types/{farm_type_uuid}/products/` | دریافت محصولات مربوط به یک نوع مزرعه | +| GET | `/api/farm-hub/{farm_uuid}/` | دریافت جزئیات یک مزرعه | +| PATCH | `/api/farm-hub/{farm_uuid}/` | ویرایش مزرعه | +| DELETE | `/api/farm-hub/{farm_uuid}/` | حذف مزرعه | +| POST | `/api/farm-hub/active/` | فعال‌کردن مزرعه | +| POST | `/api/farm-hub/deactive/` | غیرفعال‌کردن مزرعه | + +--- + +## 1) دریافت لیست مزارع + +### Request + +```http +GET /api/farm-hub/ +Authorization: Bearer +``` + +### رفتار + +- فقط مزارع متعلق به کاربر جاری برگردانده می‌شوند. +- برای هر مزرعه، اطلاعات `farm_type`، لیست `products`، لیست `sensors` و `area_uuid` برگردانده می‌شود. + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": { + "moisture": { + "ideal_value": 65 + } + }, + "irrigation_profile": {}, + "growth_profile": {} + } + ], + "sensors": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + }, + "last_updated": "2025-02-18T12:00:00Z" + } + ], + "last_updated": "2025-02-18T12:00:00Z" + } + ] +} +``` + +--- + +## 2) ساخت مزرعه جدید + +### Request + +```http +POST /api/farm-hub/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type_uuid": "11111111-1111-1111-1111-111111111111", + "product_uuids": [ + "22222222-2222-2222-2222-222222222222" + ], + "sensors": [ + { + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + } + } + ], + "area_geojson": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815] + ] + ] + } + } +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---|---| +| `name` | string | بله | نام مزرعه | +| `is_active` | boolean | خیر | وضعیت فعال بودن مزرعه؛ پیش‌فرض مدل `true` است | +| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه | +| `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست | +| `sensors` | array | خیر | لیست سنسورهای مزرعه | +| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon` | + +### فیلدهای هر سنسور در `sensors` + +| فیلد | نوع | اجباری | توضیح | +|---|---|---|---| +| `sensor_catalog_uuid` | uuid | خیر | اگر ارسال شود باید در `SensorCatalog` وجود داشته باشد | +| `physical_device_uuid` | uuid | خیر | شناسه دستگاه فیزیکی؛ اگر داده نشود مدل خودش مقدار تولید می‌کند | +| `name` | string | وابسته به ورودی | نام سنسور؛ اگر `sensor_catalog_uuid` معتبر باشد و `name` نفرستید، از نام catalog استفاده می‌شود، ولی اگر `sensor_catalog_uuid` هم نداشته باشید عملا باید `name` را بفرستید | +| `sensor_type` | string | خیر | نوع سنسور | +| `is_active` | boolean | خیر | وضعیت فعال بودن سنسور | +| `specifications` | object | خیر | مشخصات فنی | +| `power_source` | object | خیر | نوع یا جزئیات منبع تغذیه | + +### اعتبارسنجی‌ها + +- `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته می‌شود. +- `farm_type_uuid` باید معتبر باشد، وگرنه: + +```json +{ + "farm_type_uuid": [ + "Farm type not found." + ] +} +``` + +- `product_uuids` باید همگی وجود داشته باشند: + +```json +{ + "product_uuids": [ + "One or more products were not found." + ] +} +``` + +- همه محصولات باید متعلق به همان `farm_type` باشند: + +```json +{ + "product_uuids": [ + "Products must belong to farm type `زراعی`." + ] +} +``` + +- `sensor_catalog_uuid` اگر ارسال شود باید معتبر باشد: + +```json +{ + "sensors": [ + { + "sensor_catalog_uuid": [ + "Sensor catalog not found." + ] + } + ] +} +``` + +- `area_geojson` باید object معتبر باشد. +- اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی می‌شود. +- `geometry.type` فقط باید `Polygon` باشد. +- `coordinates` باید ساختار polygon ring داشته باشد. + +نمونه خطاهای `area_geojson`: + +```json +{ + "area_geojson": [ + "`area_geojson` must be a GeoJSON object." + ] +} +``` + +```json +{ + "area_geojson": [ + "`area_geojson.geometry.type` must be `Polygon`." + ] +} +``` + +### رفتار داخلی مهم + +- اگر `area_geojson` ارسال نشود، سیستم از `get_default_area_feature()` استفاده می‌کند. +- بعد از ساخت مزرعه، فرآیند zoning اجرا می‌شود. +- خروجی zoning به `current_crop_area` وصل می‌شود. +- اگر zoning با موفقیت ساخته شود، در response فیلد `zoning` هم برگردانده می‌شود. + +### Response 201 + +```json +{ + "code": 201, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": {}, + "irrigation_profile": {}, + "growth_profile": {} + } + ], + "sensors": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + }, + "last_updated": "2025-02-18T12:00:00Z" + } + ], + "last_updated": "2025-02-18T12:00:00Z", + "zoning": { + "zone_count": 4 + } + } +} +``` + +### Response 500 + +در صورتی که سرویس لازم برای zoning/config به‌درستی تنظیم نشده باشد: + +```json +{ + "code": 500, + "msg": "..." +} +``` + +--- + +## 3) دریافت لیست نوع مزرعه‌ها + +### Request + +```http +GET /api/farm-hub/farm-types/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "درختی", + "description": "", + "metadata": {} + } + ] +} +``` + +### نکته + +- خروجی بر اساس `name` مرتب می‌شود. + +--- + +## 4) دریافت محصولات یک نوع مزرعه + +### Request + +```http +GET /api/farm-hub/farm-types/{farm_type_uuid}/products/ +Authorization: Bearer +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_type_uuid` | uuid | شناسه نوع مزرعه | + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": { + "moisture": { + "ideal_value": 65 + } + }, + "irrigation_profile": {}, + "growth_profile": {} + } + ] +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm type not found." +} +``` + +### نکته + +- محصولات با ترتیب `name` برگردانده می‌شوند. + +--- + +## 5) دریافت جزئیات یک مزرعه + +### Request + +```http +GET /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_uuid` | uuid | شناسه مزرعه | + +### رفتار + +- فقط اگر مزرعه متعلق به کاربر جاری باشد برگردانده می‌شود. +- اگر UUID وجود داشته باشد ولی متعلق به کاربر دیگری باشد، عملا مثل not found رفتار می‌شود. + +### Response 200 + +ساختار `data` دقیقا مثل آیتم‌های خروجی لیست مزرعه‌ها است. + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +--- + +## 6) ویرایش مزرعه + +### Request + +```http +PATCH /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +Content-Type: application/json +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_uuid` | uuid | شناسه مزرعه | + +### Body + +این endpoint از `partial update` استفاده می‌کند؛ یعنی می‌توانید فقط بخشی از فیلدها را بفرستید. + +نمونه: + +```json +{ + "name": "مزرعه اصلاح شده", + "is_active": false, + "farm_type_uuid": "11111111-1111-1111-1111-111111111111", + "product_uuids": [ + "22222222-2222-2222-2222-222222222222" + ], + "sensors": [ + { + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station Updated", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-2" + }, + "power_source": { + "type": "solar" + } + } + ] +} +``` + +### رفتار update + +- `name` و `is_active` در صورت ارسال تغییر می‌کنند. +- اگر `farm_type_uuid` ارسال شود، نوع مزرعه به‌روزرسانی می‌شود. +- اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین می‌شوند. +- اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته می‌شوند. +- `area_geojson` در متد `update` دریافت می‌شود ولی در حال حاضر برای update نادیده گرفته می‌شود و zoning مجدد انجام نمی‌شود. + +### اعتبارسنجی + +همان قوانین create اینجا هم برقرار است، با این تفاوت: + +- در update، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده می‌شود. +- در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ می‌شوند. +- در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ می‌شوند. + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه اصلاح شده", + "is_active": false, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [], + "sensors": [], + "last_updated": "2025-02-18T13:00:00Z" + } +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +--- + +## 7) حذف مزرعه + +### Request + +```http +DELETE /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### نکته + +- فقط مزرعه متعلق به کاربر جاری حذف می‌شود. + +--- + +## 8) فعال‌کردن مزرعه + +### Request + +```http +POST /api/farm-hub/active/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### خطای validation + +اگر `farm_uuid` ارسال نشود یا فرمت آن درست نباشد، خطای serializer برگردانده می‌شود. نمونه: + +```json +{ + "farm_uuid": [ + "This field is required." + ] +} +``` + +--- + +## 9) غیرفعال‌کردن مزرعه + +### Request + +```http +POST /api/farm-hub/deactive/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +--- + +## ساختار آبجکت‌ها + +## آبجکت `FarmType` + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} +} +``` + +## آبجکت `Product` + +```json +{ + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "", + "harvest_time": "", + "spacing": "", + "fertilizer": "", + "health_profile": {}, + "irrigation_profile": {}, + "growth_profile": {} +} +``` + +### توضیح فیلدهای پروفایل محصول + +- `health_profile`: برای KPIها و سلامت محصول +- `irrigation_profile`: برای محاسبات آبیاری و ETc +- `growth_profile`: برای مدل رشد مانند GDD + +نمونه ساختار `health_profile`: + +```json +{ + "moisture": { + "ideal_value": 65, + "min_range": 45, + "max_range": 75, + "weight": 0.4 + } +} +``` + +نمونه ساختار `irrigation_profile`: + +```json +{ + "kc_initial": 0.6, + "kc_mid": 1.15, + "kc_end": 0.8, + "growth_stage_duration": { + "initial": 20, + "mid": 30, + "late": 25 + } +} +``` + +نمونه ساختار `growth_profile`: + +```json +{ + "base_temperature": 10, + "required_gdd_for_maturity": 1200, + "stage_thresholds": { + "flowering": 500, + "fruiting": 850 + }, + "current_cumulative_gdd": 320 +} +``` + +## آبجکت `FarmSensor` + +```json +{ + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": {}, + "power_source": {}, + "last_updated": "2025-02-18T12:00:00Z" +} +``` + +## آبجکت `FarmHub` + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": {}, + "products": [], + "sensors": [], + "last_updated": "2025-02-18T12:00:00Z" +} +``` + +--- + +## نکات مهم برای فرانت‌اند + +- برای ساخت مزرعه، ابتدا `GET /api/farm-hub/farm-types/` را صدا بزنید. +- سپس برای نوع انتخاب‌شده، `GET /api/farm-hub/farm-types/{farm_type_uuid}/products/` را بگیرید. +- برای ساخت یا ویرایش مزرعه، `product_uuids` باید با `farm_type_uuid` هم‌خوانی داشته باشند. +- اگر در update فیلد `sensors` را بفرستید، لیست قبلی کامل جایگزین می‌شود. +- اگر در create، `area_geojson` نفرستید، سیستم خودش یک محدوده پیش‌فرض می‌سازد. +- endpointهای detail/update/delete/active/deactive فقط روی مزرعه‌های خود کاربر عمل می‌کنند. + +--- + +## منبع پیاده‌سازی + +- رجیستر اپ: `farm_hub/apps.py` +- تعریف routeها: `farm_hub/urls.py` +- منطق APIها: `farm_hub/views.py` +- serializerها و validation: `farm_hub/serializers.py` +- مدل‌ها: `farm_hub/models.py` +- منطق ساخت zoning: `farm_hub/services.py` +- نمونه requestها: `farm_hub/postman/farm_hub.json` diff --git a/farm_hub/migrations/0007_farmhub_subscription_plan.py b/farm_hub/migrations/0007_farmhub_subscription_plan.py new file mode 100644 index 0000000..763ef49 --- /dev/null +++ b/farm_hub/migrations/0007_farmhub_subscription_plan.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.12 on 2026-04-03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0006_seed_expanded_product_catalog"), + ("access_control", "0002_link_subscription_plan_to_farm"), + ] + + operations = [ + migrations.AddField( + model_name="farmhub", + name="subscription_plan", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farms", + to="access_control.subscriptionplan", + ), + ), + ] diff --git a/farm_hub/models.py b/farm_hub/models.py index e53e1e3..b31fecb 100644 --- a/farm_hub/models.py +++ b/farm_hub/models.py @@ -91,6 +91,13 @@ class FarmHub(models.Model): on_delete=models.PROTECT, related_name="farms", ) + subscription_plan = models.ForeignKey( + "access_control.SubscriptionPlan", + on_delete=models.PROTECT, + related_name="farms", + null=True, + blank=True, + ) name = models.CharField(max_length=255) is_active = models.BooleanField(default=True) current_crop_area = models.ForeignKey( diff --git a/farm_hub/serializers.py b/farm_hub/serializers.py index ba44743..d0d4d0b 100644 --- a/farm_hub/serializers.py +++ b/farm_hub/serializers.py @@ -1,4 +1,7 @@ from rest_framework import serializers +from access_control.models import SubscriptionPlan +from access_control.serializers import SubscriptionPlanSerializer +from access_control.catalog import GOLD_PLAN_CODE from .models import FarmHub, FarmSensor, FarmType, Product from sensor_catalog.models import SensorCatalog @@ -55,6 +58,7 @@ class FarmSensorSerializer(serializers.ModelSerializer): class FarmHubSerializer(serializers.ModelSerializer): last_updated = serializers.DateTimeField(source="updated_at", read_only=True) farm_type = FarmTypeSerializer(read_only=True) + subscription_plan = SubscriptionPlanSerializer(read_only=True) products = ProductSerializer(many=True, read_only=True) sensors = FarmSensorSerializer(many=True, read_only=True) area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True) @@ -67,6 +71,7 @@ class FarmHubSerializer(serializers.ModelSerializer): "name", "is_active", "farm_type", + "subscription_plan", "products", "sensors", "last_updated", @@ -105,6 +110,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer): class FarmHubCreateSerializer(serializers.ModelSerializer): area_geojson = serializers.JSONField(write_only=True, required=False) farm_type_uuid = serializers.UUIDField(write_only=True) + subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True) product_uuids = serializers.ListField( child=serializers.UUIDField(), write_only=True, @@ -118,6 +124,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): "name", "is_active", "farm_type_uuid", + "subscription_plan_uuid", "product_uuids", "sensors", "area_geojson", @@ -148,6 +155,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): def validate(self, attrs): farm_type_uuid = attrs.get("farm_type_uuid") + subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty) product_uuids = attrs.get("product_uuids") if farm_type_uuid is None: @@ -173,7 +181,21 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): {"product_uuids": [f"Products must belong to farm type `{farm_type.name}`."]} ) + if subscription_plan_uuid is serializers.empty: + if self.instance is not None: + subscription_plan = self.instance.subscription_plan + else: + subscription_plan = SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first() + elif subscription_plan_uuid is None: + subscription_plan = None + else: + try: + subscription_plan = SubscriptionPlan.objects.get(uuid=subscription_plan_uuid, is_active=True) + except SubscriptionPlan.DoesNotExist as exc: + raise serializers.ValidationError({"subscription_plan_uuid": ["Subscription plan not found."]}) from exc + attrs["farm_type"] = farm_type + attrs["subscription_plan"] = subscription_plan attrs["products"] = products return attrs @@ -182,7 +204,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): sensors_data = validated_data.pop("sensors", []) products = validated_data.pop("products", []) validated_data["farm_type"] = validated_data.pop("farm_type") + validated_data["subscription_plan"] = validated_data.pop("subscription_plan", None) validated_data.pop("farm_type_uuid", None) + validated_data.pop("subscription_plan_uuid", None) validated_data.pop("product_uuids", None) farm = super().create(validated_data) @@ -197,13 +221,17 @@ class FarmHubCreateSerializer(serializers.ModelSerializer): sensors_data = validated_data.pop("sensors", None) products = validated_data.pop("products", None) farm_type = validated_data.pop("farm_type", None) + subscription_plan = validated_data.pop("subscription_plan", serializers.empty) validated_data.pop("farm_type_uuid", None) + validated_data.pop("subscription_plan_uuid", None) validated_data.pop("product_uuids", None) for attr, value in validated_data.items(): setattr(instance, attr, value) if farm_type is not None: instance.farm_type = farm_type + if subscription_plan is not serializers.empty: + instance.subscription_plan = subscription_plan instance.save() if products is not None: diff --git a/farm_hub/tests.py b/farm_hub/tests.py index 18ee419..29037db 100644 --- a/farm_hub/tests.py +++ b/farm_hub/tests.py @@ -2,8 +2,11 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from rest_framework.test import APIRequestFactory, force_authenticate +from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan +from access_control.services import build_farm_access_profile +from access_control.views import FarmAccessProfileView from crop_zoning.models import CropArea -from farm_hub.models import FarmType, Product +from farm_hub.models import FarmHub, FarmType, Product from farm_hub.seeds import seed_admin_farm from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView from sensor_catalog.models import SensorCatalog @@ -41,6 +44,7 @@ class FarmListCreateViewTests(TestCase): ) self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم") + self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold") self.weather_station, _ = SensorCatalog.objects.get_or_create( name="Sensor 7 - Soil Moisture Sensor v1.2", defaults={"supported_power_sources": ["solar", "direct_power"]}, @@ -53,6 +57,7 @@ class FarmListCreateViewTests(TestCase): { "name": "farm-1", "farm_type_uuid": str(self.farm_type.uuid), + "subscription_plan_uuid": str(self.plan.uuid), "product_uuids": [str(self.wheat.uuid)], "sensors": [ { @@ -75,6 +80,7 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.status_code, 201) self.assertEqual(response.data["code"], 201) self.assertEqual(response.data["data"]["name"], "farm-1") + self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code) self.assertIn("zoning", response.data["data"]) self.assertIsNotNone(response.data["data"]["area_uuid"]) self.assertEqual(len(response.data["data"]["sensors"]), 1) @@ -129,6 +135,23 @@ class FarmListCreateViewTests(TestCase): self.assertEqual(response.status_code, 400) self.assertIn("sensor_catalog_uuid", response.data["sensors"][0]) + def test_create_farm_defaults_to_gold_plan_when_not_provided(self): + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-default-plan", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold") + @override_settings( USE_EXTERNAL_API_MOCK=True, @@ -215,3 +238,100 @@ class FarmCatalogViewsTests(TestCase): self.assertEqual(response.status_code, 404) self.assertEqual(response.data["msg"], "Farm type not found.") + + +class FarmAccessProfileTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="feature-user", + password="secret123", + email="feature@example.com", + phone_number="09120000002", + ) + self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter") + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.product = Product.objects.create(farm_type=self.farm_type, name="خیار") + self.sensor_catalog = SensorCatalog.objects.create(name="Climate Sensor") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Feature Farm", + ) + self.farm.products.add(self.product) + self.farm.sensors.create(name="Climate Node", sensor_catalog=self.sensor_catalog, sensor_type="climate") + + self.greenhouse_dashboard = AccessFeature.objects.create( + code="greenhouse-dashboard", + name="Greenhouse Dashboard", + feature_type=AccessFeature.PAGE, + ) + self.sensor_analytics = AccessFeature.objects.create( + code="sensor-analytics", + name="Sensor Analytics", + feature_type=AccessFeature.WIDGET, + ) + self.legacy_reports = AccessFeature.objects.create( + code="legacy-reports", + name="Legacy Reports", + feature_type=AccessFeature.PAGE, + default_enabled=True, + ) + + plan_rule = AccessRule.objects.create(code="starter-greenhouse", name="Starter Greenhouse", priority=10) + plan_rule.features.add(self.greenhouse_dashboard) + plan_rule.subscription_plans.add(self.plan) + plan_rule.farm_types.add(self.farm_type) + + sensor_rule = AccessRule.objects.create(code="sensor-analytics-rule", name="Sensor Analytics", priority=20) + sensor_rule.features.add(self.sensor_analytics) + sensor_rule.sensor_catalogs.add(self.sensor_catalog) + + deny_rule = AccessRule.objects.create( + code="hide-legacy-reports", + name="Hide Legacy Reports", + priority=30, + effect=AccessRule.DENY, + ) + deny_rule.features.add(self.legacy_reports) + deny_rule.products.add(self.product) + + def test_build_farm_access_profile_resolves_combined_rules(self): + profile = build_farm_access_profile(self.farm) + + self.assertEqual(profile["subscription_plan"]["code"], self.plan.code) + self.assertTrue(profile["features"]["greenhouse-dashboard"]["enabled"]) + self.assertTrue(profile["features"]["sensor-analytics"]["enabled"]) + self.assertFalse(profile["features"]["legacy-reports"]["enabled"]) + self.assertEqual(profile["features"]["legacy-reports"]["source"], "hide-legacy-reports") + self.assertEqual(len(profile["matched_rules"]), 3) + + def test_access_profile_view_returns_grouped_features(self): + request = self.factory.get(f"/api/access-control/farms/{self.farm.farm_uuid}/profile/") + force_authenticate(request, user=self.user) + + response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["groups"]["pages"]["greenhouse-dashboard"]["enabled"], True) + self.assertEqual(response.data["data"]["groups"]["widgets"]["sensor-analytics"]["enabled"], True) + self.assertTrue(FarmAccessProfile.objects.filter(farm=self.farm).exists()) + + def test_sensor_rule_can_match_by_metadata_sensor_name(self): + sensor_page = AccessFeature.objects.create( + code="sensor-page", + name="Sensor Page", + feature_type=AccessFeature.PAGE, + ) + sensor_rule = AccessRule.objects.create( + code="sensor-page-by-name", + name="Sensor Page By Name", + priority=40, + metadata={"sensor_catalog_names": [self.sensor_catalog.name]}, + ) + sensor_rule.features.add(sensor_page) + + profile = build_farm_access_profile(self.farm) + + self.assertTrue(profile["features"]["sensor-page"]["enabled"]) diff --git a/farm_hub/views.py b/farm_hub/views.py index 297185a..edcd53c 100644 --- a/farm_hub/views.py +++ b/farm_hub/views.py @@ -24,6 +24,7 @@ class FarmHubBaseView(APIView): try: return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related( "farm_type", + "subscription_plan", "current_crop_area", ).get( farm_uuid=farm_uuid, @@ -39,7 +40,11 @@ class FarmListCreateView(FarmHubBaseView): responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, ) def get(self, request): - farms = FarmHub.objects.filter(owner=request.user).select_related("farm_type", "current_crop_area").prefetch_related( + farms = FarmHub.objects.filter(owner=request.user).select_related( + "farm_type", + "subscription_plan", + "current_crop_area", + ).prefetch_related( "products", "sensors", "sensors__sensor_catalog", diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/notifications/__init__.py @@ -0,0 +1 @@ + diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..3a08476 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..2f22165 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +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") diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..899c0b6 --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,33 @@ +import json +import uuid +from datetime import datetime, timezone + +from django.conf import settings +from redis import Redis + + +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) + + +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 _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" diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..410b6b3 --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,97 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from .views import NotificationPublishView, NotificationStreamView + + +class NotificationPublishViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notify-user", + password="secret123", + email="notify@example.com", + phone_number="09120000099", + ) + + @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"} + request = self.factory.post( + "/api/notifications/publish/", + { + "channel": "user-1", + "title": "Test", + "message": "hello", + "level": "info", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NotificationPublishView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + mock_publish_notification.assert_called_once() + + +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", + ) + + @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 = NotificationStreamView.as_view()(request) + + 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) + + @staticmethod + def _to_text(value): + if isinstance(value, bytes): + return value.decode() + return str(value) diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..18c8e8a --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import NotificationPublishView, NotificationStreamView + +urlpatterns = [ + path("stream/", NotificationStreamView.as_view(), name="notifications-stream"), + path("publish/", NotificationPublishView.as_view(), name="notifications-publish"), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..7cda767 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,84 @@ +import json +import time + +from django.http import StreamingHttpResponse +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +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 config.swagger import code_response + +from .serializers import NotificationPublishSerializer +from .services import get_notifications_redis_client, publish_notification + + +def _sse_event(event_name, data): + return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +class NotificationStreamView(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}, + ) + 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.is_valid(raise_exception=True) + payload = publish_notification(**serializer.validated_data) + return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK)