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: due date reminder #32826

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
14 changes: 0 additions & 14 deletions cms/djangoapps/contentstore/config/waffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,3 @@
REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = WaffleFlag(
f'{WAFFLE_NAMESPACE}.library_authoring_mfe', __name__, LOG_PREFIX
)


# .. toggle_name: studio.custom_relative_dates
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable custom pacing input for Personalized Learner Schedule (PLS).
# .. This flag guards an input in Studio for a self paced course, where the user can enter date offsets
# .. for a subsection.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-07-12
# .. toggle_target_removal_date: 2021-12-31
# .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__)
2 changes: 1 addition & 1 deletion cms/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<%!
from django.utils.translation import gettext as _

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from lms.djangoapps.branding import api as branding_api
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
Expand Down
13 changes: 11 additions & 2 deletions openedx/core/djangoapps/content/course_overviews/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
Declaration of CourseOverview model
"""

from __future__ import annotations

import json
import logging
from datetime import datetime
from typing import List
from urllib.parse import urlparse, urlunparse

import pytz
Expand All @@ -17,6 +19,7 @@
from django.db.models.signals import post_save, post_delete
from django.db.utils import IntegrityError
from django.template import defaultfilters
from opaque_keys.edx.keys import CourseKey

from django.utils.functional import cached_property
from model_utils.models import TimeStampedModel
Expand Down Expand Up @@ -702,10 +705,16 @@ def get_all_courses(cls, orgs=None, filter_=None, active_only=False, course_keys
return course_overviews

@classmethod
def get_all_course_keys(cls):
def get_all_course_keys(cls, self_paced: bool | None = None) -> List[CourseKey]:
"""
Returns all course keys from course overviews.
Returns all course keys from course overviews, optionally filter by pacing.
The filter is only used when a boolean is passed as argument and it is disabled when this value is `None`.

Args:
self_paced: Optionally filter by pacing
"""
if self_paced is not None:
return CourseOverview.objects.filter(self_paced=self_paced).values_list('id', flat=True)
return CourseOverview.objects.values_list('id', flat=True)

def is_discussion_tab_enabled(self):
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/course_date_signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course
from xblock.fields import Scope

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/course_date_signals/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.handlers import (
_gather_graded_items,
_get_custom_pacing_children,
_has_assignment_blocks,
extract_dates_from_course
)
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig

from . import utils
Expand Down
26 changes: 23 additions & 3 deletions openedx/core/djangoapps/course_date_signals/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"""

from datetime import timedelta
from typing import Optional

from openedx.core.djangoapps.catalog.utils import get_course_run_details
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES


MIN_DURATION = timedelta(weeks=4)
Expand All @@ -33,7 +35,24 @@ def get_expected_duration(course_id):
return access_duration


def spaced_out_sections(course):
def get_expected_duration_based_on_relative_due_dates(course) -> timedelta:
"""
Calculate duration based on custom relative due dates.
Returns the longest relative due date if set else a minimum duration of 1 week.
"""
duration_in_weeks = 1
if CUSTOM_RELATIVE_DATES.is_enabled(course.id):
for section in course.get_children():
if section.visible_to_staff_only:
continue
for subsection in section.get_children():
relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection)
if relative_weeks_due and relative_weeks_due > duration_in_weeks:
duration_in_weeks = relative_weeks_due
return timedelta(weeks=duration_in_weeks)


