This commit is contained in:
2026-04-03 23:51:00 +03:30
parent e2728871ee
commit ecb42c6895
32 changed files with 2336 additions and 3 deletions
+194
View File
@@ -0,0 +1,194 @@
# تغییرات سطح دسترسی برای فرانت
این سند توضیح می‌دهد فرانت چطور از سیستم `access_control` استفاده کند، چه `feature code` هایی وجود دارند، و plan `gold` دقیقاً چه دسترسی‌هایی می‌دهد.
## 1) API پروفایل دسترسی مزرعه
برای گرفتن دسترسی موثر هر مزرعه از این endpoint استفاده کنید:
- `GET /api/access-control/farms/{farm_uuid}/profile/`
- نیازمند `Authorization: Bearer <access_token>`
نمونه پاسخ:
```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
+115
View File
@@ -0,0 +1,115 @@
# راهنمای استفاده فرانت از سیستم نوتیفیکیشن (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 شده است.
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccessControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "access_control"
+69
View File
@@ -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]},
},
]
+109
View File
@@ -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"},
),
]
@@ -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 = []
@@ -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),
]
+1
View File
@@ -0,0 +1 @@
+108
View File
@@ -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)
+60
View File
@@ -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
+34
View File
@@ -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",
]
+139
View File
@@ -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"))
+10
View File
@@ -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/<uuid:farm_uuid>/profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"),
]
+57
View File
@@ -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)
+4 -1
View File
@@ -28,6 +28,7 @@ INSTALLED_APPS = [
"auth.apps.AuthConfig", "auth.apps.AuthConfig",
"account.apps.AccountConfig", "account.apps.AccountConfig",
"farm_hub.apps.FarmHubConfig", "farm_hub.apps.FarmHubConfig",
"access_control.apps.AccessControlConfig",
"sensor_catalog.apps.SensorCatalogConfig", "sensor_catalog.apps.SensorCatalogConfig",
"dashboard", "dashboard",
"crop_zoning", "crop_zoning",
@@ -36,6 +37,7 @@ INSTALLED_APPS = [
"irrigation_recommendation", "irrigation_recommendation",
"fertilization_recommendation", "fertilization_recommendation",
"farm_ai_assistant", "farm_ai_assistant",
"notifications.apps.NotificationsConfig",
"external_api_adapter.apps.ExternalApiAdapterConfig", "external_api_adapter.apps.ExternalApiAdapterConfig",
"rest_framework", "rest_framework",
"drf_spectacular", "drf_spectacular",
@@ -114,6 +116,7 @@ CACHES = {
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
"access_control.permissions.FeatureAccessPermission",
], ],
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication", "rest_framework_simplejwt.authentication.JWTAuthentication",
@@ -153,7 +156,6 @@ EXTERNAL_SERVICES = {
}, },
} }
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=7), "ACCESS_TOKEN_LIFETIME": timedelta(days=7),
"REFRESH_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_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) 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_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
CELERY_TASK_ACKS_LATE = True CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1")) CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
+2
View File
@@ -10,6 +10,7 @@ urlpatterns = [
path("api/auth/", include("auth.urls")), path("api/auth/", include("auth.urls")),
path("api/account/", include("account.urls")), path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.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/sensor-catalog/", include("sensor_catalog.urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")), path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")), path("api/farm-dashboard/", include("dashboard.urls")),
@@ -19,4 +20,5 @@ urlpatterns = [
path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")), path("api/irrigation-recommendation/", include("irrigation_recommendation.urls")),
path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")), path("api/fertilization-recommendation/", include("fertilization_recommendation.urls")),
path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")), path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")),
path("api/notifications/", include("notifications.urls")),
] ]
+28
View File
@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.test import APIRequestFactory, force_authenticate
from access_control.models import AccessFeature, AccessRule
from farm_hub.models import FarmHub, FarmType from farm_hub.models import FarmHub, FarmType
from .mock_data import DEFAULT_CONFIG from .mock_data import DEFAULT_CONFIG
@@ -30,6 +31,17 @@ class DashboardBaseTestCase(TestCase):
self.farm_type = FarmType.objects.create(name="زراعی") self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") 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.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): class FarmDashboardConfigViewTests(DashboardBaseTestCase):
@@ -150,3 +162,19 @@ class FarmDashboardCardsViewTests(DashboardBaseTestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["farm_uuid"][0], "This field is required.") 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)
+2
View File
@@ -70,6 +70,7 @@ class FarmDashboardConfigView(FarmAccessMixin, APIView):
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
required_feature_code = "greenhouse-dashboard"
def get(self, request): def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid")) farm = self._get_farm(request, request.query_params.get("farm_uuid"))
@@ -120,6 +121,7 @@ class FarmDashboardCardsView(FarmAccessMixin, APIView):
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
required_feature_code = "greenhouse-dashboard"
def get(self, request): def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid")) farm = self._get_farm(request, request.query_params.get("farm_uuid"))
+865
View File
@@ -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 <token>
```
- فرمت کلی پاسخ موفق:
```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 <token>
```
### رفتار
- فقط مزارع متعلق به کاربر جاری برگردانده می‌شوند.
- برای هر مزرعه، اطلاعات `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 <token>
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 <token>
```
### 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 <token>
```
### 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 <token>
```
### 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 <token>
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 <token>
```
### 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 <token>
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 <token>
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`
@@ -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",
),
),
]
+7
View File
@@ -91,6 +91,13 @@ class FarmHub(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="farms", 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) name = models.CharField(max_length=255)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
current_crop_area = models.ForeignKey( current_crop_area = models.ForeignKey(
+28
View File
@@ -1,4 +1,7 @@
from rest_framework import serializers 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 .models import FarmHub, FarmSensor, FarmType, Product
from sensor_catalog.models import SensorCatalog from sensor_catalog.models import SensorCatalog
@@ -55,6 +58,7 @@ class FarmSensorSerializer(serializers.ModelSerializer):
class FarmHubSerializer(serializers.ModelSerializer): class FarmHubSerializer(serializers.ModelSerializer):
last_updated = serializers.DateTimeField(source="updated_at", read_only=True) last_updated = serializers.DateTimeField(source="updated_at", read_only=True)
farm_type = FarmTypeSerializer(read_only=True) farm_type = FarmTypeSerializer(read_only=True)
subscription_plan = SubscriptionPlanSerializer(read_only=True)
products = ProductSerializer(many=True, read_only=True) products = ProductSerializer(many=True, read_only=True)
sensors = FarmSensorSerializer(many=True, read_only=True) sensors = FarmSensorSerializer(many=True, read_only=True)
area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True) area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True)
@@ -67,6 +71,7 @@ class FarmHubSerializer(serializers.ModelSerializer):
"name", "name",
"is_active", "is_active",
"farm_type", "farm_type",
"subscription_plan",
"products", "products",
"sensors", "sensors",
"last_updated", "last_updated",
@@ -105,6 +110,7 @@ class FarmSensorWriteSerializer(serializers.ModelSerializer):
class FarmHubCreateSerializer(serializers.ModelSerializer): class FarmHubCreateSerializer(serializers.ModelSerializer):
area_geojson = serializers.JSONField(write_only=True, required=False) area_geojson = serializers.JSONField(write_only=True, required=False)
farm_type_uuid = serializers.UUIDField(write_only=True) 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( product_uuids = serializers.ListField(
child=serializers.UUIDField(), child=serializers.UUIDField(),
write_only=True, write_only=True,
@@ -118,6 +124,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
"name", "name",
"is_active", "is_active",
"farm_type_uuid", "farm_type_uuid",
"subscription_plan_uuid",
"product_uuids", "product_uuids",
"sensors", "sensors",
"area_geojson", "area_geojson",
@@ -148,6 +155,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
def validate(self, attrs): def validate(self, attrs):
farm_type_uuid = attrs.get("farm_type_uuid") farm_type_uuid = attrs.get("farm_type_uuid")
subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty)
product_uuids = attrs.get("product_uuids") product_uuids = attrs.get("product_uuids")
if farm_type_uuid is None: 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}`."]} {"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["farm_type"] = farm_type
attrs["subscription_plan"] = subscription_plan
attrs["products"] = products attrs["products"] = products
return attrs return attrs
@@ -182,7 +204,9 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
sensors_data = validated_data.pop("sensors", []) sensors_data = validated_data.pop("sensors", [])
products = validated_data.pop("products", []) products = validated_data.pop("products", [])
validated_data["farm_type"] = validated_data.pop("farm_type") 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("farm_type_uuid", None)
validated_data.pop("subscription_plan_uuid", None)
validated_data.pop("product_uuids", None) validated_data.pop("product_uuids", None)
farm = super().create(validated_data) farm = super().create(validated_data)
@@ -197,13 +221,17 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
sensors_data = validated_data.pop("sensors", None) sensors_data = validated_data.pop("sensors", None)
products = validated_data.pop("products", None) products = validated_data.pop("products", None)
farm_type = validated_data.pop("farm_type", 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("farm_type_uuid", None)
validated_data.pop("subscription_plan_uuid", None)
validated_data.pop("product_uuids", None) validated_data.pop("product_uuids", None)
for attr, value in validated_data.items(): for attr, value in validated_data.items():
setattr(instance, attr, value) setattr(instance, attr, value)
if farm_type is not None: if farm_type is not None:
instance.farm_type = farm_type instance.farm_type = farm_type
if subscription_plan is not serializers.empty:
instance.subscription_plan = subscription_plan
instance.save() instance.save()
if products is not None: if products is not None:
+121 -1
View File
@@ -2,8 +2,11 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIRequestFactory, force_authenticate 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 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.seeds import seed_admin_farm
from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView from farm_hub.views import FarmListCreateView, FarmTypeListView, FarmTypeProductsView
from sensor_catalog.models import SensorCatalog from sensor_catalog.models import SensorCatalog
@@ -41,6 +44,7 @@ class FarmListCreateViewTests(TestCase):
) )
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, 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( self.weather_station, _ = SensorCatalog.objects.get_or_create(
name="Sensor 7 - Soil Moisture Sensor v1.2", name="Sensor 7 - Soil Moisture Sensor v1.2",
defaults={"supported_power_sources": ["solar", "direct_power"]}, defaults={"supported_power_sources": ["solar", "direct_power"]},
@@ -53,6 +57,7 @@ class FarmListCreateViewTests(TestCase):
{ {
"name": "farm-1", "name": "farm-1",
"farm_type_uuid": str(self.farm_type.uuid), "farm_type_uuid": str(self.farm_type.uuid),
"subscription_plan_uuid": str(self.plan.uuid),
"product_uuids": [str(self.wheat.uuid)], "product_uuids": [str(self.wheat.uuid)],
"sensors": [ "sensors": [
{ {
@@ -75,6 +80,7 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["code"], 201) self.assertEqual(response.data["code"], 201)
self.assertEqual(response.data["data"]["name"], "farm-1") 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.assertIn("zoning", response.data["data"])
self.assertIsNotNone(response.data["data"]["area_uuid"]) self.assertIsNotNone(response.data["data"]["area_uuid"])
self.assertEqual(len(response.data["data"]["sensors"]), 1) self.assertEqual(len(response.data["data"]["sensors"]), 1)
@@ -129,6 +135,23 @@ class FarmListCreateViewTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("sensor_catalog_uuid", response.data["sensors"][0]) 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( @override_settings(
USE_EXTERNAL_API_MOCK=True, USE_EXTERNAL_API_MOCK=True,
@@ -215,3 +238,100 @@ class FarmCatalogViewsTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm type not found.") 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"])
+6 -1
View File
@@ -24,6 +24,7 @@ class FarmHubBaseView(APIView):
try: try:
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related( return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
"farm_type", "farm_type",
"subscription_plan",
"current_crop_area", "current_crop_area",
).get( ).get(
farm_uuid=farm_uuid, farm_uuid=farm_uuid,
@@ -39,7 +40,11 @@ class FarmListCreateView(FarmHubBaseView):
responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))},
) )
def get(self, request): 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", "products",
"sensors", "sensors",
"sensors__sensor_catalog", "sensors__sensor_catalog",
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
+10
View File
@@ -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")
+33
View File
@@ -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"
+97
View File
@@ -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)
+8
View File
@@ -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"),
]
+84
View File
@@ -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)