[BUILD-413] Create rules predicates to apply to plans (#2153)

[BUILD-413] Create rules predicates to apply to plans (#2153)

create rules predicates to apply to plans

diff --git a/ankihub/memberships/models.py b/ankihub/memberships/models.py
index 4e573ed..ac704eb 100644
--- a/ankihub/memberships/models.py
+++ b/ankihub/memberships/models.py
@@ -527,3 +527,6 @@ class Plan(LifecycleModelMixin, TimeStampedModel):
 
     def __str__(self):
         return self.name
+
+    def has_feature(self, feature):
+        return feature in self.features
diff --git a/ankihub/memberships/rules.py b/ankihub/memberships/rules.py
new file mode 100644
index 0000000..317649c
--- /dev/null
+++ b/ankihub/memberships/rules.py
@@ -0,0 +1,46 @@
+import rules
+from django.conf import settings
+
+from ankihub.memberships.models import Membership, Plan
+
+
+@rules.predicate
+def has_access(user, feature):
+    if feature in settings.TRIALING_NOT_AVAILABLE_FEATURES:
+        is_trialing = False
+    else:
+        is_trialing = user.is_trialing
+    has_feature = user.has_perm("memberships.has_feature", feature)
+    has_org_feature = user.has_perm("memberships.org_has_feature", feature)
+    return is_trialing or has_feature or has_org_feature
+
+
+@rules.predicate
+def has_feature(user, feature):
+    if user.has_active_membership:
+        try:
+            plan = user.memberships.get_ankihub_membership().plan
+            return plan.has_feature(feature=feature)
+        except (Membership.DoesNotExist, Plan.DoesNotExist):
+            return False
+    return False
+
+
+@rules.predicate
+def org_has_feature(user, feature):
+    organization = user.active_organization
+    if organization:
+        plan = organization.membership.plan
+        if plan:
+            return plan.has_feature(feature=feature)
+    return False
+
+
+rules.add_rule("has_feature", has_feature)
+rules.add_perm("memberships.has_feature", has_feature)
+
+rules.add_rule("org_has_feature", org_has_feature)
+rules.add_perm("memberships.org_has_feature", org_has_feature)
+
+rules.add_rule("has_access", has_access)
+rules.add_perm("memberships.has_access", has_access)
diff --git a/ankihub/memberships/tests/factories.py b/ankihub/memberships/tests/factories.py
index f9d2987..04139d4 100644
--- a/ankihub/memberships/tests/factories.py
+++ b/ankihub/memberships/tests/factories.py
@@ -9,6 +9,7 @@ from ankihub.memberships.models import (
     MembershipStatusChoices,
     Organization,
     Partner,
+    Plan,
 )
 from ankihub.users.tests.factories import UserFactory
 
@@ -48,3 +49,13 @@ class OrganizationFactory(factory.django.DjangoModelFactory):
         ]
     )
     owner = factory.SubFactory(UserFactory)
+
+
+class PlanFactory(factory.django.DjangoModelFactory):
+    class Meta:
+        model = Plan
+        django_get_or_create = ("slug",)
+
+    slug = factory.Sequence(
+        lambda n: f"#{n} {fake.slug()}"[: Plan._meta.get_field("slug").max_length]
+    )
diff --git a/ankihub/memberships/tests/test_models.py b/ankihub/memberships/tests/test_models.py
index 3f7a729..1c97d08 100644
--- a/ankihub/memberships/tests/test_models.py
+++ b/ankihub/memberships/tests/test_models.py
@@ -15,6 +15,7 @@ from ankihub.memberships.tests.factories import (
     MembershipFactory,
     OrganizationFactory,
     PartnerFactory,
+    PlanFactory,
 )
 from ankihub.users.models import OnboardingStep, OnboardingTask, User
 from ankihub.users.tests.factories import (
@@ -430,3 +431,18 @@ class TestOrganization:
 
         mock_sleep.assert_called_once_with(30)
         assert not organization.has_pending_members
+
+
+@pytest.mark.django_db
+class TestPlan:
+    @pytest.mark.parametrize(
+        "feature,plan_feature_available,expected",
+        [
+            ("test_feature", ["test_feature"], True),
+            ("test_feature", [], False),
+            ("test_feature", ["other_feature"], False),
+        ],
+    )
+    def test_has_feature(self, feature, plan_feature_available, expected):
+        plan = PlanFactory(features=plan_feature_available)
+        assert plan.has_feature(feature) == expected
diff --git a/ankihub/memberships/tests/test_rules.py b/ankihub/memberships/tests/test_rules.py
new file mode 100644
index 0000000..aaf8ef7
--- /dev/null
+++ b/ankihub/memberships/tests/test_rules.py
@@ -0,0 +1,84 @@
+from unittest.mock import Mock
+
+import pytest
+from django.test import override_settings
+
+from ankihub.memberships.tests.factories import (
+    MembershipFactory,
+    OrganizationFactory,
+    PlanFactory,
+)
+
+
+@pytest.mark.django_db
+class TestRules:
+    @override_settings(TRIALING_NOT_AVAILABLE_FEATURES=["test_feature"])
+    def test_has_access_trialing_not_available(self, user, monkeypatch):
+        monkeypatch.setattr(
+            "ankihub.users.models.User.is_trialing", Mock(return_value=True)
+        )
+        assert not user.has_perm("memberships.has_access", "test_feature")
+
+    @override_settings(TRIALING_NOT_AVAILABLE_FEATURES=[])
+    def test_has_access_trialing_available(self, user):
+        assert user.has_perm("memberships.has_access", "test_feature")
+
+    def test_has_access_direct_permission(self, user, monkeypatch):
+        monkeypatch.setattr(
+            "ankihub.users.models.User.is_trialing", Mock(return_value=False)
+        )
+        plan = PlanFactory(features=["test_feature"])
+        MembershipFactory(user=user, plan=plan)
+        assert user.has_perm("memberships.has_access", "test_feature")
+
+    def test_has_access_org_permission(self, user, monkeypatch):
+        monkeypatch.setattr(
+            "ankihub.users.models.User.is_trialing", Mock(return_value=False)
+        )
+        plan = PlanFactory(features=["test_feature"])
+        membership = MembershipFactory(user=user, plan=plan)
+        organization = OrganizationFactory(membership=membership)
+        organization.members.add(user)
+        assert user.has_perm("memberships.has_access", "test_feature")
+
+    def test_has_feature_active_membership(self, user, monkeypatch):
+        monkeypatch.setattr(
+            "ankihub.users.models.User.has_active_membership", Mock(return_value=True)
+        )
+        plan = PlanFactory(features=["test_feature"])
+        MembershipFactory(user=user, plan=plan)
+        assert user.has_perm("memberships.has_feature", "test_feature")
+
+    def test_has_feature_no_active_membership(self, user, monkeypatch):
+        monkeypatch.setattr(
+            "ankihub.users.models.User.has_active_membership", Mock(return_value=False)
+        )
+        assert not user.has_perm("memberships.has_feature", "test_feature")
+
+    def test_org_has_feature_with_org(self, user):
+        plan = PlanFactory(features=["test_feature"])
+        membership = MembershipFactory(user=user, plan=plan)
+        organization = OrganizationFactory(membership=membership)
+        organization.members.add(user)
+        assert user.has_perm("memberships.org_has_feature", "test_feature")
+
+    def test_org_has_feature_no_feature(self, user):
+        plan = PlanFactory(features=[])
+        membership = MembershipFactory(user=user, plan=plan)
+        organization = OrganizationFactory(membership=membership)
+        organization.members.add(user)
+        assert not user.has_perm("memberships.org_has_feature", "test_feature")
+
+    def test_org_has_feature_no_org(self, user):
+        assert not user.has_perm("memberships.org_has_feature", "test_feature")
+
+    def test_org_has_feature_org_no_membership(self, user):
+        organization = OrganizationFactory()
+        organization.members.add(user)
+        assert not user.has_perm("memberships.org_has_feature", "test_feature")
+
+    def test_org_has_feature_org_membership_no_plan(self, user):
+        membership = MembershipFactory(user=user)
+        organization = OrganizationFactory(membership=membership)
+        organization.members.add(user)
+        assert not user.has_perm("memberships.org_has_feature", "test_feature")
diff --git a/ankihub/users/models.py b/ankihub/users/models.py
index 718c74e..59b5f97 100644
--- a/ankihub/users/models.py
+++ b/ankihub/users/models.py

[... diff too long, it was truncated ...]

GitHub
sha: dcf11a262c8ca3f89c7efc0adfd8b244aabd92ca