UPDATE
This commit is contained in:
@@ -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
|
||||
@@ -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 شده است.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccessControlConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "access_control"
|
||||
|
||||
@@ -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]},
|
||||
},
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
+121
-1
@@ -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"])
|
||||
|
||||
+6
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "notifications"
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user