Commit f0ca9978 by Clinton Blackburn

Merge pull request #51 from edx/course-problems

Added Endpoint for Course Problems
parents addc2a1f d5bc70ac
......@@ -39,14 +39,15 @@ class ModelSerializerWithCreatedField(serializers.ModelSerializer):
created = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
class ProblemSubmissionCountSerializer(serializers.Serializer):
class ProblemSerializer(serializers.Serializer):
"""
Serializer for problem submission counts.
Serializer for problems.
"""
module_id = serializers.CharField()
total = serializers.IntegerField(default=0)
correct = serializers.IntegerField(default=0)
module_id = serializers.CharField(required=True)
total_submissions = serializers.IntegerField(default=0)
correct_submissions = serializers.IntegerField(default=0)
part_ids = serializers.CharField()
class ProblemResponseAnswerDistributionSerializer(ModelSerializerWithCreatedField):
......
......@@ -580,3 +580,60 @@ class CourseActivityWeeklyViewTests(CourseViewTestCaseMixin, TestCaseWithAuthent
expected = self.format_as_response(*self.model.objects.all())
self.assertEqual(len(expected), 2)
self.assertIntervalFilteringWorks(expected, self.interval_start, interval_end + datetime.timedelta(days=1))
class CourseProblemsListViewTests(DemoCourseMixin, TestCaseWithAuthentication):
def _get_data(self, course_id=None):
"""
Retrieve data for the specified course.
"""
course_id = course_id or self.course_id
url = '/api/v0/courses/{}/problems/'.format(course_id)
return self.authenticated_get(url)
def test_get(self):
"""
The view should return data when data exists for the course.
"""
# This data should never be returned by the tests below because the course_id doesn't match.
G(models.ProblemResponseAnswerDistribution)
# This test assumes the view is using Python's groupby for grouping. Create multiple objects here to test the
# grouping. Add a model with a different module_id to break up the natural order and ensure the view properly
# sorts the objects before grouping.
module_id = 'i4x://test/problem/1'
alt_module_id = 'i4x://test/problem/2'
o1 = G(models.ProblemResponseAnswerDistribution, course_id=self.course_id, module_id=module_id, correct=True,
count=100)
o2 = G(models.ProblemResponseAnswerDistribution, course_id=self.course_id, module_id=alt_module_id,
correct=True, count=100)
o3 = G(models.ProblemResponseAnswerDistribution, course_id=self.course_id, module_id=module_id, correct=False,
count=200)
expected = [
{
'module_id': module_id,
'total_submissions': 300,
'correct_submissions': 100,
'part_ids': [o1.part_id, o3.part_id]
},
{
'module_id': alt_module_id,
'total_submissions': 100,
'correct_submissions': 100,
'part_ids': [o2.part_id]
}
]
response = self._get_data(self.course_id)
self.assertEquals(response.status_code, 200)
self.assertListEqual(response.data, expected)
def test_get_404(self):
"""
The view should return 404 if no data exists for the course.
"""
response = self._get_data('foo/bar/course')
self.assertEquals(response.status_code, 404)
......@@ -10,7 +10,6 @@ from django_dynamic_fixture import G
from analytics_data_api.v0 import models
from analytics_data_api.v0.serializers import ProblemResponseAnswerDistributionSerializer, \
GradeDistributionSerializer, SequentialOpenDistributionSerializer
from analytics_data_api.v0.tests.views import DemoCourseMixin
from analyticsdataserver.tests import TestCaseWithAuthentication
......@@ -98,68 +97,3 @@ class SequentialOpenDistributionTests(TestCaseWithAuthentication):
def test_get_404(self):
response = self.authenticated_get('/api/v0/problems/%s%s' % ("DOES-NOT-EXIST", self.path))
self.assertEquals(response.status_code, 404)
class SubmissionCountsListViewTests(DemoCourseMixin, TestCaseWithAuthentication):
path = '/api/v0/problems/submission_counts/'
@classmethod
def setUpClass(cls):
super(SubmissionCountsListViewTests, cls).setUpClass()
cls.ad_1 = G(models.ProblemResponseAnswerDistribution)
cls.ad_2 = G(models.ProblemResponseAnswerDistribution)
def _get_data(self, problem_ids=None):
"""
Retrieve data for the specified problems from the server.
"""
url = self.path
if problem_ids:
problem_ids = ','.join(problem_ids)
url = '{}?problem_ids={}'.format(url, problem_ids)
return self.authenticated_get(url)
def assertValidResponse(self, *problem_ids):
expected_data = []
for problem_id in problem_ids:
_models = models.ProblemResponseAnswerDistribution.objects.filter(module_id=problem_id)
serialized = [{'module_id': model.module_id, 'total': model.count, 'correct': model.correct or 0} for model
in _models]
expected_data += serialized
response = self._get_data(problem_ids)
self.assertEquals(response.status_code, 200)
actual = response.data
self.assertListEqual(actual, expected_data)
def test_get(self):
"""
The view should return data when data exists for at least one of the problems.
"""
problem_id_1 = self.ad_1.module_id
problem_id_2 = self.ad_2.module_id
self.assertValidResponse(problem_id_1)
self.assertValidResponse(problem_id_1, problem_id_2)
self.assertValidResponse(problem_id_1, problem_id_2, 'DOES-NOT-EXIST')
def test_get_404(self):
"""
The view should return 404 if data does not exist for at least one of the provided problems.
"""
problem_ids = ['DOES-NOT-EXIST']
response = self._get_data(problem_ids)
self.assertEquals(response.status_code, 404)
def test_get_406(self):
"""
The view should return a 406 if no problem ID values are supplied.
"""
response = self._get_data()
self.assertEquals(response.status_code, 406)
......@@ -12,6 +12,7 @@ COURSE_URLS = [
('enrollment/education', views.CourseEnrollmentByEducationView, 'enrollment_by_education'),
('enrollment/gender', views.CourseEnrollmentByGenderView, 'enrollment_by_gender'),
('enrollment/location', views.CourseEnrollmentByLocationView, 'enrollment_by_location'),
('problems', views.ProblemsListView, 'problems')
]
urlpatterns = []
......
......@@ -11,7 +11,6 @@ PROBLEM_URLS = [
urlpatterns = patterns(
'',
url(r'^submission_counts/$', views.SubmissionCountsListView.as_view(), name='submission_counts'),
url(r'^(?P<module_id>.+)/sequential_open_distribution/$',
views.SequentialOpenDistributionView.as_view(), name='sequential_open_distribution'),
)
......
......@@ -9,8 +9,8 @@ from django.http import Http404
from django.utils.timezone import make_aware, utc
from rest_framework import generics
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers
......@@ -608,3 +608,55 @@ class CourseEnrollmentByLocationView(BaseCourseEnrollmentView):
# acceptable since the consuming code simply expects the returned
# value to be iterable, not necessarily a queryset.
return returned_items
class ProblemsListView(BaseCourseView):
"""
Get the problems.
**Example request**
GET /api/v0/courses/{course_id}/problems/
**Response Values**
Returns a collection of submission counts and part IDs for each problem. Each collection contains:
* module_id: The ID of the problem.
* total_submissions: Total number of submissions
* correct_submissions: Total number of *correct* submissions.
* part_ids: List of problem part IDs
"""
model = models.ProblemResponseAnswerDistribution
serializer_class = serializers.ProblemSerializer
def apply_date_filtering(self, queryset):
# Date filtering is not possible for this data.
return queryset
def get_queryset(self):
queryset = super(ProblemsListView, self).get_queryset()
queryset = queryset.order_by('module_id', 'part_id')
data = []
for problem_id, distribution in groupby(queryset, lambda x: x.module_id):
total = 0
correct = 0
part_ids = set() # Use a set to remove duplicate values.
for answer in distribution:
part_ids.add(answer.part_id)
count = answer.count
total += count
if answer.correct:
correct += count
data.append({
'module_id': problem_id,
'total_submissions': total,
'correct_submissions': correct,
'part_ids': sorted(part_ids)
})
return data
from itertools import groupby
from rest_framework import generics
from rest_framework.exceptions import NotAcceptable
from analytics_data_api.v0.models import ProblemResponseAnswerDistribution
from analytics_data_api.v0.serializers import ProblemResponseAnswerDistributionSerializer, \
ProblemSubmissionCountSerializer
from analytics_data_api.v0.serializers import ProblemResponseAnswerDistributionSerializer
from analytics_data_api.v0.models import GradeDistribution
from analytics_data_api.v0.serializers import GradeDistributionSerializer
from analytics_data_api.v0.models import SequentialOpenDistribution
from analytics_data_api.v0.serializers import SequentialOpenDistributionSerializer
class SubmissionCountsListView(generics.ListAPIView):
"""
Get the number of submissions to one, or more, problems.
**Example request**
GET /api/v0/problems/submission_counts/?problem_ids={problem_id},{problem_id}
**Response Values**
Returns a collection of counts of total and correct solutions to the specified
problems. Each collection contains:
* module_id: The ID of the problem.
* total: Total number of submissions
* correct: Total number of *correct* submissions.
**Parameters**
problem_ids -- Comma-separated list of problem IDs representing the problems whose data should be returned.
"""
serializer_class = ProblemSubmissionCountSerializer
allow_empty = False
def get_queryset(self):
problem_ids = self.request.QUERY_PARAMS.get('problem_ids', '')
if not problem_ids:
raise NotAcceptable
problem_ids = problem_ids.split(',')
queryset = ProblemResponseAnswerDistribution.objects.filter(module_id__in=problem_ids).order_by('module_id')
data = []
for problem_id, distribution in groupby(queryset, lambda x: x.module_id):
total = 0
correct = 0
for answer in distribution:
count = answer.count
total += count
if answer.correct:
correct += count
data.append({
'module_id': problem_id,
'total': total,
'correct': correct
})
return data
class ProblemResponseAnswerDistributionView(generics.ListAPIView):
"""
Get the distribution of student answers to a specific problem.
......
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