def spaced_out_sections(course, duration: Optional[timedelta] = None):
"""
Generator that returns sections of the course block with a suggested time to complete for each

Expand All @@ -42,13 +61,14 @@ def spaced_out_sections(course):
section (block): a section block of the course
relative time (timedelta): the amount of weeks to complete the section, since start of course
"""
duration = get_expected_duration(course.id)
if not duration:
duration = get_expected_duration(course.id)
sections = [
section
for section
in course.get_children()
if not section.visible_to_staff_only
]
weeks_per_section = duration / len(sections)
weeks_per_section = duration / (len(sections) or 1) # if course has zero sections
for idx, section in enumerate(sections):
yield idx, section, weeks_per_section * (idx + 1)
20 changes: 20 additions & 0 deletions openedx/core/djangoapps/course_date_signals/waffle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
This module contains various configuration settings via
waffle switches for the course_date_signals app.
"""


from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag

# .. toggle_name: studio.custom_relative_dates
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable custom pacing input for Personalized Learner Schedule (PLS).
# .. This flag guards an input in Studio for a self paced course, where the user can enter date offsets
# .. for a subsection.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-07-12
# .. toggle_target_removal_date: 2021-12-31
# .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag('studio.custom_relative_dates', __name__)
1 change: 1 addition & 0 deletions openedx/core/djangoapps/schedules/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,5 +176,6 @@ class ScheduleConfigAdmin(admin.ModelAdmin): # lint-amnesty, pylint: disable=mi
'enqueue_recurring_nudge', 'deliver_recurring_nudge',
'enqueue_upgrade_reminder', 'deliver_upgrade_reminder',
'enqueue_course_update', 'deliver_course_update',
'enqueue_course_due_date_reminder', 'deliver_course_due_date_reminder',
)
form = ScheduleConfigAdminForm
68 changes: 47 additions & 21 deletions openedx/core/djangoapps/schedules/content_highlights.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"""
Contains methods for accessing course highlights. Course highlights is a
schedule experience built on the Schedules app.
Contains methods for accessing course highlights and course due dates.
Course highlights is a schedule experience built on the Schedules app.
"""


from datetime import timedelta
import logging

from lms.djangoapps.courseware.courses import get_course
from lms.djangoapps.courseware.exceptions import CourseRunNotFound
from openedx.core.djangoapps.course_date_signals.utils import spaced_out_sections
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.lib.request_utils import get_request_or_stub
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order

log = logging.getLogger(__name__)
DUE_DATE_FORMAT = "%b %d, %Y at %H:%M %Z"


def get_all_course_highlights(course_key):
Expand Down Expand Up @@ -67,8 +71,8 @@ def course_has_highlights_from_store(course_key):
course_key (CourseKey): course to lookup from the modulestore
"""
try:
course = _get_course_descriptor(course_key)
except CourseUpdateDoesNotExist:
course = get_course(course_key, depth=1)
except CourseRunNotFound:
return False
return course_has_highlights(course)

Expand All @@ -93,7 +97,16 @@ def get_week_highlights(user, course_key, week_num):
return highlights


def get_next_section_highlights(user, course_key, start_date, target_date):
def get_upcoming_subsection_due_dates(user, course_key, start_date, target_date, current_date, duration=None):
"""
Get section due dates, based upon the current date.
"""
course_descriptor = get_course(course_key, depth=2)
course_block = _get_course_block(course_descriptor, user)
return _get_upcoming_due_dates(course_block, start_date, target_date, current_date, duration)


def get_next_section_highlights(user, course_key, start_date, target_date, duration=None):
"""
Get highlights (list of unicode strings) for a week, based upon the current date.

Expand All @@ -102,12 +115,12 @@ def get_next_section_highlights(user, course_key, start_date, target_date):
"""
course_descriptor = _get_course_with_highlights(course_key)
course_block = _get_course_block(course_descriptor, user)
return _get_highlights_for_next_section(course_block, start_date, target_date)
return _get_highlights_for_next_section(course_block, start_date, target_date, duration)


def _get_course_with_highlights(course_key):
""" Gets Course descriptor if highlights are enabled for the course """
course_descriptor = _get_course_descriptor(course_key)
course_descriptor = get_course(course_key, depth=1)
if not course_descriptor.highlights_enabled_for_messaging:
raise CourseUpdateDoesNotExist(
f'{course_key} Course Update Messages are disabled.'
Expand All @@ -116,16 +129,6 @@ def _get_course_with_highlights(course_key):
return course_descriptor


def _get_course_descriptor(course_key):
""" Gets course descriptor from modulestore """
descriptor = modulestore().get_course(course_key, depth=1)
if descriptor is None:
raise CourseUpdateDoesNotExist(
f'Course {course_key} not found.'
)
return descriptor


def _get_course_block(course_descriptor, user):
""" Gets course block that takes into account user state and permissions """
# Adding courseware imports here to insulate other apps (e.g. schedules) to
Expand All @@ -146,7 +149,7 @@ def _get_course_block(course_descriptor, user):
user, request, course_descriptor, field_data_cache, course_descriptor.id, course=course_descriptor,
)
if not course_block:
raise CourseUpdateDoesNotExist(f'Course block {course_descriptor.id} not found')
raise CourseRunNotFound(course_descriptor.id)
return course_block


