Skip to content
Snippets Groups Projects
Unverified Commit a59155e8 authored by Dillon Dumesnil's avatar Dillon Dumesnil
Browse files

AA-36: Link to toggle calendar sync

parent 0c2dfd13
No related merge requests found
Showing
with 317 additions and 31 deletions
<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>
\ No newline at end of file
......@@ -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");
}
.section-tools .course-tool {
.course-tool-link:visited {
color: theme-color("primary");
}
&:not(:first-child) {
margin-top: ($baseline / 5);
}
&:not(:first-child) {
margin-top: ($baseline / 5);
}
@media print {
.section-tools {
display: none !important;
}
.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):
"""
......
"""
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
"""
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)
"""
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))
"""
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',
),
]
"""
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
......@@ -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",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment