Skip to content

Commit

Permalink
refactor: [ACI-249] extract and unify (#142)
Browse files Browse the repository at this point in the history
* refactor: [ACI-249] extract and unify

* refactor: extract signal emitters

* fix: progress method API

* fix: "model.check" is an internal Django method
  • Loading branch information
wowkalucky authored Apr 16, 2024
1 parent f051688 commit 310d341
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 91 deletions.
3 changes: 1 addition & 2 deletions credentials/apps/badges/issuers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from credentials.apps.badges.credly.data import IssueBadgeData
from credentials.apps.badges.credly.exceptions import CredlyAPIError
from credentials.apps.badges.models import BadgeTemplate, CredlyBadge, CredlyBadgeTemplate, UserCredential
from credentials.apps.badges.processing.progression import notify_badge_awarded
from credentials.apps.badges.processing.regression import notify_badge_revoked
from credentials.apps.badges.signals.signals import notify_badge_awarded, notify_badge_revoked
from credentials.apps.core.api import get_user_by_username
from credentials.apps.credentials.constants import UserCredentialStatus
from credentials.apps.credentials.issuers import AbstractCredentialIssuer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.20 on 2024-04-15 14:28

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('badges', '0012_auto_20240412_1707'),
]

operations = [
migrations.AlterField(
model_name='datarule',
name='requirement',
field=models.ForeignKey(help_text='Parent requirement for this data rule.', on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='badges.badgerequirement'),
),
]
149 changes: 110 additions & 39 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from credentials.apps.badges.signals.signals import (
notify_progress_complete,
notify_progress_incomplete,
notify_requirement_fulfilled,
notify_requirement_regressed,
)
from django_extensions.db.models import TimeStampedModel
from model_utils import Choices
from model_utils.fields import StatusField
Expand Down Expand Up @@ -93,7 +99,7 @@ def user_progress(self, username: str) -> float:
"""
Determines a completion progress for user.
"""
progress = BadgeProgress.objects.filter(username=username, template=self).first()
progress = BadgeProgress.for_user(username=username, template_id=self.id)
if progress is None:
return 0.00
return progress.ratio
Expand Down Expand Up @@ -136,7 +142,7 @@ class BadgeRequirement(models.Model):
Describes what must happen for badge template to progress.
- what unique event is expected to happen;
- what exact conditions such event must carry in its payload;
- what exact conditions the expected event must carry in its payload;
NOTE: all attached to a badge template requirements must be fulfilled by default;
to achieve "OR" processing logic for 2 attached requirements just group them (put identical group ID).
Expand All @@ -157,9 +163,7 @@ class BadgeRequirement(models.Model):
'Public signal type. Available events are configured in "BADGES_CONFIG" setting. The crucial aspect for event to carry UserData in its payload.'
),
)

description = models.TextField(null=True, blank=True, help_text=_("Provide more details if needed."))

group = models.CharField(
max_length=255,
null=True,
Expand All @@ -172,31 +176,58 @@ class BadgeRequirement(models.Model):
def __str__(self):
return f"BadgeRequirement:{self.id}:{self.template.uuid}"

def fulfill(self, username: str):
"""
Marks itself as "done" for the user.
- notifies about the progression if any;
Returns: (bool) if progression happened
"""
template_id = self.template.id
progress = BadgeProgress.for_user(username=username, template_id=template_id)
fulfillment, created = Fulfillment.objects.get_or_create(progress=progress, requirement=self)
if created:
notify_requirement_fulfilled(
sender=self,
username=username,
badge_template_id=template_id,
fulfillment_id=fulfillment.id,
)
return created

def reset(self, username: str):
fulfillments = Fulfillment.objects.filter(
"""
Marks itself as "undone" for the user.
- removes user progress for the requirement if any;
- notifies about the regression if any;
Returns: (bool) if any progress existed.
"""
template_id = self.template.id
fulfillment = Fulfillment.objects.filter(
requirement=self,
progress__username=username,
)
fulfillments.delete()
BADGE_REQUIREMENT_REGRESSED.send(sender=None, username=username, fulfillments=fulfillments)
).first()
deleted, __ = fulfillment.delete()
if deleted:
notify_requirement_regressed(
sender=self,
username=username,
badge_template_id=template_id,
fulfillment_id=fulfillment.id,
)
return bool(deleted)

def is_fulfilled(self, username: str) -> bool:
return self.fulfillment_set.filter(progress__username=username, progress__template=self.template).exists()

def fulfill(self, username: str):
progress, _ = BadgeProgress.objects.get_or_create(template=self.template, username=username)
fulfillment, _ = Fulfillment.objects.get_or_create(progress=progress, requirement=self)
BADGE_REQUIREMENT_FULFILLED.send(sender=None, username=username, fulfillment=fulfillment)

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
"""
Evaluates payload rules.
"""
return all(rule.apply(data) for rule in self.rules.all())

@property
def is_active(self):
Expand Down Expand Up @@ -237,6 +268,36 @@ class AbstractDataRule(models.Model):
class Meta:
abstract = True

def apply(self, data: dict) -> bool:
"""
Evaluates itself on the input data (event payload).
This method retrieves a value specified by a data path within a given dictionary,
converts that value to a string, and then applies a comparison operation against
a predefined value. The comparison operation is determined by the `self.operator`
attribute, which should match the name of an operator function in the `operator`
module.
Parameters:
- data (dict): A dictionary containing data against which the comparison operation
will be applied. The specific value to be compared is determined by
the `self.data_path` attribute, which specifies the path to the value
within the dictionary.
Returns:
- bool: True if the rule "worked".
Example:
Assuming `self.operator` is set to "eq", `self.data_path` is set to "user.age",
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))
return comparison_func(data_value, self.value)
return False


class DataRule(AbstractDataRule):
"""
Expand All @@ -248,6 +309,7 @@ class DataRule(AbstractDataRule):
BadgeRequirement,
on_delete=models.CASCADE,
help_text=_("Parent requirement for this data rule."),
related_name="rules",
)