Expand Down Expand Up @@ -175,10 +178,10 @@ def _get_highlights_for_week(sections, week_num, course_key):
return section.highlights


def _get_highlights_for_next_section(course, start_date, target_date):
def _get_highlights_for_next_section(course, start_date, target_date, duration=None):
""" Using the target date, retrieves highlights for the next section. """
use_next_sections_highlights = False
for index, section, weeks_to_complete in spaced_out_sections(course):
for index, section, weeks_to_complete in spaced_out_sections(course, duration):
# We calculate section due date ourselves (rather than grabbing the due attribute),
# since not every section has a real due date (i.e. not all are graded), but we still
# want to know when this section should have been completed by the learner.
Expand All @@ -199,3 +202,26 @@ def _get_highlights_for_next_section(course, start_date, target_date):
)

return None, None


def _get_upcoming_due_dates(course, start_date, target_date, current_date, duration=None):
""" Retrieves section names and due dates within the provided target_date. """
date_items = []
# Apply the same relative due date to all content inside a section,
# unless that item already has a relative date set
for _, section, days_to_complete in spaced_out_sections(course, duration):
# Default to Personalized Learner Schedules (PLS) logic for self paced courses.
section_due_date = start_date + days_to_complete
section_date_items = []

for subsection in section.get_children():
# Get custom due date for subsection if it is set
relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection)
if CUSTOM_RELATIVE_DATES.is_enabled(course.id) and relative_weeks_due:
section_due_date = start_date + timedelta(weeks=relative_weeks_due)

# If the section_due_date is within current date and the target date range, include it in reminder list.
if current_date <= section_due_date <= target_date:
section_date_items.append((subsection.display_name, section_due_date.strftime(DUE_DATE_FORMAT)))
date_items.extend(section_date_items)
return date_items
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Management command to send Schedule course due date reminders
"""

import datetime
import pytz
from textwrap import dedent # lint-amnesty, pylint: disable=wrong-import-order

from django.contrib.sites.models import Site

from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand
from openedx.core.djangoapps.schedules.tasks import COURSE_DUE_DATE_REMINDER_LOG_PREFIX, ScheduleCourseDueDateReminders


class Command(SendEmailBaseCommand):
"""
Command to send due date reminders for subsections in Self paced courses.

Note: this feature does not support reminders for INDIVIDUAL_DUE_DATES as the applicable schedule
objects are fetched based on course relative due dates.

Usage:
./manage.py lms send_course_due_date_reminders localhost:18000 --due 7 --date 2023-06-07

Positional required args:
- site: Django site domain name, for example: localhost:18000
Keyword Required args
- due-in: Remind subsections due in given days
Optional args:
- date: The date to compute weekly messages relative to, in YYYY-MM-DD format.
- override-recipient-email: Send all emails to this address instead of the actual recipient
"""
help = dedent(__doc__).strip()
async_send_task = ScheduleCourseDueDateReminders
log_prefix = COURSE_DUE_DATE_REMINDER_LOG_PREFIX

def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument(
'--due-in',
type=int,
help='Remind subsections due in given days',
)

def handle(self, *args, **options):
current_date = datetime.datetime(
*[int(x) for x in options['date'].split('-')],
tzinfo=pytz.UTC
)

site = Site.objects.get(domain__iexact=options['site_domain_name'])
override_recipient_email = options.get('override_recipient_email')
navinkarkera marked this conversation as resolved.
Show resolved Hide resolved

self.async_send_task.enqueue(site, current_date, options['due_in'], override_recipient_email)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
class Command(SendEmailBaseCommand):
"""
Command to send Schedule course updates for Self-paced Courses

Usage:
./manage.py lms send_course_next_section_update localhost:18000 --date 2023-06-08
"""
help = dedent(__doc__).strip()
async_send_task = ScheduleCourseNextSectionUpdate
Expand Down
4 changes: 4 additions & 0 deletions openedx/core/djangoapps/schedules/message_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@ class CourseUpdate(ScheduleMessageType):
pass


class CourseDueDatesReminder(ScheduleMessageType):
pass


class InstructorLedCourseUpdate(ScheduleMessageType):
pass
Loading