Merge pull request #23284 from edx/ddumesnil/calendar-sync-button-AA-36
AA-36: Link to toggle calendar sync
This commit is contained in:
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="calendar-alt" class="svg-inline--fa fa-calendar-alt fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M148 288h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm108-12v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 96v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96-260v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h48V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h128V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h48c26.5 0 48 21.5 48 48zm-48 346V160H48v298c0 3.3 2.7 6 6 6h340c3.3 0 6-2.7 6-6z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
from .models import UserCalendarSyncConfig
|
||||
|
||||
SUBSCRIBE = 'subscribe'
|
||||
UNSUBSCRIBE = 'unsubscribe'
|
||||
|
||||
|
||||
def subscribe_user_to_calendar(user, course_key):
|
||||
"""
|
||||
|
||||
76
openedx/features/calendar_sync/plugins.py
Normal file
76
openedx/features/calendar_sync/plugins.py
Normal file
@@ -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
|
||||
44
openedx/features/calendar_sync/tests/test_plugins.py
Normal file
44
openedx/features/calendar_sync/tests/test_plugins.py
Normal file
@@ -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)
|
||||
51
openedx/features/calendar_sync/tests/test_views.py
Normal file
51
openedx/features/calendar_sync/tests/test_views.py
Normal file
@@ -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))
|
||||
16
openedx/features/calendar_sync/urls.py
Normal file
16
openedx/features/calendar_sync/urls.py
Normal file
@@ -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',
|
||||
),
|
||||
]
|
||||
0
openedx/features/calendar_sync/views/__init__.py
Normal file
0
openedx/features/calendar_sync/views/__init__.py
Normal file
54
openedx/features/calendar_sync/views/calendar_sync.py
Normal file
54
openedx/features/calendar_sync/views/calendar_sync.py
Normal file
@@ -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]))
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
<ul class="list-unstyled">
|
||||
% for course_tool in course_tools:
|
||||
<li class="course-tool">
|
||||
<a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
|
||||
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
|
||||
${course_tool.title()}
|
||||
</a>
|
||||
% if course_tool.http_method == HttpMethod.GET:
|
||||
<a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
|
||||
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
|
||||
${course_tool.title()}
|
||||
</a>
|
||||
% elif course_tool.http_method == HttpMethod.POST:
|
||||
<form class="course-tool-form" action="${course_tool.url(course_key)}" method="post">
|
||||
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
<input type="hidden" name="tool_data" value="${course_tool.data()}">
|
||||
<button class="course-tool-button" data-analytics-id="${course_tool.analytics_id()}" aria-hidden="true">
|
||||
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
|
||||
${course_tool.title()}
|
||||
</button>
|
||||
</form>
|
||||
% endif
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.utils.translation import ugettext as _
|
||||
<%page args="course_date" expression_filter="h"/>
|
||||
<div class="date-summary date-summary-${course_date.css_class}">
|
||||
<div class="left-column">
|
||||
<div class="calendar-icon"></div>
|
||||
<span class="icon fa fa-calendar" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="right-column">
|
||||
% if course_date.date:
|
||||
|
||||
@@ -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
|
||||
|
||||
1
setup.py
1
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",
|
||||
|
||||
Reference in New Issue
Block a user