This commit is contained in:
2026-04-03 23:51:00 +03:30
parent e2728871ee
commit ecb42c6895
32 changed files with 2336 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccessControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "access_control"
+69
View File
@@ -0,0 +1,69 @@
GOLD_PLAN_CODE = "gold"
SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2"
DEFAULT_SUBSCRIPTION_PLANS = [
{
"code": "gold",
"name": "Gold",
"description": "Default premium subscription plan with full CropLogic access.",
"metadata": {"is_default": True},
},
]
DEFAULT_ACCESS_FEATURES = [
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section"},
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section"},
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page"},
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page"},
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page"},
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section"},
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page"},
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section"},
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page"},
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page"},
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section"},
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page"},
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page"},
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page"},
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page"},
]
DEFAULT_ACCESS_RULES = [
{
"code": "gold-full-access",
"name": "Gold Full Access",
"description": "Enables all core product features for gold subscribers.",
"effect": "allow",
"priority": 10,
"subscription_plans": ["gold"],
"features": [
"dashboards",
"data-section",
"water-data",
"soil-information",
"crop-zoning",
"simulator",
"plant-growth-simulator",
"recommendations",
"irrigation-recommendation",
"fertilization-recommendation",
"smart-assistant",
"farm-ai-assistant",
"pest-detection",
"greenhouse-dashboard",
],
},
{
"code": "sensor-7-page-access",
"name": "Sensor 7 Page Access",
"description": "Adds sensor page access when Sensor 7 is attached to the farm.",
"effect": "allow",
"priority": 20,
"features": ["sensor-page"],
"sensor_catalogs": [SENSOR_7_NAME],
"metadata": {"sensor_catalog_names": [SENSOR_7_NAME]},
},
]
+109
View File
@@ -0,0 +1,109 @@
# Generated by Django 5.2.12 on 2026-03-19 16:40
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0006_seed_expanded_product_catalog"),
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
]
operations = [
migrations.CreateModel(
name="AccessFeature",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=128, unique=True)),
("name", models.CharField(max_length=255)),
(
"feature_type",
models.CharField(
choices=[("page", "Page"), ("action", "Action"), ("widget", "Widget"), ("section", "Section")],
default="page",
max_length=32,
),
),
("description", models.TextField(blank=True, default="")),
("default_enabled", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "access_features", "ordering": ["feature_type", "code"]},
),
migrations.CreateModel(
name="SubscriptionPlan",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=64, unique=True)),
("name", models.CharField(db_index=True, max_length=255, unique=True)),
("description", models.TextField(blank=True, default="")),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "subscription_plans", "ordering": ["name"]},
),
migrations.CreateModel(
name="AccessRule",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=128, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)),
("priority", models.PositiveIntegerField(default=100)),
("is_active", models.BooleanField(default=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")),
("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")),
(
"sensor_catalogs",
models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog"),
),
(
"subscription_plans",
models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan"),
),
],
options={"db_table": "access_rules", "ordering": ["priority", "code"]},
),
migrations.CreateModel(
name="FarmAccessProfile",
fields=[
(
"farm",
models.OneToOneField(
db_column="farm_uuid",
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="access_profile",
serialize=False,
to="farm_hub.farmhub",
to_field="farm_uuid",
),
),
("cached_features", models.JSONField(blank=True, default=dict)),
("cached_groups", models.JSONField(blank=True, default=dict)),
("matched_rules", models.JSONField(blank=True, default=list)),
("metadata", models.JSONField(blank=True, default=dict)),
("last_resolved_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "farm_access_profiles"},
),
]
@@ -0,0 +1,12 @@
# Generated by Django 5.2.12 on 2026-03-19 16:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("access_control", "0001_initial"),
("farm_hub", "0006_seed_expanded_product_catalog"),
]
operations = []
@@ -0,0 +1,93 @@
# Generated by Django 5.2.12 on 2026-04-03
from django.db import migrations
def seed_default_access_rules(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
AccessRule = apps.get_model("access_control", "AccessRule")
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
SensorCatalog = apps.get_model("sensor_catalog", "SensorCatalog")
from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS
features_by_code = {}
for feature_data in DEFAULT_ACCESS_FEATURES:
feature, _created = AccessFeature.objects.update_or_create(
code=feature_data["code"],
defaults={
"name": feature_data["name"],
"feature_type": feature_data["feature_type"],
"description": feature_data.get("description", ""),
"metadata": feature_data.get("metadata", {}),
"default_enabled": feature_data.get("default_enabled", False),
},
)
features_by_code[feature.code] = feature
plans_by_code = {}
for plan_data in DEFAULT_SUBSCRIPTION_PLANS:
plan, _created = SubscriptionPlan.objects.update_or_create(
code=plan_data["code"],
defaults={
"name": plan_data["name"],
"description": plan_data.get("description", ""),
"metadata": plan_data.get("metadata", {}),
"is_active": True,
},
)
plans_by_code[plan.code] = plan
sensor_catalogs_by_name = {
sensor.name: sensor for sensor in SensorCatalog.objects.filter(name__in=_sensor_names(DEFAULT_ACCESS_RULES))
}
for rule_data in DEFAULT_ACCESS_RULES:
rule, _created = AccessRule.objects.update_or_create(
code=rule_data["code"],
defaults={
"name": rule_data["name"],
"description": rule_data.get("description", ""),
"effect": rule_data.get("effect", "allow"),
"priority": rule_data.get("priority", 100),
"metadata": rule_data.get("metadata", {}),
"is_active": True,
},
)
rule.features.set([features_by_code[code] for code in rule_data.get("features", []) if code in features_by_code])
rule.subscription_plans.set(
[plans_by_code[code] for code in rule_data.get("subscription_plans", []) if code in plans_by_code]
)
rule.sensor_catalogs.set(
[sensor_catalogs_by_name[name] for name in rule_data.get("sensor_catalogs", []) if name in sensor_catalogs_by_name]
)
def unseed_default_access_rules(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
AccessRule = apps.get_model("access_control", "AccessRule")
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS
AccessRule.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_RULES]).delete()
AccessFeature.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_FEATURES]).delete()
SubscriptionPlan.objects.filter(code__in=[item["code"] for item in DEFAULT_SUBSCRIPTION_PLANS]).delete()
def _sensor_names(rule_data_list):
names = []
for rule_data in rule_data_list:
names.extend(rule_data.get("sensor_catalogs", []))
return names
class Migration(migrations.Migration):
dependencies = [
("access_control", "0002_link_subscription_plan_to_farm"),
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
]
operations = [
migrations.RunPython(seed_default_access_rules, unseed_default_access_rules),
]
+1
View File
@@ -0,0 +1 @@
+108
View File
@@ -0,0 +1,108 @@
import uuid as uuid_lib
from django.db import models
class SubscriptionPlan(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=64, unique=True, db_index=True)
name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, default="")
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "subscription_plans"
ordering = ["name"]
def __str__(self):
return self.name
class AccessFeature(models.Model):
PAGE = "page"
ACTION = "action"
WIDGET = "widget"
SECTION = "section"
FEATURE_TYPES = [
(PAGE, "Page"),
(ACTION, "Action"),
(WIDGET, "Widget"),
(SECTION, "Section"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=128, unique=True, db_index=True)
name = models.CharField(max_length=255)
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
description = models.TextField(blank=True, default="")
default_enabled = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_features"
ordering = ["feature_type", "code"]
def __str__(self):
return self.code
class AccessRule(models.Model):
ALLOW = "allow"
DENY = "deny"
EFFECTS = [
(ALLOW, "Allow"),
(DENY, "Deny"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=128, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW)
priority = models.PositiveIntegerField(default=100)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict, blank=True)
features = models.ManyToManyField(AccessFeature, related_name="rules", blank=True)
subscription_plans = models.ManyToManyField(SubscriptionPlan, related_name="access_rules", blank=True)
farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True)
products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_rules"
ordering = ["priority", "code"]
def __str__(self):
return self.code
class FarmAccessProfile(models.Model):
farm = models.OneToOneField(
"farm_hub.FarmHub",
to_field="farm_uuid",
db_column="farm_uuid",
on_delete=models.CASCADE,
related_name="access_profile",
primary_key=True,
)
cached_features = models.JSONField(default=dict, blank=True)
cached_groups = models.JSONField(default=dict, blank=True)
matched_rules = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
last_resolved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_access_profiles"
def __str__(self):
return str(self.farm_id)
+60
View File
@@ -0,0 +1,60 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.permissions import BasePermission
from farm_hub.models import FarmHub
from .services import is_feature_enabled_for_farm
class FeatureAccessPermission(BasePermission):
message = "You do not have access to this API."
def has_permission(self, request, view):
feature_code = getattr(view, "required_feature_code", None)
if not feature_code:
return True
farm_uuid = self._extract_farm_uuid(request, view)
if not farm_uuid:
return True
farm = self._resolve_owned_farm(request, farm_uuid)
if farm is None:
return True
if is_feature_enabled_for_farm(farm, feature_code):
return True
self.message = f"Access to feature `{feature_code}` is denied."
return False
@staticmethod
def _extract_farm_uuid(request, view):
for key in ("farm_uuid", "farmUuid"):
farm_uuid = view.kwargs.get(key)
if farm_uuid:
return str(farm_uuid)
for key in ("farm_uuid", "farmUuid"):
farm_uuid = request.query_params.get(key)
if farm_uuid:
return farm_uuid
if isinstance(request.data, dict):
for key in ("farm_uuid", "farmUuid"):
farm_uuid = request.data.get(key)
if farm_uuid:
return farm_uuid
return None
@staticmethod
def _resolve_owned_farm(request, farm_uuid):
try:
return (
FarmHub.objects.select_related("access_profile")
.prefetch_related("products", "sensors")
.filter(farm_uuid=farm_uuid, owner=request.user)
.first()
)
except (ValueError, TypeError, DjangoValidationError):
return None
+34
View File
@@ -0,0 +1,34 @@
from rest_framework import serializers
from .models import FarmAccessProfile, SubscriptionPlan
class SubscriptionPlanSerializer(serializers.ModelSerializer):
class Meta:
model = SubscriptionPlan
fields = ["uuid", "code", "name", "description", "metadata", "is_active"]
class FarmAccessProfileSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
features = serializers.DictField()
groups = serializers.DictField()
matched_rules = serializers.ListField()
resolved_from_profile = serializers.BooleanField()
class FarmAccessProfileCacheSerializer(serializers.ModelSerializer):
class Meta:
model = FarmAccessProfile
fields = [
"farm",
"cached_features",
"cached_groups",
"matched_rules",
"metadata",
"last_resolved_at",
"created_at",
"updated_at",
]
+139
View File
@@ -0,0 +1,139 @@
from django.utils import timezone
from .models import AccessFeature, AccessRule, FarmAccessProfile
def _manager_id_set(manager):
return {obj.id for obj in manager.all()}
def rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
if not rule.is_active:
return False
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
if subscription_plan_ids:
if farm.subscription_plan_id is None or farm.subscription_plan_id not in subscription_plan_ids:
return False
farm_type_ids = _manager_id_set(rule.farm_types)
if farm_type_ids and farm.farm_type_id not in farm_type_ids:
return False
product_rule_ids = _manager_id_set(rule.products)
if product_rule_ids:
product_ids = product_ids if product_ids is not None else set(farm.products.values_list("id", flat=True))
if not product_ids or product_rule_ids.isdisjoint(product_ids):
return False
sensor_catalog_rule_ids = _manager_id_set(rule.sensor_catalogs)
if sensor_catalog_rule_ids:
sensor_catalog_ids = (
sensor_catalog_ids
if sensor_catalog_ids is not None
else set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True))
)
if not sensor_catalog_ids or sensor_catalog_rule_ids.isdisjoint(sensor_catalog_ids):
return False
sensor_catalog_rule_names = set(rule.metadata.get("sensor_catalog_names", [])) if isinstance(rule.metadata, dict) else set()
if sensor_catalog_rule_names:
farm_sensor_catalog_names = set(
farm.sensors.exclude(sensor_catalog__name__isnull=True).values_list("sensor_catalog__name", flat=True)
)
if not farm_sensor_catalog_names or sensor_catalog_rule_names.isdisjoint(farm_sensor_catalog_names):
return False
return True
def build_farm_access_profile(farm):
features = AccessFeature.objects.all().order_by("feature_type", "code")
resolved = {
feature.code: {
"enabled": feature.default_enabled,
"type": feature.feature_type,
"name": feature.name,
"description": feature.description,
"metadata": feature.metadata,
"source": "default",
}
for feature in features
}
product_ids = set(farm.products.values_list("id", flat=True))
sensor_catalog_ids = set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True))
rules = (
AccessRule.objects.filter(is_active=True, features__isnull=False)
.distinct()
.prefetch_related("features", "subscription_plans", "farm_types", "products", "sensor_catalogs")
.order_by("priority", "id")
)
matched_rules = []
for rule in rules:
if not rule_matches_farm(rule, farm, product_ids=product_ids, sensor_catalog_ids=sensor_catalog_ids):
continue
matched_rules.append(
{
"code": rule.code,
"name": rule.name,
"effect": rule.effect,
"priority": rule.priority,
}
)
is_enabled = rule.effect == AccessRule.ALLOW
for feature in rule.features.all():
resolved[feature.code] = {
"enabled": is_enabled,
"type": feature.feature_type,
"name": feature.name,
"description": feature.description,
"metadata": feature.metadata,
"source": rule.code,
}
grouped = {}
for code, payload in resolved.items():
grouped.setdefault(f"{payload['type']}s", {})[code] = payload
profile, _created = FarmAccessProfile.objects.update_or_create(
farm=farm,
defaults={
"cached_features": resolved,
"cached_groups": grouped,
"matched_rules": matched_rules,
"last_resolved_at": timezone.now(),
},
)
return {
"farm_uuid": str(farm.farm_uuid),
"subscription_plan": {
"uuid": str(farm.subscription_plan.uuid),
"code": farm.subscription_plan.code,
"name": farm.subscription_plan.name,
}
if farm.subscription_plan_id
else None,
"features": profile.cached_features,
"groups": profile.cached_groups,
"matched_rules": profile.matched_rules,
"resolved_from_profile": True,
}
def is_feature_enabled_for_farm(farm, feature_code):
profile = getattr(farm, "access_profile", None)
if profile and isinstance(profile.cached_features, dict):
feature_payload = profile.cached_features.get(feature_code)
if feature_payload is not None:
return bool(feature_payload.get("enabled"))
profile_data = build_farm_access_profile(farm)
feature_payload = profile_data["features"].get(feature_code)
if feature_payload is None:
return False
return bool(feature_payload.get("enabled"))
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import FarmAccessProfileView, SubscriptionPlanListView
urlpatterns = [
path("subscription-plans/", SubscriptionPlanListView.as_view(), name="subscription-plan-list"),
path("farms/<uuid:farm_uuid>/profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"),
]
+57
View File
@@ -0,0 +1,57 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .models import SubscriptionPlan
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
from .services import build_farm_access_profile
class AccessControlBaseView(APIView):
permission_classes = [IsAuthenticated]
def _get_farm(self, request, farm_uuid):
try:
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
"farm_type",
"subscription_plan",
).get(
farm_uuid=farm_uuid,
owner=request.user,
)
except FarmHub.DoesNotExist:
return None
class SubscriptionPlanListView(AccessControlBaseView):
@extend_schema(
tags=["Access Control"],
responses={200: code_response("SubscriptionPlanListResponse", data=SubscriptionPlanSerializer(many=True))},
)
def get(self, request):
plans = SubscriptionPlan.objects.filter(is_active=True).order_by("name")
data = SubscriptionPlanSerializer(plans, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class FarmAccessProfileView(AccessControlBaseView):
@extend_schema(
tags=["Access Control"],
responses={
200: code_response("FarmAccessProfileResponse", data=FarmAccessProfileSerializer()),
404: code_response("FarmAccessProfileNotFoundResponse"),
},
)
def get(self, request, farm_uuid):
farm = self._get_farm(request, farm_uuid)
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
data = build_farm_access_profile(farm)
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)