Skip to content

Commit

Permalink
chore: [ACI-962, ACI-963] merge 'aci.main' into current branch
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrii committed May 7, 2024
2 parents 7071b1f + 722a107 commit 2d98698
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 17 deletions.
19 changes: 19 additions & 0 deletions credentials/apps/badges/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@


class BadgeRequirementInline(admin.TabularInline):
"""
Badge template requirement inline setup.
"""

model = BadgeRequirement
show_change_link = True
extra = 0
Expand Down Expand Up @@ -64,12 +68,19 @@ def rules(self, obj):


class BadgePenaltyInline(admin.TabularInline):
"""
Badge template penalty inline setup.
"""

model = BadgePenalty
show_change_link = True
extra = 0
form = BadgePenaltyForm

def formfield_for_manytomany(self, db_field, request, **kwargs):
"""
Filter requirements by parent badge template.
"""
if db_field.name == "requirements":
template_id = request.resolver_match.kwargs.get("object_id")
if template_id:
Expand All @@ -78,6 +89,10 @@ def formfield_for_manytomany(self, db_field, request, **kwargs):


class FulfillmentInline(admin.TabularInline):
"""
Badge template fulfillment inline setup.
"""

model = Fulfillment
extra = 0
readonly_fields = [
Expand All @@ -86,6 +101,10 @@ class FulfillmentInline(admin.TabularInline):


class DataRuleInline(admin.TabularInline):
"""
Data rule inline setup.
"""

model = DataRule
extra = 0
form = DataRuleForm
Expand Down
19 changes: 19 additions & 0 deletions credentials/apps/badges/admin_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,17 @@ def _ensure_organization_exists(self, api_client):


class BadgePenaltyForm(forms.ModelForm):
"""
Form for BadgePenalty model.
"""
class Meta:
model = BadgePenalty
fields = "__all__"

def clean(self):
"""
Ensure that all penalties belong to the same template.
"""
cleaned_data = super().clean()
requirements = cleaned_data.get("requirements")

Expand All @@ -79,20 +85,33 @@ def clean(self):


class DataRuleFormSet(forms.BaseInlineFormSet):
"""
Formset for DataRule model.
"""
def get_form_kwargs(self, index):
"""
Pass parent instance to the form.
"""

kwargs = super().get_form_kwargs(index)
kwargs["parent_instance"] = self.instance
return kwargs


class DataRuleForm(forms.ModelForm):
"""
Form for DataRule model.
"""
class Meta:
model = DataRule
fields = "__all__"

data_path = forms.ChoiceField()

def __init__(self, *args, parent_instance=None, **kwargs):
"""
Load data paths based on the parent instance event type.
"""
self.parent_instance = parent_instance
super().__init__(*args, **kwargs)

Expand Down
18 changes: 16 additions & 2 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ def reset(self, username: str):
return bool(deleted)

def is_fulfilled(self, username: str) -> bool:
"""
Checks if the requirement is fulfilled for the user.
"""

return self.fulfillments.filter(progress__username=username, progress__template=self.template).exists()

def apply_rules(self, data: dict) -> bool:
Expand Down Expand Up @@ -291,6 +295,7 @@ def apply(self, data: dict) -> bool:
and `self.value` is "30", then calling `apply({"user": {"age": 30}})` will return True
because the age matches the specified value.
"""

comparison_func = getattr(operator, self.operator, None)
if comparison_func:
data_value = str(keypath(data, self.data_path))
Expand Down Expand Up @@ -357,9 +362,14 @@ def apply_rules(self, data: dict) -> bool:
"""
Evaluates payload rules.
"""

return all(rule.apply(data) for rule in self.rules.all())

def reset_requirements(self, username: str):
"""
Resets all related requirements for the user.
"""

for requirement in self.requirements.all():
requirement.reset(username)

Expand Down Expand Up @@ -419,6 +429,7 @@ def for_user(cls, *, username, template_id):
"""
Service shortcut.
"""

progress, __ = cls.objects.get_or_create(username=username, template_id=template_id)
return progress

Expand All @@ -429,11 +440,9 @@ def ratio(self) -> float:
"""

requirements = BadgeRequirement.objects.filter(template=self.template)

group_ids = requirements.filter(group__isnull=False).values_list("group", flat=True).distinct()

requirements_count = requirements.filter(group__isnull=True).count() + group_ids.count()

fulfilled_requirements_count = Fulfillment.objects.filter(
progress=self,
requirement__template=self.template,
Expand All @@ -456,6 +465,10 @@ def ratio(self) -> float:

@property
def completed(self):
"""
Checks if the badge template is completed.
"""

return self.ratio == 1.00

def validate(self):
Expand Down Expand Up @@ -560,4 +573,5 @@ def propagated(self):
"""
Checks if this user credential already has issued (external) Credly badge.
"""

return self.external_uuid and (self.state in self.ISSUING_STATES)
70 changes: 70 additions & 0 deletions credentials/apps/badges/tests/test_admin_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import uuid

from django import forms
from django.contrib.sites.models import Site
from django.test import TestCase
from django.utils.translation import gettext as _

from credentials.apps.badges.admin_forms import BadgePenaltyForm
from credentials.apps.badges.models import BadgeRequirement, BadgeTemplate


COURSE_PASSING_EVENT = "org.openedx.learning.course.passing.status.updated.v1"


class BadgePenaltyFormTestCase(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_template", state="draft", site=self.site
)
self.badge_template2 = BadgeTemplate.objects.create(
uuid=uuid.uuid4(), name="test_template", state="draft", site=self.site
)
self.requirement1 = BadgeRequirement.objects.create(
template=self.badge_template1,
event_type=COURSE_PASSING_EVENT,
description="Test course passing award description 1",
)
self.requirement2 = BadgeRequirement.objects.create(
template=self.badge_template2,
event_type=COURSE_PASSING_EVENT,
description="Test course passing award description 2",
)
self.requirement3 = BadgeRequirement.objects.create(
template=self.badge_template2,
event_type=COURSE_PASSING_EVENT,
description="Test course passing award description 3",
)

def test_clean_requirements_same_template(self):
form = BadgePenaltyForm()
form.cleaned_data = {
"template": self.badge_template2,
"requirements": [
self.requirement2,
self.requirement3,
],
}
self.assertEqual(form.clean(), {
"template": self.badge_template2,
"requirements": [
self.requirement2,
self.requirement3,
],
})

def test_clean_requirements_different_template(self):
form = BadgePenaltyForm()
form.cleaned_data = {
"template": self.badge_template1,
"requirements": [
self.requirement2,
self.requirement1,
],
}

with self.assertRaises(forms.ValidationError) as cm:
form.clean()

self.assertEqual(str(cm.exception), "['All requirements must belong to the same template.']")
85 changes: 76 additions & 9 deletions credentials/apps/badges/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
from django.conf import settings
from unittest.mock import patch

from django.conf import settings
from openedx_events.learning.data import UserData, UserPersonalData, CourseData, CoursePassingStatusData

from credentials.apps.badges.checks import badges_checks
from credentials.apps.badges.utils import extract_payload, keypath, get_user_data, get_event_type_keypaths
from credentials.apps.badges.credly.utils import get_credly_base_url, get_credly_api_base_url
from credentials.apps.badges.utils import credly_check, extract_payload, get_event_type_keypaths, get_user_data, keypath


class TestKeypath(unittest.TestCase):
Expand Down Expand Up @@ -127,15 +129,80 @@ def test_badges_checks_non_empty_events(self, mock_get_badging_event_types):
self.assertEqual(len(errors), 0)


class TestGetEventTypeKeypaths(unittest.TestCase):
def setUp(self):
self.EVENT_TYPE = "org.openedx.learning.course.passing.status.updated.v1"
class TestCredlyCheck(unittest.TestCase):
def test_credly_configured(self):
settings.BADGES_CONFIG = {
"credly": {
"CREDLY_BASE_URL": "https://www.credly.com",
"CREDLY_API_BASE_URL": "https://api.credly.com",
"CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com",
"CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com",
"USE_SANDBOX": True,
}
}
result = credly_check()
self.assertTrue(result)

def test_credly_not_configured(self):
settings.BADGES_CONFIG = {}
result = credly_check()
self.assertFalse(result)

def test_credly_missing_keys(self):
settings.BADGES_CONFIG = {
"credly": {
"CREDLY_BASE_URL": "https://www.credly.com",
"CREDLY_API_BASE_URL": "https://api.credly.com",
"USE_SANDBOX": True,
}
}
result = credly_check()
self.assertFalse(result)


class TestGetEventTypeKeypaths(unittest.TestCase):
def test_get_event_type_keypaths(self):
event_type_keypaths = get_event_type_keypaths(self.EVENT_TYPE)
self.assertIsNotNone(event_type_keypaths)
self.assertIn("status", event_type_keypaths)
self.assertIn("course.display_name", event_type_keypaths)
result = get_event_type_keypaths("org.openedx.learning.course.passing.status.updated.v1")

for ignored_keypath in settings.BADGES_CONFIG["rules"].get("ignored_keypaths", []):
self.assertNotIn(ignored_keypath, event_type_keypaths)
self.assertNotIn(ignored_keypath, result)

class TestGetCredlyBaseUrl(unittest.TestCase):
def test_get_credly_base_url_sandbox(self):
settings.BADGES_CONFIG["credly"] = {
"CREDLY_BASE_URL": "https://www.credly.com",
"CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com",
"USE_SANDBOX": True,
}
result = get_credly_base_url(settings)
self.assertEqual(result, "https://sandbox.credly.com")

def test_get_credly_base_url_production(self):
settings.BADGES_CONFIG["credly"] = {
"CREDLY_BASE_URL": "https://www.credly.com",
"CREDLY_SANDBOX_BASE_URL": "https://sandbox.credly.com",
"USE_SANDBOX": False,
}
result = get_credly_base_url(settings)
self.assertEqual(result, "https://www.credly.com")


class TestGetCredlyApiBaseUrl(unittest.TestCase):
def test_get_credly_api_base_url_sandbox(self):
settings.BADGES_CONFIG["credly"] = {
"CREDLY_API_BASE_URL": "https://api.credly.com",
"CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com",
"USE_SANDBOX": True,
}

result = get_credly_api_base_url(settings)
self.assertEqual(result, "https://sandbox.api.credly.com")

def test_get_credly_api_base_url_production(self):
settings.BADGES_CONFIG["credly"] = {
"CREDLY_API_BASE_URL": "https://api.credly.com",
"CREDLY_SANDBOX_API_BASE_URL": "https://sandbox.api.credly.com",
"USE_SANDBOX": False,
}
result = get_credly_api_base_url(settings)
self.assertEqual(result, "https://api.credly.com")
Loading

0 comments on commit 2d98698

Please sign in to comment.