Skip to content

Commit

Permalink
Merge branch 'aci.main' into hantkovskyi/aci-748/unconditional-badge-…
Browse files Browse the repository at this point in the history
…revocation
  • Loading branch information
wowkalucky authored Mar 28, 2024
2 parents 6ea69df + 7f4cd27 commit d23f1b6
Show file tree
Hide file tree
Showing 22 changed files with 574 additions and 19 deletions.
27 changes: 25 additions & 2 deletions credentials/apps/badges/distribution/credly/credly_badges/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from credentials.apps.badges.toggles import is_badges_enabled

from .forms import CredlyOrganizationAdminForm
from .models import CredlyBadgeTemplate, CredlyOrganization
from .models import CredlyBadge, CredlyBadgeTemplate, CredlyOrganization
from .utils import sync_badge_templates_for_organization


Expand All @@ -33,7 +33,7 @@ def sync_organization_badge_templates(self, request, queryset):
Sync badge templates for selected organizations.
"""
for organization in queryset:
sync_badge_templates_for_organization(organization.uuid)
sync_badge_templates_for_organization(organization.uuid, request.site)


class CredlyBadgeTemplateAdmin(admin.ModelAdmin):
Expand Down Expand Up @@ -63,6 +63,29 @@ class CredlyBadgeTemplateAdmin(admin.ModelAdmin):
]


class CredlyBadgeAdmin(admin.ModelAdmin):
"""
Credly badge admin setup.
"""
list_display = (
"username",
"state",
"uuid",
)
list_filter = (
"state",
)
search_fields = (
"username",
"uuid",
)
readonly_fields = (
"state",
"uuid",
)


if is_badges_enabled():
admin.site.register(CredlyOrganization, CredlyOrganizationAdmin)
admin.site.register(CredlyBadgeTemplate, CredlyBadgeTemplateAdmin)
admin.site.register(CredlyBadge, CredlyBadgeAdmin)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from credly_badges.models import CredlyOrganization
from credly_badges.utils import sync_badge_templates_for_organization
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand


Expand All @@ -12,27 +13,34 @@ class Command(BaseCommand):
help = "Sync badge templates for a specific organization or all organizations"

def add_arguments(self, parser):
parser.add_argument("--site_id", type=int, help="Site ID.")
parser.add_argument("--organization_id", type=str, help="UUID of the organization.")

def handle(self, *args, **options):
"""
Sync badge templates for a specific organization or all organizations.
Usage:
./manage.py sync_organization_badge_templates
./manage.py sync_organization_badge_templates --organization_id c117c179-81b1-4f7e-a3a1-e6ae30568c13
./manage.py sync_organization_badge_templates --site_id 1
./manage.py sync_organization_badge_templates --site_id 1 --organization_id c117c179-81b1-4f7e-a3a1-e6ae30568c13
"""

organization_id = options.get("organization_id")
site_id = options.get("site_id")
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
logger.info(f"Site with id {site_id} does not exists")

if organization_id:
logger.info(f"Syncing badge templates for single organization: {organization_id}")
sync_badge_templates_for_organization(organization_id)
sync_badge_templates_for_organization(organization_id, site)
else:
all_organization_ids = CredlyOrganization.get_all_organization_ids()
logger.info(
f"Organization id was not provided. Syncing badge templates for all organizations: {all_organization_ids}"
)
for organization_id in all_organization_ids:
sync_badge_templates_for_organization(organization_id)
sync_badge_templates_for_organization(organization_id, site)

logger.info("Done.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2024-03-20 12:30

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


class Migration(migrations.Migration):

dependencies = [
('credentials', '0026_alter_usercredential_credential_content_type'),
('credly_badges', '0003_credlybadgetemplate_state_and_more'),
]

operations = [
migrations.CreateModel(
name='CredlyBadge',
fields=[
('usercredential_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='credentials.usercredential')),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
bases=('credentials.usercredential',),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 3.2.20 on 2024-03-27 11:18

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


class Migration(migrations.Migration):

dependencies = [
('badges', '0001_initial'),
('credentials', '0026_alter_usercredential_credential_content_type'),
('credly_badges', '0004_credlybadge'),
]

operations = [
migrations.CreateModel(
name='CredlyBadgeCredential',
fields=[
('usercredential_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='credentials.usercredential')),
('state', model_utils.fields.StatusField(choices=[('created', 'created'), ('no_response', 'no_response'), ('error', 'error'), ('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected'), ('revoked', 'revoked')], default='created', help_text='Credly badge issuing state', max_length=100, no_check_for_status=True)),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
bases=('credentials.usercredential',),
),
migrations.DeleteModel(
name='CredlyBadge',
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.20 on 2024-03-27 11:46

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('credentials', '0026_alter_usercredential_credential_content_type'),
('contenttypes', '0002_remove_content_type_name'),
('badges', '0001_initial'),
('credly_badges', '0005_auto_20240327_1118'),
]

operations = [
migrations.RenameModel(
old_name='CredlyBadgeCredential',
new_name='CredlyBadge',
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,11 @@ class CredlyBadge(UserCredential):
"""
Earned Credly badge template for user.
"""
# TODO: check if we can fetch pii for username from LMS for badge issuing?

STATES = Choices("created", "no_response", "error", "pending", "accepted", "rejected", "revoked")

state = StatusField(
choices_name="STATES",
help_text=_("Credly badge issuing state"),
default=STATES.created,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from .models import CredlyBadgeTemplate, CredlyOrganization


def sync_badge_templates_for_organization(organization_id):
def sync_badge_templates_for_organization(organization_id, site):
"""
Pull active badge templates for a given Credly Organization.
Expand All @@ -27,7 +27,7 @@ def sync_badge_templates_for_organization(organization_id):
CredlyBadgeTemplate.objects.update_or_create(
uuid=badge_template_data.get("id"),
defaults={
"site": get_current_request().site,
"site": site,
"organization": organization,
"name": badge_template_data.get("name"),
"state": badge_template_data.get("state"),
Expand Down
4 changes: 2 additions & 2 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class BadgeTemplate(AbstractCredential):
Describes badge credential type.
"""

TYPE = "openedx"
ORIGIN = "openedx"

uuid = models.UUIDField(
unique=True, default=uuid.uuid4, help_text=_("Unique badge template ID.")
Expand All @@ -37,7 +37,7 @@ def save(self, *args, **kwargs):
super().save()
# auto-evaluate type:
if not self.origin:
self.origin = self.TYPE
self.origin = self.ORIGIN
self.save(*args, **kwargs)


Expand Down
11 changes: 9 additions & 2 deletions credentials/apps/badges/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from openedx_events.learning.data import BadgeData, BadgeTemplateData
from openedx_events.learning.signals import BADGE_AWARDED, BADGE_REVOKED

from credentials.apps.core.api import get_or_create_user_from_event_data

from .models import BadgeTemplate


def process(signal, sender, **kwargs):
"""
Processes incoming public signal consumed from event bus and re-emitted within the service.
This handler is connected to all public signals that are listed in BADGES_CONFIG setting.
"""

# find all REQUIREMENTs for the signal;
Expand All @@ -34,7 +38,7 @@ def process(signal, sender, **kwargs):
user=kwargs.get("user_course_data").user,
template=BadgeTemplateData(
uuid=str(badge_template.uuid),
type=badge_template.origin,
origin=badge_template.origin,
name=badge_template.name,
description=badge_template.description,
image_url=badge_template.icon.url,
Expand All @@ -45,7 +49,10 @@ def process(signal, sender, **kwargs):
BADGE_AWARDED.send_event(badge=badge_data)
elif sender == "org.openedx.learning.course.grade.now.failed.v1":
BADGE_REVOKED.send_event(badge=badge_data)


# Registering credentials User
if sender == "org.openedx.learning.student.registration.completed.v1":
get_or_create_user_from_event_data(kwargs.get("user"))

def collect(sender, **kwargs):
""" """
Expand Down
Empty file.
10 changes: 10 additions & 0 deletions credentials/apps/badges/services/badge_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.core.exceptions import ObjectDoesNotExist

from ..models import BadgeTemplate


def get_badge_template_by_id(badge_template_id):
try:
return BadgeTemplate.objects.get(id=badge_template_id)
except ObjectDoesNotExist:
return None
19 changes: 19 additions & 0 deletions credentials/apps/badges/services/user_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.contrib.contenttypes.models import ContentType

from ..models import BadgeTemplate, UserCredential


def create_user_credential(username, badge_template):
if not isinstance(username, str):
raise ValueError("`username` must be a string")

if not isinstance(badge_template, BadgeTemplate):
raise TypeError("`badge_template` must be an instance of BadgeTemplate")


UserCredential.objects.create(
credential_content_type=ContentType.objects.get_for_model(
badge_template),
credential_id=badge_template.id,
username=username,
)
69 changes: 68 additions & 1 deletion credentials/apps/badges/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@
"""
import logging

from django.dispatch import receiver

from openedx_events.learning.data import BadgeData, BadgeTemplateData
from openedx_events.learning.signals import BADGE_AWARDED, BADGE_REVOKED
from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals

from apps.core.api import get_user_by_username

from .signals import BADGE_PROGRESS_COMPLETE, BADGE_PROGRESS_INCOMPLETE
from ..services.badge_templates import get_badge_template_by_id
from ..services.user_credentials import create_user_credential
from ..utils import get_badging_event_types
from ..processing import process

Expand All @@ -33,4 +42,62 @@ def event_handler(sender, signal, **kwargs):
logger.debug(f"Received signal {signal}")

# NOTE (performance): all consumed messages from event bus trigger this.
process(signal, sender=sender, **kwargs)
process(signal, sender=sender, **kwargs)


@receiver(BADGE_PROGRESS_COMPLETE)
def listen_for_completed_badge(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
badge_template = get_badge_template_by_id(badge_template_id)
user = get_user_by_username(username)

if badge_template is None:
return

if badge_template.origin == 'openedx':
create_user_credential(username, badge_template)

badge = award_badge() # function needs to be implemented

# UserCredential.as_badge_data():
badge_data = BadgeData(
uuid=badge.uuid,
user=user,
template=BadgeTemplateData(
uuid=str(badge_template.uuid),
type=badge_template.origin,
name=badge_template.name,
description=badge_template.description,
image_url=badge_template.icon.url,
),
)

BADGE_AWARDED.send_event(badge=badge_data)


@receiver(BADGE_PROGRESS_INCOMPLETE)
def listen_for_incompleted_badge(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
badge_template = get_badge_template_by_id(badge_template_id)
user = get_user_by_username(username)

if badge_template is None:
return

if user is None:
return

badge = revoke_badge() # function needs to be implemented

# UserCredential.as_badge_data():
badge_data = BadgeData(
uuid=badge.uuid,
user=user,
template=BadgeTemplateData(
uuid=str(badge_template.uuid),
type=badge_template.origin,
name=badge_template.name,
description=badge_template.description,
image_url=badge_template.icon.url,
),
)

BADGE_REVOKED.send_event(badge=badge_data)
8 changes: 8 additions & 0 deletions credentials/apps/badges/signals/signals.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from django.dispatch import Signal

"""
define internal signals:
- BADGE_REQUIREMENT_FULFILLED - a single specific requirement has finished;
- BADGE_REQUIREMENTS_COMPLETE - all badge template requirements are finished;
- BADGE_REQUIREMENTS_NOT_COMPLETE - a reason for earned badge revocation;
"""

from django.dispatch import Signal


BADGE_PROGRESS_COMPLETE = Signal()
BADGE_PROGRESS_INCOMPLETE = Signal()
Loading

0 comments on commit d23f1b6

Please sign in to comment.