diff --git a/lms/static/images/calendar-alt-regular.svg b/lms/static/images/calendar-alt-regular.svg deleted file mode 100644 index 1e90abcbba..0000000000 --- a/lms/static/images/calendar-alt-regular.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index b1fe70780b..11430439f2 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -11,7 +11,6 @@ .icon { width: 20px; - text-align: center; } &:not(:last-child) { @@ -275,21 +274,31 @@ } } } + } - .section-tools .course-tool { - .course-tool-link:visited { - color: theme-color("primary"); - } - - &:not(:first-child) { - margin-top: ($baseline / 5); - } + .section-tools .course-tool { + .course-tool-link:visited { + color: theme-color("primary"); } - @media print { - .section-tools { - display: none !important; - } + &:not(:first-child) { + margin-top: ($baseline / 5); + } + + .course-tool-button { + background: none; + border: none; + color: theme-color("primary"); + cursor: pointer; + font-size: 1em; + padding: 0; + text-align: left; + } + } + + @media print { + .section-tools { + display: none !important; } } @@ -422,20 +431,9 @@ padding-bottom: 0; } - .left-column { - flex: 0 0 24px; - - .calendar-icon { - margin-top: 4px; - height: 1em; - width: 16px; - background: url('#{$static-path}/images/calendar-alt-regular.svg'); - background-repeat: no-repeat; - } - } - .right-column { flex: auto; + padding-left: 4px; .localized-datetime { font-weight: $font-weight-bold; diff --git a/lms/urls.py b/lms/urls.py index d141946a33..cfc6e3df6a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -648,6 +648,12 @@ urlpatterns += [ include('openedx.features.course_bookmarks.urls'), ), + # Calendar Sync UI in LMS + url( + r'^courses/{}/'.format(settings.COURSE_ID_PATTERN,), + include('openedx.features.calendar_sync.urls'), + ), + # Course search url( r'^courses/{}/search/'.format( diff --git a/openedx/features/calendar_sync/api.py b/openedx/features/calendar_sync/api.py index 4f8ac91a31..97836ee0c6 100644 --- a/openedx/features/calendar_sync/api.py +++ b/openedx/features/calendar_sync/api.py @@ -3,6 +3,9 @@ from .models import UserCalendarSyncConfig +SUBSCRIBE = 'subscribe' +UNSUBSCRIBE = 'unsubscribe' + def subscribe_user_to_calendar(user, course_key): """ diff --git a/openedx/features/calendar_sync/plugins.py b/openedx/features/calendar_sync/plugins.py new file mode 100644 index 0000000000..0e07f52649 --- /dev/null +++ b/openedx/features/calendar_sync/plugins.py @@ -0,0 +1,76 @@ +""" +Platform plugins to support Calendar Sync toggle. +""" + + +from django.urls import reverse +from django.utils.translation import ugettext as _ + +from openedx.features.calendar_sync.api import SUBSCRIBE, UNSUBSCRIBE +from openedx.features.calendar_sync.models import UserCalendarSyncConfig +from openedx.features.course_experience import CALENDAR_SYNC_FLAG, RELATIVE_DATES_FLAG +from openedx.features.course_experience.course_tools import CourseTool, HttpMethod +from student.models import CourseEnrollment + + +class CalendarSyncToggleTool(CourseTool): + """ + The Calendar Sync toggle tool. + """ + http_method = HttpMethod.POST + link_title = _('Calendar Sync') + toggle_data = {'toggle_data': ''} + + @classmethod + def analytics_id(cls): + """ + Returns an id to uniquely identify this tool in analytics events. + """ + return 'edx.calendar-sync' + + @classmethod + def is_enabled(cls, request, course_key): + """ + The Calendar Sync toggle tool is limited to user enabled through a waffle flag. + Staff always has access. + """ + if not (CALENDAR_SYNC_FLAG.is_enabled(course_key) and RELATIVE_DATES_FLAG.is_enabled(course_key)): + return False + + if CourseEnrollment.is_enrolled(request.user, course_key): + if UserCalendarSyncConfig.is_enabled_for_course(request.user, course_key): + cls.link_title = _('Unsubscribe from calendar updates') + cls.toggle_data['toggle_data'] = UNSUBSCRIBE + else: + cls.link_title = _('Subscribe to calendar updates') + cls.toggle_data['toggle_data'] = SUBSCRIBE + return True + return False + + @classmethod + def title(cls): # pylint: disable=arguments-differ + """ + Returns the title of this tool. + """ + return cls.link_title + + @classmethod + def icon_classes(cls): # pylint: disable=arguments-differ + """ + Returns the icon classes needed to represent this tool. + """ + return 'fa fa-calendar' + + @classmethod + def url(cls, course_key): + """ + Returns the URL for this tool for the specified course key. + """ + return reverse('openedx.calendar_sync', args=[course_key]) + + @classmethod + def data(cls): + """ + Additional data to send with a form submission + """ + return cls.toggle_data diff --git a/openedx/features/calendar_sync/tests/test_plugins.py b/openedx/features/calendar_sync/tests/test_plugins.py new file mode 100644 index 0000000000..3ec11c3f26 --- /dev/null +++ b/openedx/features/calendar_sync/tests/test_plugins.py @@ -0,0 +1,44 @@ +""" +Unit tests for the calendar sync plugins. +""" + + +import crum +import ddt +from django.test import RequestFactory + +from xmodule.modulestore.tests.django_utils import CourseUserType, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag +from openedx.features.calendar_sync.plugins import CalendarSyncToggleTool +from openedx.features.course_experience import CALENDAR_SYNC_FLAG, RELATIVE_DATES_FLAG + + +@ddt.ddt +class TestCalendarSyncToggleTool(SharedModuleStoreTestCase): + """ + Test the calendar sync toggle tool. + """ + @classmethod + def setUpClass(cls): + """ Set up any course data """ + super(TestCalendarSyncToggleTool, cls).setUpClass() + cls.course = CourseFactory.create() + cls.course_key = cls.course.id + + @ddt.data( + [CourseUserType.ANONYMOUS, False], + [CourseUserType.ENROLLED, True], + [CourseUserType.UNENROLLED, False], + [CourseUserType.UNENROLLED_STAFF, False], + ) + @ddt.unpack + @override_waffle_flag(CALENDAR_SYNC_FLAG, active=True) + @RELATIVE_DATES_FLAG.override(active=True) + def test_calendar_sync_toggle_tool_is_enabled(self, user_type, should_be_enabled): + request = RequestFactory().request() + request.user = self.create_user_for_course(self.course, user_type) + self.addCleanup(crum.set_current_request, None) + crum.set_current_request(request) + self.assertEqual(CalendarSyncToggleTool.is_enabled(request, self.course.id), should_be_enabled) diff --git a/openedx/features/calendar_sync/tests/test_views.py b/openedx/features/calendar_sync/tests/test_views.py new file mode 100644 index 0000000000..d9892c050d --- /dev/null +++ b/openedx/features/calendar_sync/tests/test_views.py @@ -0,0 +1,51 @@ +""" +Tests for Calendar Sync views. +""" + + +import ddt + +from django.test import TestCase +from django.urls import reverse + +from openedx.features.calendar_sync.api import SUBSCRIBE, UNSUBSCRIBE +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +TEST_PASSWORD = 'test' + + +@ddt.ddt +class TestCalendarSyncView(SharedModuleStoreTestCase, TestCase): + """Tests for the calendar sync view.""" + @classmethod + def setUpClass(cls): + """ Set up any course data """ + super(TestCalendarSyncView, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestCalendarSyncView, self).setUp() + self.user = self.create_user_for_course(self.course) + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.calendar_sync_url = reverse('openedx.calendar_sync', args=[self.course.id]) + + @ddt.data( + # Redirect on successful subscribe + [{'tool_data': "{{'toggle_data': '{}'}}".format(SUBSCRIBE)}, 302, ''], + # Redirect on successful unsubscribe + [{'tool_data': "{{'toggle_data': '{}'}}".format(UNSUBSCRIBE)}, 302, ''], + # 422 on unknown toggle_data + [{'tool_data': "{{'toggle_data': '{}'}}".format('gibberish')}, 422, + 'Toggle data was not provided or had unknown value.'], + # 422 on no toggle_data + [{'tool_data': "{{'random_data': '{}'}}".format('gibberish')}, 422, + 'Toggle data was not provided or had unknown value.'], + # 422 on no tool_data + [{'nonsense': "{{'random_data': '{}'}}".format('gibberish')}, 422, 'Tool data was not provided.'], + ) + @ddt.unpack + def test_course_dates_fragment(self, data, expected_status_code, contained_text): + response = self.client.post(self.calendar_sync_url, data) + self.assertEqual(response.status_code, expected_status_code) + self.assertIn(contained_text, str(response.content)) diff --git a/openedx/features/calendar_sync/urls.py b/openedx/features/calendar_sync/urls.py new file mode 100644 index 0000000000..35ae5373db --- /dev/null +++ b/openedx/features/calendar_sync/urls.py @@ -0,0 +1,16 @@ +""" +Defines URLs for Calendar Sync. +""" + + +from django.conf.urls import url + +from .views.calendar_sync import CalendarSyncView + +urlpatterns = [ + url( + r'^calendar_sync$', + CalendarSyncView.as_view(), + name='openedx.calendar_sync', + ), +] diff --git a/openedx/features/calendar_sync/views/__init__.py b/openedx/features/calendar_sync/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/calendar_sync/views/calendar_sync.py b/openedx/features/calendar_sync/views/calendar_sync.py new file mode 100644 index 0000000000..930d3d80ab --- /dev/null +++ b/openedx/features/calendar_sync/views/calendar_sync.py @@ -0,0 +1,54 @@ +""" +Views to toggle Calendar Sync settings for a user on a course +""" + + +import json + +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.generic import View +from opaque_keys.edx.keys import CourseKey +from rest_framework import status + +from openedx.features.calendar_sync.api import ( + SUBSCRIBE, UNSUBSCRIBE, subscribe_user_to_calendar, unsubscribe_user_to_calendar +) +from util.views import ensure_valid_course_key + + +class CalendarSyncView(View): + """ + View for Calendar Sync + """ + @method_decorator(login_required) + @method_decorator(ensure_csrf_cookie) + @method_decorator(ensure_valid_course_key) + def post(self, request, course_id): + """ + Updates the request user's calendar sync subscription status + + Arguments: + request: HTTP request + course_id (str): string of a course key + """ + course_key = CourseKey.from_string(course_id) + tool_data = request.POST.get('tool_data') + if not tool_data: + return HttpResponse('Tool data was not provided.', status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + json_acceptable_string = tool_data.replace("'", "\"") + data = json.loads(json_acceptable_string) + toggle_data = data.get('toggle_data') + if toggle_data == SUBSCRIBE: + subscribe_user_to_calendar(request.user, course_key) + elif toggle_data == UNSUBSCRIBE: + unsubscribe_user_to_calendar(request.user, course_key) + else: + return HttpResponse('Toggle data was not provided or had unknown value.', + status=status.HTTP_422_UNPROCESSABLE_ENTITY) + return redirect(reverse('openedx.course_experience.course_home', args=[course_id])) diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index a9449ff1ca..5829ef695d 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -82,6 +82,9 @@ COURSE_ENABLE_UNENROLLED_ACCESS_FLAG = CourseWaffleFlag(SEO_WAFFLE_FLAG_NAMESPAC # Waffle flag to enable relative dates for course content RELATIVE_DATES_FLAG = ExperimentWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'relative_dates', experiment_id=17) +# Waffle flag to enable user calendar syncing +CALENDAR_SYNC_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'calendar_sync') + def course_home_page_title(course): # pylint: disable=unused-argument """ diff --git a/openedx/features/course_experience/course_tools.py b/openedx/features/course_experience/course_tools.py index 03c5c5df4c..336933dddb 100644 --- a/openedx/features/course_experience/course_tools.py +++ b/openedx/features/course_experience/course_tools.py @@ -3,12 +3,23 @@ Support for course tool plugins. """ +from enum import Enum + from openedx.core.lib.plugins import PluginManager # Stevedore extension point namespace COURSE_TOOLS_NAMESPACE = 'openedx.course_tool' +class HttpMethod(Enum): + """ Enum for HTTP Methods """ + DELETE = 'DELETE' + GET = 'GET' + OPTIONS = 'OPTIONS' + POST = 'POST' + PUT = 'PUT' + + class CourseTool(object): """ This is an optional base class for Course Tool plugins. @@ -18,6 +29,8 @@ class CourseTool(object): not a requirement, and plugin implementations outside of this repo should simply follow the contract defined below. """ + http_method = HttpMethod.GET + @classmethod def analytics_id(cls): """ @@ -57,6 +70,16 @@ class CourseTool(object): """ raise NotImplementedError("Must specify a url for a course tool.") + @classmethod + def data(cls): + """ + Additional data to send with a form submission + """ + if cls.http_method == HttpMethod.POST: + return {} + else: + return None + class CourseToolsPluginManager(PluginManager): """ diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index 879cd07826..1c4275e87d 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -15,6 +15,7 @@ from lms.djangoapps.discussion.django_comment_client.permissions import has_perm from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import Text, HTML from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REVIEWS_TOOL_FLAG +from openedx.features.course_experience.course_tools import HttpMethod %> <%block name="header_extras"> @@ -114,10 +115,21 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV diff --git a/openedx/features/course_experience/templates/course_experience/dates-summary.html b/openedx/features/course_experience/templates/course_experience/dates-summary.html index 0cfa204bcc..97ac0ea361 100644 --- a/openedx/features/course_experience/templates/course_experience/dates-summary.html +++ b/openedx/features/course_experience/templates/course_experience/dates-summary.html @@ -4,7 +4,7 @@ from django.utils.translation import ugettext as _ <%page args="course_date" expression_filter="h"/>
-
+
% if course_date.date: diff --git a/scripts/thresholds.sh b/scripts/thresholds.sh index eb18c1c821..b10fbc1ad9 100755 --- a/scripts/thresholds.sh +++ b/scripts/thresholds.sh @@ -2,6 +2,6 @@ set -e export LOWER_PYLINT_THRESHOLD=1000 -export UPPER_PYLINT_THRESHOLD=4050 +export UPPER_PYLINT_THRESHOLD=3990 export ESLINT_THRESHOLD=5530 export STYLELINT_THRESHOLD=880 diff --git a/setup.py b/setup.py index 50f3640e67..86259da331 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ setup( "wiki = lms.djangoapps.course_wiki.tab:WikiTab", ], "openedx.course_tool": [ + "calendar_sync_toggle = openedx.features.calendar_sync.plugins:CalendarSyncToggleTool", "course_bookmarks = openedx.features.course_bookmarks.plugins:CourseBookmarksTool", "course_updates = openedx.features.course_experience.plugins:CourseUpdatesTool", "course_reviews = openedx.features.course_experience.plugins:CourseReviewsTool",