From be3b15062198d9d8f97be9ebdf19e42bcdcb6b4d Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso <ndalfonso@edx.org> Date: Fri, 14 Feb 2020 12:35:58 -0500 Subject: [PATCH] AA-12 reset self paced due dates - for self paced courses, if a sequential's due date has passed and it is incomplete, allow the user to reset the start_date for the related schedule to now, thus shifting all due dates for all sequentials within the course by the pre determined due date offset. - add historical table to the Schedule model. --- lms/djangoapps/courseware/views/views.py | 9 +++- lms/static/sass/bootstrap/lms-main.scss | 1 + .../sass/course/layout/_reset_deadlines.scss | 23 ++++++++++ lms/static/sass/shared-v2/_components.scss | 2 +- lms/templates/main.html | 4 ++ lms/templates/reset_deadlines_banner.html | 13 ++++++ .../migrations/0013_historicalschedule.py | 45 +++++++++++++++++++ openedx/core/djangoapps/schedules/models.py | 2 + .../course-outline-fragment.html | 11 ++++- .../tests/views/test_course_home.py | 7 +++ .../tests/views/test_course_outline.py | 17 ++++++- openedx/features/course_experience/urls.py | 7 ++- .../course_experience/views/course_outline.py | 22 +++++++++ 13 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 lms/static/sass/course/layout/_reset_deadlines.scss create mode 100644 lms/templates/reset_deadlines_banner.html create mode 100644 openedx/core/djangoapps/schedules/migrations/0013_historicalschedule.py diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 4cd4c466e74..5ac5f7acec8 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -110,7 +110,8 @@ from openedx.features.course_duration_limits.access import generate_course_expir from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, UNIFIED_COURSE_TAB_FLAG, - course_home_url_name + course_home_url_name, + RELATIVE_DATES_FLAG, ) from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView @@ -728,6 +729,10 @@ class CourseTabView(EdxFragmentView): else: masquerade = None + reset_deadlines_url = reverse( + 'openedx.course_experience.reset_course_deadlines', kwargs={'course_id': text_type(course.id)} + ) + context = { 'course': course, 'tab': tab, @@ -738,6 +743,8 @@ class CourseTabView(EdxFragmentView): 'uses_bootstrap': uses_bootstrap, 'uses_pattern_library': not uses_bootstrap, 'disable_courseware_js': True, + 'relative_dates_is_enabled': RELATIVE_DATES_FLAG.is_enabled(course.id), + 'reset_deadlines_url': reset_deadlines_url, } # Avoid Multiple Mathjax loading on the 'user_profile' if 'profile_page_context' in kwargs: diff --git a/lms/static/sass/bootstrap/lms-main.scss b/lms/static/sass/bootstrap/lms-main.scss index fc384030be0..935fea19cfe 100644 --- a/lms/static/sass/bootstrap/lms-main.scss +++ b/lms/static/sass/bootstrap/lms-main.scss @@ -15,6 +15,7 @@ $static-path: '../..'; @import 'layouts'; @import 'components'; @import 'course/layout/courseware_preview'; +@import 'course/layout/reset_deadlines'; @import 'shared/modal'; @import 'shared/help-tab'; diff --git a/lms/static/sass/course/layout/_reset_deadlines.scss b/lms/static/sass/course/layout/_reset_deadlines.scss new file mode 100644 index 00000000000..24c915e4ac9 --- /dev/null +++ b/lms/static/sass/course/layout/_reset_deadlines.scss @@ -0,0 +1,23 @@ +div.reset-deadlines-banner { + background-color: theme-color("primary"); + display: none; + flex-wrap: wrap; + padding: 15px 20px; + margin-top: 5px; + + div, + button { + flex: 0 0 auto; + + &.reset-deadlines-text { + color: theme-color("inverse"); + padding-top: 2px; + margin-right: 10px; + } + + &.reset-deadlines-button { + border-radius: 5px; + cursor: pointer; + } + } +} diff --git a/lms/static/sass/shared-v2/_components.scss b/lms/static/sass/shared-v2/_components.scss index 24bcffce6df..afab8dca34a 100644 --- a/lms/static/sass/shared-v2/_components.scss +++ b/lms/static/sass/shared-v2/_components.scss @@ -148,7 +148,7 @@ @include box-sizing(border-box); margin: 0 auto; - padding: ($baseline*0.75) ($baseline*2); + padding: ($baseline*0.75) 20px; background-color: theme-color("primary"); @media print { diff --git a/lms/templates/main.html b/lms/templates/main.html index 4858b1ef993..37a0954339a 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -184,6 +184,10 @@ from pipeline_mako import render_require_js_path_overrides <%include file="/preview_menu.html" /> % endif + % if course and course.self_paced and tab and relative_dates_is_enabled: + <%include file="/reset_deadlines_banner.html" /> + % endif + <%include file="/page_banner.html" /> <div class="marketing-hero"><%block name="marketing_hero"></%block></div> diff --git a/lms/templates/reset_deadlines_banner.html b/lms/templates/reset_deadlines_banner.html new file mode 100644 index 00000000000..39cc944037e --- /dev/null +++ b/lms/templates/reset_deadlines_banner.html @@ -0,0 +1,13 @@ +## mako + +<%page expression_filter="h"/> +<%! +from django.utils.translation import ugettext as _ +%> +<div class="reset-deadlines-banner"> + <div class="reset-deadlines-text">${_("It looks like you've missed some important deadlines. Reset your deadlines and get started today.")}</div> + <form method="post" action="${reset_deadlines_url}"> + <input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}"> + <button class="reset-deadlines-button">${_("Reset my deadlines")}</button> + </form> +</div> diff --git a/openedx/core/djangoapps/schedules/migrations/0013_historicalschedule.py b/openedx/core/djangoapps/schedules/migrations/0013_historicalschedule.py new file mode 100644 index 00000000000..e60df25e462 --- /dev/null +++ b/openedx/core/djangoapps/schedules/migrations/0013_historicalschedule.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2020-03-03 18:36 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('schedules', '0012_auto_20200302_1914'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalSchedule', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('active', models.BooleanField(default=True, help_text='Indicates if this schedule is actively used')), + ('start', models.DateTimeField(db_index=True, help_text='Date this schedule went into effect')), + ('start_date', models.DateTimeField(db_index=True, help_text='Date this schedule went into effect')), + ('upgrade_deadline', models.DateTimeField(blank=True, db_index=True, help_text='Deadline by which the learner must upgrade to a verified seat', null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('enrollment', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='student.CourseEnrollment')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + 'verbose_name': 'historical Schedule', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/openedx/core/djangoapps/schedules/models.py b/openedx/core/djangoapps/schedules/models.py index 26425fa4c50..e713d375036 100644 --- a/openedx/core/djangoapps/schedules/models.py +++ b/openedx/core/djangoapps/schedules/models.py @@ -6,6 +6,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from model_utils import Choices from model_utils.models import TimeStampedModel +from simple_history.models import HistoricalRecords class Schedule(TimeStampedModel): @@ -33,6 +34,7 @@ class Schedule(TimeStampedModel): null=True, help_text=_('Deadline by which the learner must upgrade to a verified seat') ) + history = HistoricalRecords() def get_experience_type(self): try: diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html index 3ccdd7641b6..a0f0bdaf196 100644 --- a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html @@ -6,8 +6,10 @@ <%! import json -from datetime import date +import pytz +from datetime import date, datetime, timedelta +from django.utils import timezone from django.utils.translation import ugettext as _ from openedx.core.djangolib.markup import HTML, Text @@ -16,6 +18,7 @@ from openedx.core.djangolib.markup import HTML, Text <% course_sections = blocks.get('children') self_paced = context.get('self_paced', False) +reset_deadlines_banner_displayed = False %> <main role="main" class="course-outline" id="main" tabindex="-1"> % if course_sections is not None: @@ -56,6 +59,12 @@ self_paced = context.get('self_paced', False) scored = 'scored' if subsection.get('scored', False) else '' graded = 'graded' if subsection.get('graded') else '' %> + % if not subsection.get('complete', True) and subsection.get('due', timezone.now() + timedelta(1)) < timezone.now() and not reset_deadlines_banner_displayed: + <% reset_deadlines_banner_displayed = True %> + <script type="text/javascript"> + $('.reset-deadlines-banner').css('display', 'flex'); + </script> + % endif <li class="subsection accordion ${ 'current' if subsection.get('resume_block') else '' } ${graded} ${scored}"> <a % if enable_links: diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index 8b88c99381e..37396e22ce2 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -51,6 +51,7 @@ from openedx.core.djangolib.markup import HTML from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, + RELATIVE_DATES_FLAG, SHOW_REVIEWS_TOOL_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME, UNIFIED_COURSE_TAB_FLAG @@ -948,6 +949,7 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): self.course = CourseFactory( start=now() - timedelta(days=30), end=end, + self_paced=True, ) self.url = course_home_url(self.course) @@ -1020,3 +1022,8 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): response = self.client.get(self.url) self.assertContains(response, "<span>DISCOUNT_PRICE</span>") + + @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) + def test_reset_deadline_banner_is_present_on_course_tab(self): + response = self.client.get(self.url) + self.assertContains(response, '<div class="reset-deadlines-banner">') diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py index 88d413a2508..07353f8634c 100644 --- a/openedx/features/course_experience/tests/views/test_course_outline.py +++ b/openedx/features/course_experience/tests/views/test_course_outline.py @@ -14,6 +14,7 @@ from completion.test_utils import CompletionWaffleTestMixin from django.contrib.sites.models import Site from django.test import override_settings from django.urls import reverse +from django.utils import timezone from milestones.tests.utils import MilestonesTestCaseMixin from mock import Mock, patch from opaque_keys.edx.keys import CourseKey, UsageKey @@ -25,6 +26,8 @@ from waffle.testutils import override_switch from lms.djangoapps.courseware.tests.factories import StaffFactory from gating import api as lms_gating_api from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer +from openedx.core.djangoapps.schedules.models import Schedule +from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.lib.gating import api as gating_api from openedx.features.course_experience.views.course_outline import ( DEFAULT_COMPLETION_TRACKING_START, @@ -55,7 +58,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): # pylint: disable=super-method-not-called with super(TestCourseOutlinePage, cls).setUpClassAndTestData(): cls.courses = [] - course = CourseFactory.create() + course = CourseFactory.create(self_paced=True) with cls.store.bulk_operations(course.id): chapter = ItemFactory.create(category='chapter', parent_location=course.location) sequential = ItemFactory.create(category='sequential', parent_location=chapter.location) @@ -132,6 +135,18 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): self.assertContains(response, sequential.format) self.assertTrue(sequential.children) + def test_reset_course_deadlines(self): + course = self.courses[0] + enrollment = CourseEnrollment.objects.get(course_id=course.id) + ScheduleFactory( + start_date=timezone.now() - datetime.timedelta(1), + enrollment=enrollment + ) + url = '{}{}'.format(course_home_url(course), 'reset_deadlines') + self.client.post(url) + updated_schedule = Schedule.objects.get(enrollment=enrollment) + self.assertEqual(updated_schedule.start_date.day, datetime.datetime.today().day) + class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, MilestonesTestCaseMixin): """ diff --git a/openedx/features/course_experience/urls.py b/openedx/features/course_experience/urls.py index 14a96b7ac53..1a444ab99a4 100644 --- a/openedx/features/course_experience/urls.py +++ b/openedx/features/course_experience/urls.py @@ -7,7 +7,7 @@ from django.conf.urls import url from .views.course_dates import CourseDatesFragmentMobileView from .views.course_home import CourseHomeFragmentView, CourseHomeView -from .views.course_outline import CourseOutlineFragmentView +from .views.course_outline import CourseOutlineFragmentView, reset_course_deadlines from .views.course_reviews import CourseReviewsView from .views.course_sock import CourseSockFragmentView from .views.course_updates import CourseUpdatesFragmentView, CourseUpdatesView @@ -70,4 +70,9 @@ urlpatterns = [ CourseDatesFragmentMobileView.as_view(), name='openedx.course_experience.mobile_dates_fragment_view', ), + url( + r'^reset_deadlines$', + reset_course_deadlines, + name='openedx.course_experience.reset_course_deadlines', + ), ] diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index bab2cdc4858..228aa2c480e 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -5,11 +5,16 @@ Views to show a course outline. import datetime import re +import pytz +import six from completion import waffle as completion_waffle from django.contrib.auth.models import User +from django.shortcuts import redirect from django.template.context_processors import csrf from django.template.loader import render_to_string +from django.urls import reverse +from django.views.decorators.csrf import ensure_csrf_cookie import edx_when.api as edx_when_api from opaque_keys.edx.keys import CourseKey from pytz import UTC @@ -17,6 +22,7 @@ from waffle.models import Switch from web_fragments.fragment import Fragment from lms.djangoapps.courseware.courses import get_course_overview_with_access +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from student.models import CourseEnrollment from util.milestones_helpers import get_course_content_milestones @@ -154,3 +160,19 @@ class CourseOutlineFragmentView(EdxFragmentView): if children: children[0]['resume_block'] = True self.mark_first_unit_to_resume(children[0]) + + +@ensure_csrf_cookie +def reset_course_deadlines(request, course_id): + """ + Set the start_date of a schedule to today, which in turn will adjust due dates for + sequentials belonging to a self paced course + """ + course = CourseOverview.objects.get(id=course_id) + if course.self_paced: + enrollment = CourseEnrollment.objects.get(user=request.user, course=course_id) + schedule = enrollment.schedule + if schedule: + schedule.start_date = datetime.datetime.now(pytz.utc) + schedule.save() + return redirect(reverse('openedx.course_experience.course_home', args=[six.text_type(course_id)])) -- GitLab