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