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):
"""
return date_time + u" UTC"
@property
def forum_posts_allowed(self):
def get_discussion_blackout_datetimes(self):
"""
Get a list of dicts with start and end fields with datetime values from
the discussion_blackouts setting
"""
date_proxy = Date()
try:
blackout_periods = [(date_proxy.from_json(start),
date_proxy.from_json(end))
for start, end
in filter(None, self.discussion_blackouts)]
now = datetime.now(UTC())
for start, end in blackout_periods:
if start <= now <= end:
return False
except:
log.exception("Error parsing discussion_blackouts %s for course %s", self.discussion_blackouts, self.id)
ret = [
{"start": date_proxy.from_json(start), "end": date_proxy.from_json(end)}
for start, end
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())
for blackout in blackouts:
if blackout["start"] <= now <= blackout["end"]:
return False
return True
@property
......
......@@ -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
"""
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), "")))
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):
"""
Return the course topic listing for the given course and user.
......
......@@ -26,6 +26,7 @@ from discussion_api.api import (
delete_comment,
delete_thread,
get_comment_list,
get_course,
get_course_topics,
get_thread_list,
update_comment,
......@@ -63,6 +64,72 @@ def _remove_discussion_tab(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})
class GetCourseTopicsTest(UrlResetMixin, ModuleStoreTestCase):
"""Test for get_course_topics"""
......
......@@ -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):
"""Tests for CourseTopicsView"""
def setUp(self):
......
......@@ -6,7 +6,7 @@ from django.conf.urls import include, patterns, url
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()
......@@ -16,6 +16,11 @@ ROUTER.register("comments", CommentViewSet, base_name="comment")
urlpatterns = patterns(
"discussion_api",
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),
CourseTopicsView.as_view(),
name="course_topics"
......
......@@ -9,7 +9,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
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 (
create_comment,
......@@ -17,6 +17,7 @@ from discussion_api.api import (
delete_thread,
delete_comment,
get_comment_list,
get_course,
get_course_topics,
get_thread_list,
update_comment,
......@@ -35,6 +36,38 @@ class _ViewMixin(object):
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):
"""
**Use Cases**
......@@ -44,7 +77,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView):
**Example Requests**:
GET /api/discussion/v1/course_topics/{course_id}
GET /api/discussion/v1/course_topics/course-v1:ExampleX+Subject101+2015
**Response Values**:
......@@ -63,7 +96,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView):
"""
def get(self, request, course_id):
"""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))
......
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