Skip to content

Commit

Permalink
Merge branch 'aci.main' into hantkovskyi/aci-854/penalty-data-rules-a…
Browse files Browse the repository at this point in the history
…pplication
  • Loading branch information
andrii-hantkovskyi authored Apr 10, 2024
2 parents 4191a35 + 75612c7 commit 77d0cd1
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 68 deletions.
25 changes: 16 additions & 9 deletions credentials/apps/badges/admin_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
from .models import BadgePenalty, BadgeRequirement, CredlyOrganization, DataRule, PenaltyDataRule


class BadgeTemplteValidationMixin:
def clean(self):
cleaned_data = super().clean()
if self.instance.is_active:
raise forms.ValidationError("Configuration updates are blocked on active badge templates")
return cleaned_data


class CredlyOrganizationAdminForm(forms.ModelForm):
"""
Additional actions for Credly Organization items.
Expand Down Expand Up @@ -60,7 +68,7 @@ def _ensure_organization_exists(self, api_client):
raise forms.ValidationError(message=str(err))


class BadgePenaltyForm(forms.ModelForm):
class BadgePenaltyForm(BadgeTemplteValidationMixin, forms.ModelForm):
class Meta:
model = BadgePenalty
fields = "__all__"
Expand All @@ -74,7 +82,7 @@ def __init__(self, *args, **kwargs):
for field_name in self.fields:
if field_name in ("template", "requirements", "description"):
self.fields[field_name].disabled = True

def clean(self):
cleaned_data = super().clean()
requirements = cleaned_data.get("requirements")
Expand All @@ -84,41 +92,40 @@ def clean(self):
return cleaned_data



class PenaltyDataRuleForm(forms.ModelForm):
class PenaltyDataRuleForm(BadgeTemplteValidationMixin, forms.ModelForm):
class Meta:
model = PenaltyDataRule
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and hasattr(self.instance, "penalty") and self.instance.penalty.template.is_active:
if self.instance and hasattr(self.instance, "penalty") and self.instance.is_active:
for field_name in self.fields:
if field_name in ("data_path", "operator", "value"):
self.fields[field_name].disabled = True


class BadgeRequirementForm(forms.ModelForm):
class BadgeRequirementForm(BadgeTemplteValidationMixin, forms.ModelForm):
class Meta:
model = BadgeRequirement
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and hasattr(self.instance, "template") and self.instance.template.is_active:
if self.instance and hasattr(self.instance, "template") and self.instance.is_active:
for field_name in self.fields:
if field_name in ("template", "event_type", "description", "group"):
self.fields[field_name].disabled = True


class DataRuleForm(forms.ModelForm):
class DataRuleForm(BadgeTemplteValidationMixin, forms.ModelForm):
class Meta:
model = DataRule
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and hasattr(self.instance, "requirement") and self.instance.requirement.template.is_active:
if self.instance and hasattr(self.instance, "requirement") and self.instance.is_active:
for field_name in self.fields:
if field_name in ("data_path", "operator", "value"):
self.fields[field_name].disabled = True
72 changes: 43 additions & 29 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import uuid

from django.conf import settings
from django.db import models
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from model_utils import Choices
Expand Down Expand Up @@ -185,14 +185,10 @@ def __str__(self):
return f"BadgeRequirement:{self.id}:{self.template.uuid}"

def save(self, *args, **kwargs):
# Check if the related BadgeTemplate is active
if not self.id:
super().save(*args, **kwargs)
return
if not self.template.is_active:
super().save(*args, **kwargs)
else:
raise ValidationError("Cannot update BadgeRequirement for active BadgeTemplate")
if self.is_active:
raise ValidationError("Configuration updates are blocked on active badge templates")

super().save(*args, **kwargs)

def reset(self, username: str):
Fulfillment.objects.filter(
Expand All @@ -208,8 +204,19 @@ def fulfill(self, username: str):
progress, _ = BadgeProgress.objects.get_or_create(template=self.template, username=username)
return Fulfillment.objects.create(progress=progress, requirement=self)

def apply_rules(self, **kwargs):
pass
def apply_rules(self, data: dict) -> bool:
for rule in self.datarule_set.all():
comparison_func = getattr(operator, rule.operator, None)
if comparison_func:
data_value = str(keypath(data, rule.data_path))
result = comparison_func(data_value, rule.value)
if not result:
return False
return True

@property
def is_active(self):
return self.template.is_active


class DataRule(AbstractDataRule):
Expand All @@ -234,11 +241,14 @@ def save(self, *args, **kwargs):
if not is_datapath_valid(self.data_path, self.requirement.event_type):
raise ValidationError("Invalid data path for event type")

# Check if the related BadgeTemplate is active
if not self.requirement.template.is_active:
super().save(*args, **kwargs)
else:
raise ValidationError("Cannot update DataRule for active BadgeTemplate")
if self.is_active:
raise ValidationError("Configuration updates are blocked on active badge templates")

super().save(*args, **kwargs)

@property
def is_active(self):
return self.requirement.template.is_active


class BadgePenalty(models.Model):
Expand Down Expand Up @@ -270,14 +280,10 @@ class Meta:
verbose_name_plural = "Badge penalties"

def save(self, *args, **kwargs):
if not self.id:
super().save(*args, **kwargs)
return
# Check if the related BadgeTemplate is active
if not self.template.is_active:
super().save(*args, **kwargs)
else:
raise ValidationError("Cannot update BadgePenalty for active BadgeTemplate")
if self.is_active:
raise ValidationError("Configuration updates are blocked on active badge templates")

super().save(*args, **kwargs)

def __str__(self):
return f"BadgePenalty:{self.id}:{self.template.uuid}"
Expand All @@ -292,6 +298,10 @@ def apply_rules(self, data: dict) -> bool:
def is_active(self):
return self.template.is_active

@property
def is_active(self):
return self.template.is_active


class PenaltyDataRule(AbstractDataRule):
"""
Expand All @@ -314,11 +324,11 @@ def save(self, *args, **kwargs):
raise ValidationError("Invalid data path for event type")

# Check if the related BadgeTemplate is active
if not self.penalty.template.is_active:
super().save(*args, **kwargs)
else:
raise ValidationError("Cannot update PenaltyDataRule for active BadgeTemplate")
if self.is_active:
raise ValidationError("Configuration updates are blocked on active badge templates")

super().save(*args, **kwargs)

def __str__(self):
return f"{self.penalty.template.uuid}:{self.data_path}:{self.operator}:{self.value}"

Expand All @@ -331,6 +341,10 @@ def apply(self, data: dict) -> bool:

class Meta:
unique_together = ("penalty", "data_path", "operator", "value")

@property
def is_active(self):
return self.requirement.template.is_active


class BadgeProgress(models.Model):
Expand Down
14 changes: 8 additions & 6 deletions credentials/apps/badges/services/awarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
)
from openedx_events.learning.signals import BADGE_AWARDED

from ..models import BadgeRequirement, CredlyBadgeTemplate
from ..signals import BADGE_PROGRESS_COMPLETE
from ..utils import keypath
from credentials.apps.badges.models import BadgeRequirement, CredlyBadgeTemplate
from credentials.apps.badges.signals import BADGE_PROGRESS_COMPLETE
from credentials.apps.badges.utils import keypath


def discover_requirements(event_type: str) -> List[BadgeRequirement]:
Expand Down Expand Up @@ -53,9 +53,11 @@ def process_requirements(event_type, username, payload_dict):

# actual processing goes here:

# for requirement in requirements:
# requirement.apply_rules(**kwargs)
# requirement.fulfill(username)
for requirement in requirements:
if not requirement.is_active:
continue
if requirement.apply_rules(payload_dict):
requirement.fulfill(username)


def notify_badge_awarded(user_credential): # pylint: disable=unused-argument
Expand Down
25 changes: 19 additions & 6 deletions credentials/apps/badges/tests/test_issuers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,28 @@ class CredlyBadgeTemplateIssuer(TestCase):

def setUp(self):
# Create a test badge template
fake = faker.Faker()
self.fake = faker.Faker()
credly_organization = CredlyOrganization.objects.create(
uuid=fake.uuid4(), api_key=fake.uuid4(), name=fake.word()
uuid=self.fake.uuid4(), api_key=self.fake.uuid4(), name=self.fake.word()
)
self.badge_template = self.issued_credential_type.objects.create(
origin=self.issued_credential_type.ORIGIN,
site_id=1,
uuid=fake.uuid4(),
name=fake.word(),
uuid=self.fake.uuid4(),
name=self.fake.word(),
state="active",
organization=credly_organization,
)

def test_create_user_credential_with_status_awared(self):
# Call create_user_credential with valid arguments
with mock.patch("credentials.apps.badges.services.issuers.notify_badge_awarded") as mock_notify_badge_awarded:
self.issuer().award(self.badge_template.id, "test_user")

with mock.patch.object(self.issuer, 'issue_credly_badge') as mock_issue_credly_badge:
self.issuer().award(self.badge_template.id, "test_user")

mock_notify_badge_awarded.assert_called_once()
mock_issue_credly_badge.assert_called_once()

# Check if user credential is created
self.assertTrue(
Expand All @@ -48,9 +51,19 @@ def test_create_user_credential_with_status_awared(self):

def test_create_user_credential_with_status_revoked(self):
# Call create_user_credential with valid arguments
self.issued_user_credential_type.objects.create(
username="test_user",
credential_content_type=ContentType.objects.get_for_model(self.badge_template),
credential_id=self.badge_template.id,
state=CredlyBadge.STATES.pending,
uuid=self.fake.uuid4(),
)

with mock.patch("credentials.apps.badges.services.issuers.notify_badge_revoked") as mock_notify_badge_revoked:
self.issuer().revoke(self.badge_template.id, "test_user")
with mock.patch.object(self.issuer, 'revoke_credly_badge') as mock_revoke_credly_badge:
self.issuer().revoke(self.badge_template.id, "test_user")

mock_revoke_credly_badge.assert_called_once()
mock_notify_badge_revoked.assert_called_once()

# Check if user credential is created
Expand Down
53 changes: 53 additions & 0 deletions credentials/apps/badges/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,59 @@ def test_multiple_data_rules_for_requirement(self):
self.assertIn(data_rule2, data_rules)


class RequirementApplyRulesCheckTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(domain="test_domain", name="test_name")
self.badge_template1 = BadgeTemplate.objects.create(
uuid=uuid.uuid4(), name="test_template1", state="draft", site=self.site
)
self.badge_template2 = BadgeTemplate.objects.create(
uuid=uuid.uuid4(), name="test_template2", state="draft", site=self.site
)
self.badge_requirement = BadgeRequirement.objects.create(
template=self.badge_template1, event_type="org.openedx.learning.course.passing.status.updated.v1"
)
self.data_rule1 = DataRule.objects.create(
requirement=self.badge_requirement,
data_path="course_passing_status.user.pii.username",
operator="eq",
value="test-username",
)
self.data_rule2 = DataRule.objects.create(
requirement=self.badge_requirement,
data_path="course_passing_status.user.pii.email",
operator="eq",
value="test@example.com",
)
self.data_rule = DataRule.objects.create

def test_apply_rules_check_success(self):
data = {
'course_passing_status': {
'user': {
'pii': {
'username': 'test-username',
'email': 'test@example.com'
}
}
}
}
self.assertTrue(self.badge_requirement.apply_rules(data))

def test_apply_rules_check_failed(self):
data = {
'course_passing_status': {
'user': {
'pii': {
'username': 'test-username2',
'email': 'test@example.com'
}
}
}
}
self.assertFalse(self.badge_requirement.apply_rules(data))


class BadgeRequirementTestCase(TestCase):
def setUp(self):
self.organization = CredlyOrganization.objects.create(
Expand Down
2 changes: 1 addition & 1 deletion credentials/apps/badges/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def test_discovery_eventtype_related_penalties(self):
event_type=self.CCX_COURSE_PASSING_EVENT,
description="Test ccx course passing revoke description",
)
)
])
course_passing_penalties = discover_penalties(event_type=self.COURSE_PASSING_EVENT)
ccx_course_passing_penalties = discover_penalties(event_type=self.CCX_COURSE_PASSING_EVENT)
self.assertEqual(course_passing_penalties.count(), 1)
Expand Down
Loading

0 comments on commit 77d0cd1

Please sign in to comment.