Commit d18b24b0 by Greg Price

Add discussion API course endpoint

This endpoint returns course metadata relating to discussions as a
starting point for clients.
parent 7a3c1f04
...@@ -1419,21 +1419,41 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor): ...@@ -1419,21 +1419,41 @@ class CourseDescriptor(CourseFields, LicenseMixin, SequenceDescriptor):
""" """
return date_time + u" UTC" return date_time + u" UTC"
@property def get_discussion_blackout_datetimes(self):
def forum_posts_allowed(self): """
Get a list of dicts with start and end fields with datetime values from
the discussion_blackouts setting
"""
date_proxy = Date() date_proxy = Date()
try: try:
blackout_periods = [(date_proxy.from_json(start), ret = [
date_proxy.from_json(end)) {"start": date_proxy.from_json(start), "end": date_proxy.from_json(end)}
for start, end for start, end
in filter(None, self.discussion_blackouts)] in filter(None, self.discussion_blackouts)
]
for blackout in ret:
if not blackout["start"] or not blackout["end"]:
raise ValueError
return ret
except (TypeError, ValueError):
log.exception(
"Error parsing discussion_blackouts %s for course %s",
self.discussion_blackouts,
self.id
)
return []
@property
def forum_posts_allowed(self):
"""
Return whether forum posts are allowed by the discussion_blackouts
setting
"""
blackouts = self.get_discussion_blackout_datetimes()
now = datetime.now(UTC()) now = datetime.now(UTC())
for start, end in blackout_periods: for blackout in blackouts:
if start <= now <= end: if blackout["start"] <= now <= blackout["end"]:
return False return False
except:
log.exception("Error parsing discussion_blackouts %s for course %s", self.discussion_blackouts, self.id)
return True return True
@property @property
......
...@@ -108,15 +108,53 @@ def _is_user_author_or_privileged(cc_content, context): ...@@ -108,15 +108,53 @@ def _is_user_author_or_privileged(cc_content, context):
) )
def get_thread_list_url(request, course_key, topic_id_list): def get_thread_list_url(request, course_key, topic_id_list=None):
""" """
Returns the URL for the thread_list_url field, given a list of topic_ids Returns the URL for the thread_list_url field, given a list of topic_ids
""" """
path = reverse("thread-list") path = reverse("thread-list")
query_list = [("course_id", unicode(course_key))] + [("topic_id", topic_id) for topic_id in topic_id_list] query_list = (
[("course_id", unicode(course_key))] +
[("topic_id", topic_id) for topic_id in topic_id_list or []]
)
return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), "")))
def get_course(request, course_key):
"""
Return general discussion information for the course.
Parameters:
request: The django request object used for build_absolute_uri and
determining the requesting user.
course_key: The key of the course to get information for
Returns:
The course information; see discussion_api.views.CourseView for more
detail.
Raises:
Http404: if the course does not exist or is not accessible to the
requesting user
"""
course = _get_course_or_404(course_key, request.user)
return {
"id": unicode(course_key),
"blackouts": [
{"start": blackout["start"].isoformat(), "end": blackout["end"].isoformat()}
for blackout in course.get_discussion_blackout_datetimes()
],
"thread_list_url": get_thread_list_url(request, course_key, topic_id_list=[]),
"topics_url": request.build_absolute_uri(
reverse("course_topics", kwargs={"course_id": course_key})
)
}
def get_course_topics(request, course_key): def get_course_topics(request, course_key):
""" """
Return the course topic listing for the given course and user. Return the course topic listing for the given course and user.
......
...@@ -26,6 +26,7 @@ from discussion_api.api import ( ...@@ -26,6 +26,7 @@ from discussion_api.api import (
delete_comment, delete_comment,
delete_thread, delete_thread,
get_comment_list, get_comment_list,
get_course,
get_course_topics, get_course_topics,
get_thread_list, get_thread_list,
update_comment, update_comment,
...@@ -63,6 +64,72 @@ def _remove_discussion_tab(course, user_id): ...@@ -63,6 +64,72 @@ def _remove_discussion_tab(course, user_id):
modulestore().update_item(course, user_id) modulestore().update_item(course, user_id)
@ddt.ddt
class GetCourseTest(UrlResetMixin, ModuleStoreTestCase):
"""Test for get_course"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super(GetCourseTest, self).setUp()
self.course = CourseFactory.create(org="x", course="y", run="z")
self.user = UserFactory.create()
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def test_nonexistent_course(self):
with self.assertRaises(Http404):
get_course(self.request, CourseLocator.from_string("non/existent/course"))
def test_not_enrolled(self):
unenrolled_user = UserFactory.create()
self.request.user = unenrolled_user
with self.assertRaises(Http404):
get_course(self.request, self.course.id)
def test_discussions_disabled(self):
_remove_discussion_tab(self.course, self.user.id)
with self.assertRaises(Http404):
get_course(self.request, self.course.id)
def test_basic(self):
self.assertEqual(
get_course(self.request, self.course.id),
{
"id": unicode(self.course.id),
"blackouts": [],
"thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz",
"topics_url": "http://testserver/api/discussion/v1/course_topics/x/y/z",
}
)
def test_blackout(self):
# A variety of formats is accepted
self.course.discussion_blackouts = [
["2015-06-09T00:00:00Z", "6-10-15"],
[1433980800000, datetime(2015, 6, 12)],
]
modulestore().update_item(self.course, self.user.id)
result = get_course(self.request, self.course.id)
self.assertEqual(
result["blackouts"],
[
{"start": "2015-06-09T00:00:00+00:00", "end": "2015-06-10T00:00:00+00:00"},
{"start": "2015-06-11T00:00:00+00:00", "end": "2015-06-12T00:00:00+00:00"},
]
)
@ddt.data(None, "not a datetime", "2015", [])
def test_blackout_errors(self, bad_value):
self.course.discussion_blackouts = [
[bad_value, "2015-06-09T00:00:00Z"],
["2015-06-10T00:00:00Z", "2015-06-11T00:00:00Z"],
]
modulestore().update_item(self.course, self.user.id)
result = get_course(self.request, self.course.id)
self.assertEqual(result["blackouts"], [])
@mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False}) @mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False})
class GetCourseTopicsTest(UrlResetMixin, ModuleStoreTestCase): class GetCourseTopicsTest(UrlResetMixin, ModuleStoreTestCase):
"""Test for get_course_topics""" """Test for get_course_topics"""
......
...@@ -67,6 +67,36 @@ class DiscussionAPIViewTestMixin(CommentsServiceMockMixin, UrlResetMixin): ...@@ -67,6 +67,36 @@ class DiscussionAPIViewTestMixin(CommentsServiceMockMixin, UrlResetMixin):
) )
class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseView"""
def setUp(self):
super(CourseViewTest, self).setUp()
self.url = reverse("discussion_course", kwargs={"course_id": unicode(self.course.id)})
def test_404(self):
response = self.client.get(
reverse("course_topics", kwargs={"course_id": "non/existent/course"})
)
self.assert_response_correct(
response,
404,
{"developer_message": "Not found."}
)
def test_get_success(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
200,
{
"id": unicode(self.course.id),
"blackouts": [],
"thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz",
"topics_url": "http://testserver/api/discussion/v1/course_topics/x/y/z",
}
)
class CourseTopicsViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): class CourseTopicsViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseTopicsView""" """Tests for CourseTopicsView"""
def setUp(self): def setUp(self):
......
...@@ -6,7 +6,7 @@ from django.conf.urls import include, patterns, url ...@@ -6,7 +6,7 @@ from django.conf.urls import include, patterns, url
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from discussion_api.views import CommentViewSet, CourseTopicsView, ThreadViewSet from discussion_api.views import CommentViewSet, CourseTopicsView, CourseView, ThreadViewSet
ROUTER = SimpleRouter() ROUTER = SimpleRouter()
...@@ -16,6 +16,11 @@ ROUTER.register("comments", CommentViewSet, base_name="comment") ...@@ -16,6 +16,11 @@ ROUTER.register("comments", CommentViewSet, base_name="comment")
urlpatterns = patterns( urlpatterns = patterns(
"discussion_api", "discussion_api",
url( url(
r"^v1/courses/{}".format(settings.COURSE_ID_PATTERN),
CourseView.as_view(),
name="discussion_course"
),
url(
r"^v1/course_topics/{}".format(settings.COURSE_ID_PATTERN), r"^v1/course_topics/{}".format(settings.COURSE_ID_PATTERN),
CourseTopicsView.as_view(), CourseTopicsView.as_view(),
name="course_topics" name="course_topics"
......
...@@ -9,7 +9,7 @@ from rest_framework.response import Response ...@@ -9,7 +9,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.keys import CourseKey
from discussion_api.api import ( from discussion_api.api import (
create_comment, create_comment,
...@@ -17,6 +17,7 @@ from discussion_api.api import ( ...@@ -17,6 +17,7 @@ from discussion_api.api import (
delete_thread, delete_thread,
delete_comment, delete_comment,
get_comment_list, get_comment_list,
get_course,
get_course_topics, get_course_topics,
get_thread_list, get_thread_list,
update_comment, update_comment,
...@@ -35,6 +36,38 @@ class _ViewMixin(object): ...@@ -35,6 +36,38 @@ class _ViewMixin(object):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
class CourseView(_ViewMixin, DeveloperErrorViewMixin, APIView):
"""
**Use Cases**
Retrieve general discussion metadata for a course.
**Example Requests**:
GET /api/discussion/v1/courses/course-v1:ExampleX+Subject101+2015
**Response Values**:
* id: The identifier of the course
* blackouts: A list of objects representing blackout periods (during
which discussions are read-only except for privileged users). Each
item in the list includes:
* start: The ISO 8601 timestamp for the start of the blackout period
* end: The ISO 8601 timestamp for the end of the blackout period
* thread_list_url: The URL of the list of all threads in the course.
* topics_url: The URL of the topic listing for the course.
"""
def get(self, request, course_id):
"""Implements the GET method as described in the class docstring."""
course_key = CourseKey.from_string(course_id) # TODO: which class is right?
return Response(get_course(request, course_key))
class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView): class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView):
""" """
**Use Cases** **Use Cases**
...@@ -44,7 +77,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView): ...@@ -44,7 +77,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView):
**Example Requests**: **Example Requests**:
GET /api/discussion/v1/course_topics/{course_id} GET /api/discussion/v1/course_topics/course-v1:ExampleX+Subject101+2015
**Response Values**: **Response Values**:
...@@ -63,7 +96,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView): ...@@ -63,7 +96,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView):
""" """
def get(self, request, course_id): def get(self, request, course_id):
"""Implements the GET method as described in the class docstring.""" """Implements the GET method as described in the class docstring."""
course_key = CourseLocator.from_string(course_id) course_key = CourseKey.from_string(course_id)
return Response(get_course_topics(request, course_key)) return Response(get_course_topics(request, course_key))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment