Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ services:
CELERY_BROKER_PASSWORD: password
DJANGO_SETTINGS_MODULE: enterprise_access.settings.devstack

consume_enterprise_groups_lifecycle:
image: edxops/enterprise-access-dev
container_name: enterprise_access.consume_enterprise_groups_lifecycle
volumes:
- .:/edx/app/enterprise-access/
- ../src:/edx/src
command: >
bash -c '
make requirements &&
pip install edx-event-bus-kafka &&
pip install openedx-events>=10.4.0 &&
pip install "confluent-kafka[avro,schema-registry]" &&
while true; do python /edx/app/enterprise-access/manage.py consume_events -t enterprise-groups-lifecycle -g enterprise_access_dev; sleep 2; done
'
ports:
- "18273:18273"
depends_on:
- mysql80
- memcache
networks:
- devstack_default
stdin_open: true
tty: true
environment:
CELERY_ALWAYS_EAGER: 'false'
CELERY_BROKER_TRANSPORT: redis
CELERY_BROKER_HOSTNAME: edx.devstack.redis:6379
CELERY_BROKER_VHOST: 0
CELERY_BROKER_PASSWORD: password
EVENT_BUS_KAFKA_SCHEMA_REGISTRY_URL: 'http://edx.devstack.schema-registry:8081'
EVENT_BUS_KAFKA_BOOTSTRAP_SERVERS: 'edx.devstack.kafka:29092'
EVENT_BUS_PRODUCER: 'edx_event_bus_kafka.create_producer'
EVENT_BUS_CONSUMER: 'edx_event_bus_kafka.KafkaEventConsumer'
EVENT_BUS_TOPIC_PREFIX: 'dev'
DJANGO_SETTINGS_MODULE: enterprise_access.settings.devstack

networks:
devstack_default:
external: true
Expand Down
10 changes: 10 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,15 @@


class SubsidyAccessPolicyConfig(AppConfig):
"""
Initialization app for enterprise_access.apps.subsidy_access_policy.
Necessary so that django signals in this app are registered.
"""
default_auto_field = 'django.db.models.BigAutoField'
name = 'enterprise_access.apps.subsidy_access_policy'

def ready(self):
super().ready()

# pylint: disable=unused-import, import-outside-toplevel
import enterprise_access.apps.subsidy_access_policy.signals
11 changes: 11 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,17 @@ class Meta:
help_text='The uuid that uniquely identifies the associated group.',
)

@classmethod
def cascade_delete_for_group_uuid(cls, group_uuid):
"""
Delete all associations for a remote EnterpriseGroup.

Called by the domain signal `handle_enterprise_group_deleted`.
"""
return cls.objects.filter(
enterprise_group_uuid=group_uuid
).delete()


class ForcedPolicyRedemption(TimeStampedModel):
"""
Expand Down
29 changes: 29 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
Signal handlers for subsidy_access_policy app.
"""
import logging

from django.dispatch import receiver
from openedx_events.enterprise.data import EnterpriseGroup
from openedx_events.enterprise.signals import ENTERPRISE_GROUP_DELETED

from enterprise_access.apps.subsidy_access_policy.models import PolicyGroupAssociation

logger = logging.getLogger(__name__)


@receiver(ENTERPRISE_GROUP_DELETED)
def handle_enterprise_group_deleted(**kwargs):
"""
OEP-49 event handler to update assignment status for reversed transaction.
"""
logger.info('Received ENTERPRISE_GROUP_DELETED signal with data: %s', kwargs)
group = kwargs.get('enterprise_group')
if not group or not isinstance(group, EnterpriseGroup):
logger.error('ENTERPRISE_GROUP_DELETED signal missing or invalid enterprise_group: %s', kwargs)
raise ValueError('Missing or invalid enterprise_group in signal')

group_uuid = group.uuid

deletions = PolicyGroupAssociation.cascade_delete_for_group_uuid(group_uuid)
logger.info('PolicyGroupAssociation records deleted: %s', deletions)
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class Meta:
model = PolicyGroupAssociation

enterprise_group_uuid = factory.LazyFunction(uuid4)
subsidy_access_policy = factory.SubFactory(SubsidyAccessPolicyFactory)
subsidy_access_policy = factory.SubFactory(PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory)


class ForcedPolicyRedemptionFactory(factory.django.DjangoModelFactory):
Expand Down
25 changes: 25 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
REQUEST_CACHE_NAMESPACE,
PerLearnerEnrollmentCreditAccessPolicy,
PerLearnerSpendCreditAccessPolicy,
PolicyGroupAssociation,
SubsidyAccessPolicy,
SubsidyAccessPolicyLockAttemptFailed
)
Expand Down Expand Up @@ -1902,3 +1903,27 @@ def test_save(self):

self.assertEqual(policy.enterprise_group_uuid, self.group_uuid)
self.assertIsNotNone(policy.subsidy_access_policy)

def test_cascade_delete_for_group_uuid_should_delete_correct_associations(self):
"""
Test that deleting a group uuid will delete all associations
with that group uuid, and no others.
"""
policy1 = PolicyGroupAssociationFactory(
enterprise_group_uuid=self.group_uuid,
subsidy_access_policy=self.access_policy,
)
policy2 = PolicyGroupAssociationFactory(
enterprise_group_uuid=uuid4(),
subsidy_access_policy=self.access_policy,
)

# Ensure both policies are created
self.assertEqual(PolicyGroupAssociation.objects.count(), 2)

# Delete the first policy group association
policy1.delete()

# Ensure only the second policy remains
self.assertEqual(PolicyGroupAssociation.objects.count(), 1)
self.assertEqual(PolicyGroupAssociation.objects.first(), policy2)
132 changes: 132 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""
Tests for subsidy_access_policy signals and handlers.
"""
import uuid

from django.test import TestCase
from openedx_events.enterprise.data import EnterpriseGroup
from openedx_events.enterprise.signals import ENTERPRISE_GROUP_DELETED

from enterprise_access.apps.subsidy_access_policy.models import PolicyGroupAssociation
from enterprise_access.apps.subsidy_access_policy.signals import handle_enterprise_group_deleted
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory,
PolicyGroupAssociationFactory
)


class TestEnterpriseGroupDeletedSignal(TestCase):
"""
Tests for the ENTERPRISE_GROUP_DELETED signal handler.
"""

def setUp(self):
"""
Set up test data for the test cases.
"""
super().setUp()
self.group_uuid_1 = uuid.uuid4()
self.group_uuid_2 = uuid.uuid4()
self.group_uuid_3 = uuid.uuid4()

# Create policies to associate with groups
self.policy_1 = PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory()
self.policy_2 = PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory()
self.policy_3 = PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory()

# Create policy-group associations
self.association_1 = PolicyGroupAssociationFactory.create(
subsidy_access_policy=self.policy_1,
enterprise_group_uuid=self.group_uuid_1
)
self.association_2 = PolicyGroupAssociationFactory.create(
subsidy_access_policy=self.policy_2,
enterprise_group_uuid=self.group_uuid_2
)
# Different group that shouldn't be affected
self.association_3 = PolicyGroupAssociationFactory.create(
subsidy_access_policy=self.policy_3,
enterprise_group_uuid=self.group_uuid_3
)

def test_handle_enterprise_group_deleted_direct_call(self):
"""
Test that the signal handler correctly deletes associations when called directly.
"""
# Set up a mock enterprise group
mock_enterprise_group = EnterpriseGroup(uuid=self.group_uuid_1)

# Verify that associations for group_uuid_1 exist before deletion
self.assertTrue(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_1).exists(),
"Associations for group_uuid_1 should exist before deletion"
)

# Call the signal handler directly
handle_enterprise_group_deleted(enterprise_group=mock_enterprise_group)

# Verify that associations for group_uuid_1 are deleted
self.assertFalse(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_1).exists(),
"Associations for deleted group should be removed"
)

# Verify that associations for other groups are not affected
self.assertTrue(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_2).exists(),
"Associations for unrelated groups should not be affected"
)

def test_handle_enterprise_group_deleted_via_signal(self):
"""
Test that the signal handler correctly responds to the ENTERPRISE_GROUP_DELETED signal.
"""
# Set up a mock enterprise group
mock_enterprise_group = EnterpriseGroup(uuid=self.group_uuid_2)

# Verify that associations for group_uuid_1 exist before deletion
self.assertTrue(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_2).exists(),
"Associations for group_uuid_1 should exist before deletion"
)

# Send the signal
ENTERPRISE_GROUP_DELETED.send_event(enterprise_group=mock_enterprise_group)

# Verify that associations for group_uuid_1 are deleted
self.assertFalse(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_2).exists(),
"Associations for deleted group should be removed when signal is sent"
)

# Verify that associations for other groups are not affected
self.assertTrue(
PolicyGroupAssociation.objects.filter(enterprise_group_uuid=self.group_uuid_3).exists(),
"Associations for unrelated groups should not be affected when signal is sent"
)

def test_handle_enterprise_group_deleted_wrong_kwargs(self):
"""
Test that the signal handler gracefully handles missing UUID.
"""
# Initial count of associations
initial_count = PolicyGroupAssociation.objects.count()

# Call the signal handler with missing kwargs
with self.assertRaises(ValueError) as e:
handle_enterprise_group_deleted()
# Assert ValueError is raised for missing enterprise_group:
self.assertEqual(str(e.exception), 'Missing or invalid enterprise_group in signal')

# Call the signal handler with an invalid enterprise_group
with self.assertRaises(ValueError) as e:
handle_enterprise_group_deleted(enterprise_group="invalid_group")
# Assert ValueError is raised for invalid enterprise_group:
self.assertEqual(str(e.exception), 'Missing or invalid enterprise_group in signal')

# Verify no associations were deleted
self.assertEqual(
PolicyGroupAssociation.objects.count(),
initial_count,
"No associations should be deleted when signal is called with wrong kwargs"
)
2 changes: 2 additions & 0 deletions enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,11 @@ def root(*path_fragments):
LICENSE_REQUEST_TOPIC_NAME = "license-request"
ACCESS_POLICY_TOPIC_NAME = "access-policy"
SUBSIDY_REDEMPTION_TOPIC_NAME = "subsidy-redemption"
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME = "enterprise-groups-lifecycle"
KAFKA_TOPICS = [
COUPON_CODE_REQUEST_TOPIC_NAME,
LICENSE_REQUEST_TOPIC_NAME,
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME,

# Access policy events
ACCESS_POLICY_TOPIC_NAME,
Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/settings/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@
LICENSE_REQUEST_TOPIC_NAME = "license-request-dev"
ACCESS_POLICY_TOPIC_NAME = "access-policy-dev"
SUBSIDY_REDEMPTION_TOPIC_NAME = "subsidy-redemption-dev"
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME = "enterprise-groups-lifecycle-dev"
KAFKA_TOPICS = [
COUPON_CODE_REQUEST_TOPIC_NAME,
LICENSE_REQUEST_TOPIC_NAME,
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME,

# Access policy events
ACCESS_POLICY_TOPIC_NAME,
Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@
LICENSE_REQUEST_TOPIC_NAME = "license-request-test"
ACCESS_POLICY_TOPIC_NAME = "access-policy-test"
SUBSIDY_REDEMPTION_TOPIC_NAME = "subsidy-redemption-test"
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME = "enterprise-groups-lifecycle-test"
KAFKA_TOPICS = [
COUPON_CODE_REQUEST_TOPIC_NAME,
LICENSE_REQUEST_TOPIC_NAME,
ENTERPRISE_GROUPS_LIFECYCLE_TOPIC_NAME,

# Access policy events
ACCESS_POLICY_TOPIC_NAME,
Expand Down