Skip to content

Commit a95e234

Browse files
committed
Learner metrics endpoint - Initial commit
This is the intial commit so Matej and work on the front end * There is a basic viewset just to exercise the code. The test requires test data to be filled out and tested in the response * List serializers need to be added to prefetch data to improve API performance * test_learner_metrics_viewset needs to be completed * Updated the CourseEnrollment mock to provide the `is_enrolled` method
1 parent 33e61a3 commit a95e234

File tree

5 files changed

+311
-2
lines changed

5 files changed

+311
-2
lines changed

figures/serializers.py

+73
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919

2020
import datetime
21+
from decimal import Decimal
2122

2223
from django.contrib.auth import get_user_model
2324
from django.contrib.sites.models import Site
@@ -791,6 +792,10 @@ class CourseMauLiveMetricsSerializer(serializers.Serializer):
791792

792793
class EnrollmentMetricsSerializer(serializers.ModelSerializer):
793794
"""Serializer for LearnerCourseGradeMetrics
795+
796+
This is a prototype serializer for exploring API endpoints
797+
798+
It provides an enrollment major, use minor view
794799
"""
795800
user = UserIndexSerializer(read_only=True)
796801
progress_percent = serializers.DecimalField(max_digits=3,
@@ -808,5 +813,73 @@ class Meta:
808813

809814

810815
class CourseCompletedSerializer(serializers.Serializer):
816+
"""Provides course id and user id for course completions
817+
818+
This serializer is used in the `enrollment-metrics` endpoint
819+
"""
811820
course_id = serializers.CharField()
812821
user_id = serializers.IntegerField()
822+
823+
824+
class EnrollmentMetricsSerializerV2(serializers.ModelSerializer):
825+
"""Provides serialization for an enrollment
826+
827+
This serializer note not identify the learner. It is used in
828+
LearnerMetricsSerializer
829+
"""
830+
course_id = serializers.CharField()
831+
date_enrolled = serializers.DateTimeField(source='created',
832+
format="%Y-%m-%d")
833+
is_enrolled = serializers.BooleanField()
834+
progress_percent = serializers.SerializerMethodField()
835+
progress_details = serializers.SerializerMethodField()
836+
837+
class Meta:
838+
model = CourseEnrollment
839+
fields = ['id', 'course_id', 'date_enrolled', 'is_enrolled',
840+
'progress_percent', 'progress_details']
841+
read_only_fields = fields
842+
843+
def to_representation(self, instance):
844+
"""
845+
Get the most recent LCGM record for the enrollment, if it exists
846+
"""
847+
self._lcgm = LearnerCourseGradeMetrics.objects.most_recent_for_learner_course(
848+
user=instance.user, course_id=str(instance.course_id))
849+
return super(EnrollmentMetricsSerializerV2, self).to_representation(instance)
850+
851+
def get_is_enrolled(self, obj):
852+
"""
853+
CourseEnrollment has to do some work to get this value
854+
TODO: inspect CourseEnrollment._get_enrollment_state to see how we
855+
can speed this up, avoiding construction of `CourseEnrollmentState`
856+
"""
857+
return CourseEnrollment.is_enrolled(obj.user, obj.course_id)
858+
859+
def get_progress_percent(self, obj):
860+
value = self._lcgm.progress_percent if self._lcgm else 0
861+
return float(Decimal(value).quantize(Decimal('.00')))
862+
863+
def get_progress_details(self, obj):
864+
"""Get progress data for a single enrollment
865+
"""
866+
return self._lcgm.progress_details if self._lcgm else None
867+
868+
869+
class LearnerMetricsSerializer(serializers.ModelSerializer):
870+
fullname = serializers.CharField(source='profile.name', default=None)
871+
# enrollments = EnrollmentMetricsSerializerV2(source='courseenrollment_set',
872+
# many=True)
873+
enrollments = serializers.SerializerMethodField()
874+
875+
class Meta:
876+
model = get_user_model()
877+
fields = ('id', 'username', 'email', 'fullname', 'is_active',
878+
'date_joined', 'enrollments')
879+
read_only_fields = fields
880+
881+
def get_enrollments(self, user):
882+
site_enrollments = figures.sites.get_course_enrollments_for_site(
883+
self.context.get('site'))
884+
user_enrollments = site_enrollments.filter(user=user)
885+
return EnrollmentMetricsSerializerV2(user_enrollments, many=True).data

figures/urls.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,21 @@
100100
views.UserIndexViewSet,
101101
base_name='user-index')
102102

103-
# Experimental
103+
104+
# New endpoints in development (unstable)
105+
# Unstable here means the code is subject to change without notice
104106

105107
router.register(
106108
r'enrollment-metrics',
107109
views.EnrollmentMetricsViewSet,
108110
base_name='enrollment-metrics')
109111

112+
router.register(
113+
r'learner-metrics',
114+
views.LearnerMetricsViewSet,
115+
base_name='learner-metrics')
116+
117+
110118
urlpatterns = [
111119

112120
# UI Templates

figures/views.py

+30
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
EnrollmentMetricsSerializer,
6565
GeneralCourseDataSerializer,
6666
LearnerDetailsSerializer,
67+
LearnerMetricsSerializer,
6768
SiteDailyMetricsSerializer,
6869
SiteMauMetricsSerializer,
6970
SiteMauLiveMetricsSerializer,
@@ -398,6 +399,35 @@ def get_serializer_context(self):
398399
return context
399400

400401

402+
class LearnerMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet):
403+
"""Provides user identity and nested enrollment data
404+
405+
This view is unders active development and subject to change
406+
407+
TODO: After we get this class tests running, restructure this module:
408+
* Group all user model based viewsets together
409+
* Make a base user viewset with the `get_queryset` and `get_serializer_context`
410+
methods
411+
"""
412+
model = get_user_model()
413+
pagination_class = FiguresLimitOffsetPagination
414+
serializer_class = LearnerMetricsSerializer
415+
filter_backends = (DjangoFilterBackend, )
416+
417+
# TODO: Improve this filter
418+
filter_class = UserFilterSet
419+
420+
def get_queryset(self):
421+
site = django.contrib.sites.shortcuts.get_current_site(self.request)
422+
queryset = figures.sites.get_users_for_site(site)
423+
return queryset
424+
425+
def get_serializer_context(self):
426+
context = super(LearnerMetricsViewSet, self).get_serializer_context()
427+
context['site'] = django.contrib.sites.shortcuts.get_current_site(self.request)
428+
return context
429+
430+
401431
class EnrollmentMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet):
402432
"""Initial viewset for enrollment metrics
403433

mocks/hawthorn/student/models.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
from collections import defaultdict
2+
from collections import defaultdict, namedtuple
33
from datetime import datetime
44

55
from pytz import UTC
@@ -129,6 +129,12 @@ def enrollment_counts(self, course_id):
129129
return enroll_dict
130130

131131

132+
# Named tuple for fields pertaining to the state of
133+
# CourseEnrollment for a user in a course. This type
134+
# is used to cache the state in the request cache.
135+
CourseEnrollmentState = namedtuple('CourseEnrollmentState', 'mode, is_active')
136+
137+
132138
class CourseEnrollment(models.Model):
133139
'''
134140
The production model is student.models.CourseEnrollment
@@ -191,6 +197,42 @@ def __init__(self, *args, **kwargs):
191197
# When the property .course_overview is accessed for the first time, this variable will be set.
192198
self._course_overview = None
193199

200+
@classmethod
201+
def is_enrolled(cls, user, course_key):
202+
"""
203+
Returns True if the user is enrolled in the course (the entry must exist
204+
and it must have `is_active=True`). Otherwise, returns False.
205+
206+
`user` is a Django User object. If it hasn't been saved yet (no `.id`
207+
attribute), this method will automatically save it before
208+
adding an enrollment for it.
209+
210+
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
211+
"""
212+
enrollment_state = cls._get_enrollment_state(user, course_key)
213+
return enrollment_state.is_active or False
214+
215+
@classmethod
216+
def _get_enrollment_state(cls, user, course_key):
217+
"""
218+
Returns the CourseEnrollmentState for the given user
219+
and course_key, caching the result for later retrieval.
220+
221+
Figures note: removed the caching after copying this method
222+
"""
223+
assert user
224+
225+
if user.is_anonymous:
226+
return CourseEnrollmentState(None, None)
227+
228+
try:
229+
record = cls.objects.get(user=user, course_id=course_key)
230+
enrollment_state = CourseEnrollmentState(record.mode, record.is_active)
231+
except cls.DoesNotExist:
232+
enrollment_state = CourseEnrollmentState(None, None)
233+
234+
return enrollment_state
235+
194236

195237
class CourseAccessRole(models.Model):
196238
user = models.ForeignKey(User)
+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Tests Figures learner-metrics viewset
2+
"""
3+
4+
import pytest
5+
6+
import django.contrib.sites.shortcuts
7+
from rest_framework import status
8+
from rest_framework.test import APIRequestFactory, force_authenticate
9+
10+
from figures.views import LearnerMetricsViewSet
11+
12+
from tests.factories import (
13+
CourseEnrollmentFactory,
14+
CourseOverviewFactory,
15+
# LearnerCourseGradeMetricsFactory,
16+
OrganizationFactory,
17+
SiteFactory,
18+
UserFactory,
19+
)
20+
21+
from tests.helpers import organizations_support_sites
22+
from tests.views.base import BaseViewTest
23+
from tests.views.helpers import is_response_paginated
24+
25+
if organizations_support_sites():
26+
from tests.factories import UserOrganizationMappingFactory
27+
28+
def map_users_to_org_site(caller, site, users):
29+
org = OrganizationFactory(sites=[site])
30+
UserOrganizationMappingFactory(user=caller,
31+
organization=org,
32+
is_amc_admin=True)
33+
[UserOrganizationMappingFactory(user=user,
34+
organization=org) for user in users]
35+
# return created objects that the test will need
36+
return caller
37+
38+
39+
@pytest.fixture
40+
def enrollment_test_data():
41+
"""Stands up shared test data. We need to revisit this
42+
"""
43+
num_courses = 2
44+
site = SiteFactory()
45+
course_overviews = [CourseOverviewFactory() for i in range(num_courses)]
46+
# Create a number of enrollments for each course
47+
enrollments = []
48+
for num_enroll, co in enumerate(course_overviews, 1):
49+
enrollments += [CourseEnrollmentFactory(
50+
course_id=co.id) for i in range(num_enroll)]
51+
52+
# This is a convenience for the test method
53+
users = [enrollment.user for enrollment in enrollments]
54+
return dict(
55+
site=site,
56+
course_overviews=course_overviews,
57+
enrollments=enrollments,
58+
users=users,
59+
)
60+
61+
62+
@pytest.mark.django_db
63+
class TestLearnerMetricsViewSet(BaseViewTest):
64+
"""Tests the learner metrics viewset
65+
66+
The tests are incomplete
67+
68+
The list action will return a list of the following records:
69+
70+
```
71+
{
72+
"id": 109,
73+
"username": "chasecynthia",
74+
"email": "[email protected]",
75+
"fullname": "Brandon Meyers",
76+
"is_active": true,
77+
"date_joined": "2020-06-03T00:00:00Z",
78+
"enrollments": [
79+
{
80+
"id": 9,
81+
"course_id": "course-v1:StarFleetAcademy+SFA01+2161",
82+
"date_enrolled": "2020-02-24",
83+
"is_enrolled": true,
84+
"progress_percent": 1.0,
85+
"progress_details": {
86+
"sections_worked": 20,
87+
"points_possible": 100.0,
88+
"sections_possible": 20,
89+
"points_earned": 50.0
90+
}
91+
}
92+
]
93+
}
94+
```
95+
"""
96+
base_request_path = 'api/learner-metrics/'
97+
view_class = LearnerMetricsViewSet
98+
99+
@pytest.fixture(autouse=True)
100+
def setup(self, db, settings):
101+
if organizations_support_sites():
102+
settings.FEATURES['FIGURES_IS_MULTISITE'] = True
103+
super(TestLearnerMetricsViewSet, self).setup(db)
104+
105+
def make_caller(self, site, users):
106+
"""Convenience method to create the API caller user
107+
"""
108+
if organizations_support_sites():
109+
# TODO: set is_staff to False after we have test coverage
110+
caller = UserFactory(is_staff=True)
111+
map_users_to_org_site(caller=caller, site=site, users=users)
112+
else:
113+
caller = UserFactory(is_staff=True)
114+
return caller
115+
116+
def make_request(self, monkeypatch, request_path, site, caller, action):
117+
"""Convenience method to make the API request
118+
119+
Returns the response object
120+
"""
121+
request = APIRequestFactory().get(request_path)
122+
request.META['HTTP_HOST'] = site.domain
123+
monkeypatch.setattr(django.contrib.sites.shortcuts,
124+
'get_current_site',
125+
lambda req: site)
126+
force_authenticate(request, user=caller)
127+
view = self.view_class.as_view({'get': action})
128+
return view(request)
129+
130+
def test_list_method_all(self, monkeypatch, enrollment_test_data):
131+
"""INCOMPLETE TEST
132+
"""
133+
site = enrollment_test_data['site']
134+
users = enrollment_test_data['users']
135+
enrollments = enrollment_test_data['enrollments']
136+
137+
caller = self.make_caller(site, users)
138+
other_site = SiteFactory()
139+
assert site.domain != other_site.domain
140+
141+
response = self.make_request(request_path=self.base_request_path,
142+
monkeypatch=monkeypatch,
143+
site=site,
144+
caller=caller,
145+
action='list')
146+
147+
assert response.status_code == status.HTTP_200_OK
148+
assert is_response_paginated(response.data)
149+
results = response.data['results']
150+
# Check user ids
151+
result_ids = [obj['id'] for obj in results]
152+
assert set(result_ids) == set([obj.user_id for obj in enrollments]+[caller.id])
153+
# Spot check the first record
154+
top_keys = ['id', 'username', 'email', 'fullname', 'is_active',
155+
'date_joined', 'enrollments']
156+
assert set(results[0].keys()) == set(top_keys)

0 commit comments

Comments
 (0)