Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added an app for sending progress emails to users #509

Merged
merged 18 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
17 changes: 17 additions & 0 deletions openedx/features/sdaia_features/course_progress/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
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'},
}
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
Django admin command to send message email emails.
"""
import json
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 django.core.management.base import BaseCommand
from edx_ace import ace
from edx_ace.recipient import Recipient

from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary
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.lib.celery.task_utils import emulate_http_request
from openedx.features.sdaia_features.course_progress.models import CourseCompletionEmailHistory
from xmodule.course_block import CourseFields # lint-amnesty, pylint: disable=wrong-import-order
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 Activation
"""
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, course_name, user_id):
"""
Sends User Activation Code Via Email
"""
user = User.objects.get(id=user_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')

context={
'current_progress': current_progress,
'progress_milestone_crossed': progress_last_email_sent_at,
'course_key': course_key,
'platform_name': "sdaia",
Faraz32123 marked this conversation as resolved.
Show resolved Hide resolved
'course_name': course_name,
'course_home_url': course_home_url,
}
message_context.update(context)
try:
with emulate_http_request(site, user):
msg = UserCourseProgressEmail(context=message_context).personalize(
recipient=Recipient(0, user.email),
language=settings.LANGUAGE_CODE,
user_context={'full_name': user.profile.name}
)
ace.send(msg)
logger.info('Proctoring requirements 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 email for proctoring requirements to user')
return False


def get_user_course_progress(user, course_key):
"""
Function to get the user's course completion percentage in a course.
:param user: The user object.
:param course_key: The course key (e.g., CourseKey.from_string("edX/DemoX/Demo_Course")).
:return: completion percentage.
"""
completion_summary = get_course_blocks_completion_summary(course_key, user)

complete_count = completion_summary.get('complete_count', 0)
incomplete_count = completion_summary.get('incomplete_count', 0)
locked_count = completion_summary.get('locked_count', 0)
total_count = complete_count + incomplete_count + locked_count

completion_percentage = (complete_count / total_count) * 100
return completion_percentage


class Command(BaseCommand):
"""
This command will update users about their course progress.
$ ./manage.py lms send_progress_emails
"""
help = 'Command to update users about their course progress'

def add_arguments(self, parser):
parser.add_argument(
'--skip-archived',
default=True,
help="If set to False, it'll send emails for archived courses also",
)

def handle(self, *args, **options):
skip_archived = options.get('skip-archived')

course_ids = [course.id for course in modulestore().get_courses()]

for course_key in course_ids:
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:
continue

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 {CourseKey.from_string(course_key)}")
continue

if skip_archived and course.has_ended():
continue

user_ids = CourseEnrollment.objects.filter(course_id=course_key, is_active=True).values_list('user_id', flat=True)
users = User.objects.filter(id__in=user_ids)
if not user_ids:
continue

for user in users:
user_completion_percentage = get_user_course_progress(user, course_key)
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 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), course.display_name, user.id)

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
"""
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,26 @@
<!-- {% 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 %}Hi there,{% endblocktrans %}
{% endautoescape %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}Congrats on your progress! You're {{ current_progress }}% through the "{{ course_name }}". {% endblocktrans %}
{% endautoescape %}
</p>
<p style="color: rgba(0,0,0,.75);">
{% autoescape off %}
{# xss-lint: disable=django-blocktrans-missing-escape-filter #}
{% blocktrans %}Keep it up! Continue your journey {% endblocktrans %}<a href="{{ course_home_url }}">here</a>.
{% endautoescape %}
<br />
</p>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}Hi there,{% endblocktrans %}
{% blocktrans %}Congrats on your progress! You're {{ current_progress }}% through the "{{ course_name }}".{% endblocktrans %}
{% blocktrans %}Keep it up! Continue your journey <a href="{{ course_home_url }}">here</a>.{% endblocktrans %}
{% trans "Enjoy your studies," %}
{% blocktrans %}The {{ platform_name }} Team {% 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' %} -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}{{ platform_name }} - {{ course_name }} | Course Progress 🚀 {% endblocktrans %}
{% endautoescape %}
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig",
"courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig",
"course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig",
"course_progress = openedx.features.sdaia_features.course_progress.apps:CourseProgressConfig",
],
"cms.djangoapp": [
"announcements = openedx.features.announcements.apps:AnnouncementsConfig",
Expand Down
12 changes: 12 additions & 0 deletions xmodule/course_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,18 @@ def to_json(self, value):


class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
allow_course_completion_emails = Boolean(
display_name=_("Allow Course Completion emails at different percentages"),
help=_("Enter true or false. When true, students will get email when they reach specific percentages mentioned in 'course completion percentages for emails'."),
default=False,
scope=Scope.settings
)
course_completion_percentages_for_emails = String(
display_name=_("Course Completion Percentages"),
help=_("set comma separated percentages at which instructors want to send course completion emails e.g. '60, 70'"),
default="60, 70",
scope=Scope.settings
)
lti_passports = List(
display_name=_("LTI Passports"),
help=_('Enter the passports for course LTI tools in the following format: "id:client_key:client_secret".'),
Expand Down
Loading