Skip to content

Commit

Permalink
feat: added an app for sending progress emails to users (#509)
Browse files Browse the repository at this point in the history
* feat: added an app for sending progress emails to users

* feat: add cronjob for sending progress emails to users

* make changes to only email course progress for specific sites

* improve command

* send emails on unit block completion post save signal instead of settings cronjob of django command

* remove empty command

* edit email template

* improve command

* filter all block completions instead of only verticals

* change signal name

* add signal to send course completion email to user

* finalize email templates

* convert floating digit to number

* add space for arabic text

* correct message condition

* add red color for progress percentage

* make percentage inline

* correct inline issue for progress percentage

---------

Co-authored-by: Ali Salman <[email protected]>
Co-authored-by: Muhammad Faraz  Maqsood <[email protected]>
Co-authored-by: Muhammad Faraz  Maqsood <[email protected]>
  • Loading branch information
4 people authored Mar 5, 2024
1 parent 36c31df commit 62d4be1
Show file tree
Hide file tree
Showing 26 changed files with 480 additions and 0 deletions.
Empty file.
24 changes: 24 additions & 0 deletions openedx/features/sdaia_features/course_progress/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Admin Models
"""
"""
Django Admin page for SurveyReport.
"""


from django.contrib import admin
from .models import CourseCompletionEmailHistory


class CourseCompletionEmailHistoryAdmin(admin.ModelAdmin):
"""
Admin to manage Course Completion Email History.
"""
list_display = (
'id', 'user', 'course_key', 'last_progress_email_sent',
)
search_fields = (
'id', 'user__username', 'user__email', 'course_key',
)

admin.site.register(CourseCompletionEmailHistory, CourseCompletionEmailHistoryAdmin)
20 changes: 20 additions & 0 deletions openedx/features/sdaia_features/course_progress/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Progress Updates App Config
"""
from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs, PluginSettings
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType

class CourseProgressConfig(AppConfig):
name = 'openedx.features.sdaia_features.course_progress'

plugin_app = {
PluginSettings.CONFIG: {
ProjectType.LMS: {
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'},
}
}
}

def ready(self):
from . import signals # pylint: disable=unused-import
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 3.2.20 on 2024-02-19 07:33

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import opaque_keys.edx.django.models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='CourseCompletionEmailHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('course_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('last_progress_email_sent', models.IntegerField(default=0)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
Empty file.
16 changes: 16 additions & 0 deletions openedx/features/sdaia_features/course_progress/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
Models
"""
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.db import models

from opaque_keys.edx.django.models import CourseKeyField


class CourseCompletionEmailHistory(models.Model):
"""
Keeps progress for a student for which he/she gets an email as he/she reaches at that particluar progress in a course.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE)
course_key = CourseKeyField(max_length=255, db_index=True)
last_progress_email_sent = models.IntegerField(default=0)
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

"""Settings"""


def plugin_settings(settings):
"""
Required Common settings
"""
70 changes: 70 additions & 0 deletions openedx/features/sdaia_features/course_progress/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Signal handlers for the course progress emails
"""
import logging

from completion.models import BlockCompletion
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.sites.models import Site

from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED
from openedx.core.lib.celery.task_utils import emulate_http_request
from openedx.features.sdaia_features.course_progress.models import CourseCompletionEmailHistory
from openedx.features.sdaia_features.course_progress.tasks import send_user_course_progress_email, send_user_course_completion_email
from openedx.features.sdaia_features.course_progress.utils import get_user_course_progress
from xmodule.modulestore.django import modulestore

logger = logging.getLogger(__name__)


@receiver(post_save, sender=BlockCompletion)
def send_course_progress_milestones_achievement_emails(**kwargs):
"""
Receives the BlockCompletion signal and sends the email to
the user if he completes a specific course progress threshold.
"""
logger.info(f"\n\n\n inside send_course_progress_milestones_achievement_emails \n\n\n")
instance = kwargs['instance']
if not instance.context_key.is_course:
return # Content in a library or some other thing that doesn't support milestones

course_key = instance.context_key

course = modulestore().get_course(course_key)
course_completion_percentages_for_emails = course.course_completion_percentages_for_emails
if not course.allow_course_completion_emails or not course_completion_percentages_for_emails:
return

course_completion_percentages_for_emails = course_completion_percentages_for_emails.split(",")
try:
course_completion_percentages_for_emails = [int(entry.strip()) for entry in course_completion_percentages_for_emails]
except Exception as e:
log.info(f"invalid course_completion_percentages_for_emails for course {str(course_key)}")
return

user_id = instance.user_id
user = User.objects.get(id=user_id)
user_completion_progress_email_history, _ = CourseCompletionEmailHistory.objects.get_or_create(user=user, course_key=course_key)
progress_last_email_sent_at = user_completion_progress_email_history and user_completion_progress_email_history.last_progress_email_sent
if progress_last_email_sent_at == course_completion_percentages_for_emails[-1]:
return

site = Site.objects.first() or Site.objects.get_current()
with emulate_http_request(site, user):
user_completion_percentage = get_user_course_progress(user, course_key)

if user_completion_percentage > progress_last_email_sent_at:
for course_completion_percentages_for_email in course_completion_percentages_for_emails:
if user_completion_percentage >= course_completion_percentages_for_email > progress_last_email_sent_at:
send_user_course_progress_email.delay(user_completion_percentage, progress_last_email_sent_at, course_completion_percentages_for_email, str(course_key), user_id)


@receiver(COURSE_GRADE_NOW_PASSED, dispatch_uid="course_completion")
def send_course_completion_email(sender, user, course_id, **kwargs): # pylint: disable=unused-argument
"""
Listen for a signal indicating that the user has passed a course run.
"""
logger.info(f"\n\n\n inside send_course_completion_email \n\n\n")
send_user_course_completion_email.delay(user.id, str(course_id))
138 changes: 138 additions & 0 deletions openedx/features/sdaia_features/course_progress/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
celery tasks for the course progress emails
"""
import logging

from celery import shared_task
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.sites.models import Site
from edx_ace import ace
from edx_ace.recipient import Recipient
from opaque_keys.edx.keys import CourseKey

from lms.djangoapps.grades.api import CourseGradeFactory
from openedx.core.djangoapps.ace_common.message import BaseMessageType
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.lib.celery.task_utils import emulate_http_request
from openedx.features.sdaia_features.course_progress.models import CourseCompletionEmailHistory
from xmodule.modulestore.django import modulestore
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url

logger = logging.getLogger(__name__)


class UserCourseProgressEmail(BaseMessageType):
"""
Message Type Class for User Course Progress Email
"""
APP_LABEL = 'course_progress'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.options['transactional'] = True


class UserCourseCompletionEmail(BaseMessageType):
"""
Message Type Class for User Course Completion Email
"""
APP_LABEL = 'course_progress'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.options['transactional'] = True


@shared_task
def send_user_course_progress_email(current_progress, progress_last_email_sent_at, course_completion_percentages_for_email, course_key, user_id):
"""
Sends User Activation Code Via Email
"""
user = User.objects.get(id=user_id)
course_id = CourseKey.from_string(course_key)
course = modulestore().get_course(course_id)

site = Site.objects.first() or Site.objects.get_current()
message_context = get_base_template_context(site)
course_home_url = get_learning_mfe_home_url(course_key=course_key, url_fragment='home')
platform_name = configuration_helpers.get_value_for_org(
course.org,
'PLATFORM_NAME',
settings.PLATFORM_NAME
)

context={
'current_progress': int(current_progress),
'progress_milestone_crossed': progress_last_email_sent_at,
'course_key': course_key,
'platform_name': platform_name,
'course_name': course.display_name,
'course_home_url': course_home_url,
}
message_context.update(context)
user_language_pref = get_user_preference(user, LANGUAGE_KEY) or settings.LANGUAGE_CODE
try:
with emulate_http_request(site, user):
msg = UserCourseProgressEmail(context=message_context).personalize(
recipient=Recipient(0, user.email),
language=user_language_pref,
user_context={'full_name': user.profile.name}
)
ace.send(msg)
logger.info('course progress email sent to user:')
user_completion_progress_email_history = CourseCompletionEmailHistory.objects.get(user=user, course_key=course_key)
user_completion_progress_email_history.last_progress_email_sent = course_completion_percentages_for_email
user_completion_progress_email_history.save()
return True
except Exception as e: # pylint: disable=broad-except
logger.exception(str(e))
logger.exception('Could not send course progress email sent to user')
return False


@shared_task
def send_user_course_completion_email(user_id, course_key):
course_id = CourseKey.from_string(course_key)
user = User.objects.get(id=user_id)
collected_block_structure = get_block_structure_manager(course_id).get_collected()
course_grade = CourseGradeFactory().read(user, collected_block_structure=collected_block_structure)
passing_grade = int(course_grade.percent * 100)

course = modulestore().get_course(course_id)
site = Site.objects.first() or Site.objects.get_current()
message_context = get_base_template_context(site)
course_progress_url = get_learning_mfe_home_url(course_key=course_key, url_fragment='progress')
platform_name = configuration_helpers.get_value_for_org(
course.org,
'PLATFORM_NAME',
settings.PLATFORM_NAME
)

context={
'course_key': course_key,
'platform_name': platform_name,
'course_name': course.display_name,
'course_progress_url': course_progress_url,
'passing_grade': passing_grade,
}
message_context.update(context)
user_language_pref = get_user_preference(user, LANGUAGE_KEY) or settings.LANGUAGE_CODE
try:
with emulate_http_request(site, user):
msg = UserCourseCompletionEmail(context=message_context).personalize(
recipient=Recipient(0, user.email),
language=user_language_pref,
user_context={'full_name': user.profile.name}
)
ace.send(msg)
logger.info('course completion email sent to user:')
return True
except Exception as e: # pylint: disable=broad-except
logger.exception(str(e))
logger.exception('Could not send course completion email sent to user')
return False
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@

{% comment %}
As the developer of this package, don't place anything here if you can help it
since this allows developers to have interoperability between your template
structure and their own.

Example: Developer melding the 2SoD pattern to fit inside with another pattern::

{% extends "base.html" %}
{% load static %}

<!-- Their site uses old school block layout -->
{% block extra_js %}

<!-- Your package using 2SoD block layout -->
{% block javascript %}
<script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
{% endblock javascript %}

{% endblock extra_js %}
{% endcomment %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!-- {% extends 'ace_common/edx_ace/common/base_body.html' %} -->

{% load i18n %}
{% load static %}
{% block content %}
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}Congratulations!{% endblocktrans %}
{% endautoescape %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}{{full_name}}{% endblocktrans %}
{% endautoescape %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}You have successfully completed this course! {% endblocktrans %}
{% endautoescape %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}Please click {% endblocktrans %}<a href="{{ course_progress_url }}">{% blocktrans %}here{% endblocktrans %}</a>{% blocktrans %} to view your accreditation. {% endblocktrans %}
{% endautoescape %}
<br />
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}Thank you. {% endblocktrans %}
{% endautoescape %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}SDAIA Academy{% endblocktrans %}
{% endautoescape %}
<br />
</p>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}Congratulations!{% endblocktrans %}
{% blocktrans %}{{ full_name }}{% endblocktrans %}
{% blocktrans %}You have successfully completed this course! {% endblocktrans %}
{% blocktrans %}Please click {% endblocktrans %}<a href="{{ course_progress_url }}">{% blocktrans %}here{% endblocktrans %}</a>{% blocktrans %} to view your accreditation. {% endblocktrans %}
{% blocktrans %}Thank you. {% endblocktrans %}
{% blocktrans %}SDAIA Academy{% endblocktrans %}
{% endautoescape %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ platform_name }}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- {% extends 'ace_common/edx_ace/common/base_head.html' %} -->
Loading

0 comments on commit 62d4be1

Please sign in to comment.