class Meta:
Expand Down Expand Up @@ -293,15 +355,15 @@ class BadgePenalty(models.Model):
description = models.TextField(null=True, blank=True, help_text=_("Provide more details if needed."))

class Meta:
verbose_name_plural = "Badge penalties"
verbose_name_plural = _("Badge penalties")

def __str__(self):
return f"BadgePenalty:{self.id}:{self.template.uuid}"

class Meta:
verbose_name_plural = "Badge penalties"

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):
Expand Down Expand Up @@ -329,25 +391,15 @@ class PenaltyDataRule(AbstractDataRule):
class Meta:
unique_together = ("penalty", "data_path", "operator", "value")

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

def save(self, *args, **kwargs):
if not is_datapath_valid(self.data_path, self.penalty.event_type):
raise ValidationError("Invalid data path for event type")

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

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

def apply(self, data: dict) -> bool:
comparison_func = getattr(operator, self.operator, None)
if comparison_func:
data_value = str(keypath(data, self.data_path))
return comparison_func(data_value, self.value)
return False

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

@property
def is_active(self):
return self.penalty.template.is_active
Expand All @@ -358,6 +410,7 @@ class BadgeProgress(models.Model):
Tracks a single badge template progress for user.
- allows multiple requirements status tracking;
- user-centric;
"""

credential = models.OneToOneField(
Expand All @@ -380,10 +433,18 @@ class Meta:
def __str__(self):
return f"BadgeProgress:{self.username}"

@classmethod
def for_user(cls, *, username, template_id):
"""
Service shortcut.
"""
progress, __ = cls.objects.get_or_create(username=username, template_id=template_id)
return progress

@property
def ratio(self) -> float:
"""
Calculate badge template progress ratio.
Calculates badge template progress ratio.
"""

requirements = BadgeRequirement.objects.filter(template=self.template)
Expand All @@ -407,6 +468,16 @@ def ratio(self) -> float:
return 0.00
return round(fulfilled_requirements_count / requirements_count, 2)

def validate(self):
"""
Performs self-check and notifies about the current status.
"""
if self.completed():
notify_progress_complete(self, self.username, self.template.id)

if not self.completed():
notify_progress_incomplete(self, self.username, self.template.id)

def reset(self):
Fulfillment.objects.filter(progress=self).delete()

Expand Down
1 change: 1 addition & 0 deletions credentials/apps/badges/processing/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from credentials.apps.badges.utils import extract_payload, get_user_data
from credentials.apps.core.api import get_or_create_user_from_event_data


logger = logging.getLogger(__name__)


Expand Down
20 changes: 6 additions & 14 deletions credentials/apps/badges/processing/progression.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
Awarding pipeline - badge progression.
"""

import logging
from typing import List

from openedx_events.learning.signals import BADGE_AWARDED

from credentials.apps.badges.models import BadgeRequirement


logger = logging.getLogger(__name__)


def discover_requirements(event_type: str) -> List[BadgeRequirement]:
"""
Picks all relevant requirements based on the event type.
Expand All @@ -24,6 +26,8 @@ def process_requirements(event_type, username, payload_dict):
requirements = discover_requirements(event_type=event_type)
completed_templates = set()

logger.debug("BADGES: found %s requirements to process.", len(requirements))

for requirement in requirements:

# ignore: if the badge template wasn't activated yet
Expand All @@ -45,15 +49,3 @@ def process_requirements(event_type, username, payload_dict):
# process: payload rules
if requirement.apply_rules(payload_dict):
requirement.fulfill(username)


def notify_badge_awarded(user_credential): # pylint: disable=unused-argument
"""
Emit public event about badge template completion.
- username
- badge template ID
"""

badge_data = user_credential.as_badge_data()
BADGE_AWARDED.send_event(badge=badge_data)
25 changes: 9 additions & 16 deletions credentials/apps/badges/processing/regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@
Revocation pipeline - badge regression.
"""

import uuid
import logging
from typing import List

from openedx_events.learning.data import (
BadgeData,
BadgeTemplateData,
CoursePassingStatusData,
UserData,
UserPersonalData,
)
from openedx_events.learning.data import CoursePassingStatusData
from openedx_events.learning.signals import BADGE_REVOKED

from credentials.apps.badges.models import BadgePenalty, CredlyBadgeTemplate, UserCredential
from credentials.apps.badges.signals.signals import BADGE_PROGRESS_INCOMPLETE
from credentials.apps.badges.utils import keypath
from credentials.apps.badges.models import BadgePenalty, CredlyBadgeTemplate, UserCredential


logger = logging.getLogger(__name__)


def discover_penalties(event_type: str) -> List[BadgePenalty]:
Expand All @@ -40,20 +42,11 @@ def process_penalties(event_type, username, payload_dict):
"""

penalties = discover_penalties(event_type=event_type)

logger.debug("BADGES: found %s penalties to process.", len(penalties))

for penalty in penalties:
if not penalty.is_active:
continue
if penalty.apply_rules(payload_dict):
penalty.reset_requirements(username)


def notify_badge_revoked(user_credential): # pylint: disable=unused-argument
"""
Emit public event about badge template regression.
- username
- badge template ID
"""

badge_data = user_credential.as_badge_data()
BADGE_REVOKED.send_event(badge=badge_data)
Loading

0 comments on commit 310d341

Please sign in to comment.