UPDATE
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user