Skip to content

Commit 926f45d

Browse files
committed
Learner metrics endpoint - Initial commit
This is the initial commit so Matej and work on the front end The endpoint is `/figures/api/learner-metrics/` * There is a basic viewset just to exercise the code. The test requires test data to be filled out and tested in the response * UserFilterSet needs to be updated or an alternate filter set needs to be used in order to provide more filtering, in particular * Show only users who have enrollments * Show only users who do not have enrollments * Show only users who have completed * Show only users who have not completed * 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 926f45d

File tree

5 files changed

+326
-2
lines changed

5 files changed

+326
-2
lines changed

figures/serializers.py

+79
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
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
2425
from django_countries import Countries
2526
from rest_framework import serializers
27+
from rest_framework.fields import empty
2628

2729
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview # noqa pylint: disable=import-error
2830
from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer # noqa pylint: disable=import-error
@@ -791,6 +793,10 @@ class CourseMauLiveMetricsSerializer(serializers.Serializer):
791793

792794
class EnrollmentMetricsSerializer(serializers.ModelSerializer):
793795
"""Serializer for LearnerCourseGradeMetrics
796+
797+
This is a prototype serializer for exploring API endpoints
798+
799+
It provides an enrollment major, use minor view
794800
"""
795801
user = UserIndexSerializer(read_only=True)
796802
progress_percent = serializers.DecimalField(max_digits=3,
@@ -808,5 +814,78 @@ class Meta:
808814

809815

810816
class CourseCompletedSerializer(serializers.Serializer):
817+
"""Provides course id and user id for course completions
818+
819+
This serializer is used in the `enrollment-metrics` endpoint
820+
"""
811821
course_id = serializers.CharField()
812822
user_id = serializers.IntegerField()
823+
824+
825+
class EnrollmentMetricsSerializerV2(serializers.ModelSerializer):
826+
"""Provides serialization for an enrollment
827+
828+
This serializer note not identify the learner. It is used in
829+
LearnerMetricsSerializer
830+
"""
831+
course_id = serializers.CharField()
832+
date_enrolled = serializers.DateTimeField(source='created',
833+
format="%Y-%m-%d")
834+
is_enrolled = serializers.BooleanField()
835+
progress_percent = serializers.SerializerMethodField()
836+
progress_details = serializers.SerializerMethodField()
837+
838+
def __init__(self, instance=None, data=empty, **kwargs):
839+
self._lcgm = None
840+
super(EnrollmentMetricsSerializerV2, self).__init__(
841+
instance=None, data=empty, **kwargs)
842+
843+
class Meta:
844+
model = CourseEnrollment
845+
fields = ['id', 'course_id', 'date_enrolled', 'is_enrolled',
846+
'progress_percent', 'progress_details']
847+
read_only_fields = fields
848+
849+
def to_representation(self, instance):
850+
"""
851+
Get the most recent LCGM record for the enrollment, if it exists
852+
"""
853+
self._lcgm = LearnerCourseGradeMetrics.objects.most_recent_for_learner_course(
854+
user=instance.user, course_id=str(instance.course_id))
855+
return super(EnrollmentMetricsSerializerV2, self).to_representation(instance)
856+
857+
def get_is_enrolled(self, obj):
858+
"""
859+
CourseEnrollment has to do some work to get this value
860+
TODO: inspect CourseEnrollment._get_enrollment_state to see how we
861+
can speed this up, avoiding construction of `CourseEnrollmentState`
862+
"""
863+
return CourseEnrollment.is_enrolled(obj.user, obj.course_id)
864+
865+
def get_progress_percent(self, obj): # pylint: disable=unused-argument
866+
value = self._lcgm.progress_percent if self._lcgm else 0
867+
return float(Decimal(value).quantize(Decimal('.00')))
868+
869+
def get_progress_details(self, obj): # pylint: disable=unused-argument
870+
"""Get progress data for a single enrollment
871+
"""
872+
return self._lcgm.progress_details if self._lcgm else None
873+
874+
875+
class LearnerMetricsSerializer(serializers.ModelSerializer):
876+
fullname = serializers.CharField(source='profile.name', default=None)
877+
# enrollments = EnrollmentMetricsSerializerV2(source='courseenrollment_set',
878+
# many=True)
879+
enrollments = serializers.SerializerMethodField()
880+
881+
class Meta:
882+
model = get_user_model()
883+
fields = ('id', 'username', 'email', 'fullname', 'is_active',
884+
'date_joined', 'enrollments')
885+
read_only_fields = fields
886+
887+
def get_enrollments(self, user):
888+
site_enrollments = figures.sites.get_course_enrollments_for_site(
889+
self.context.get('site'))
890+
user_enrollments = site_enrollments.filter(user=user)
891+
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)
+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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.sites import get_user_ids_for_site
11+
from figures.views import LearnerMetricsViewSet
12+
13+
from tests.factories import (
14+
CourseEnrollmentFactory,
15+
CourseOverviewFactory,
16+
# LearnerCourseGradeMetricsFactory,
17+
OrganizationFactory,
18+
SiteFactory,
19+
UserFactory,
20+
)
21+
22+
from tests.helpers import organizations_support_sites
23+
from tests.views.base import BaseViewTest
24+
from tests.views.helpers import is_response_paginated
25+
26+
if organizations_support_sites():
27+
from tests.factories import UserOrganizationMappingFactory
28+
29+
def map_users_to_org_site(caller, site, users):
30+
org = OrganizationFactory(sites=[site])
31+
UserOrganizationMappingFactory(user=caller,
32+
organization=org,
33+
is_amc_admin=True)
34+
[UserOrganizationMappingFactory(user=user,
35+
organization=org) for user in users]
36+
# return created objects that the test will need
37+
return caller
38+
39+
40+
@pytest.fixture
41+
def enrollment_test_data():
42+
"""Stands up shared test data. We need to revisit this
43+
"""
44+
num_courses = 2
45+
site = SiteFactory()
46+
course_overviews = [CourseOverviewFactory() for i in range(num_courses)]
47+
# Create a number of enrollments for each course
48+
enrollments = []
49+
for num_enroll, co in enumerate(course_overviews, 1):
50+
enrollments += [CourseEnrollmentFactory(
51+
course_id=co.id) for i in range(num_enroll)]
52+
53+
# This is a convenience for the test method
54+
users = [enrollment.user for enrollment in enrollments]
55+
return dict(
56+
site=site,
57+
course_overviews=course_overviews,
58+
enrollments=enrollments,
59+
users=users,
60+
)
61+
62+
63+
@pytest.mark.django_db
64+
class TestLearnerMetricsViewSet(BaseViewTest):
65+
"""Tests the learner metrics viewset
66+
67+
The tests are incomplete
68+
69+
The list action will return a list of the following records:
70+
71+
```
72+
{
73+
"id": 109,
74+
"username": "chasecynthia",
75+
"email": "[email protected]",
76+
"fullname": "Brandon Meyers",
77+
"is_active": true,
78+
"date_joined": "2020-06-03T00:00:00Z",
79+
"enrollments": [
80+
{
81+
"id": 9,
82+
"course_id": "course-v1:StarFleetAcademy+SFA01+2161",
83+
"date_enrolled": "2020-02-24",
84+
"is_enrolled": true,
85+
"progress_percent": 1.0,
86+
"progress_details": {
87+
"sections_worked": 20,
88+
"points_possible": 100.0,
89+
"sections_possible": 20,
90+
"points_earned": 50.0
91+
}
92+
}
93+
]
94+
}
95+
```
96+
"""
97+
base_request_path = 'api/learner-metrics/'
98+
view_class = LearnerMetricsViewSet
99+
100+
@pytest.fixture(autouse=True)
101+
def setup(self, db, settings):
102+
if organizations_support_sites():
103+
settings.FEATURES['FIGURES_IS_MULTISITE'] = True
104+
super(TestLearnerMetricsViewSet, self).setup(db)
105+
106+
def make_caller(self, site, users):
107+
"""Convenience method to create the API caller user
108+
"""
109+
if organizations_support_sites():
110+
# TODO: set is_staff to False after we have test coverage
111+
caller = UserFactory(is_staff=True)
112+
map_users_to_org_site(caller=caller, site=site, users=users)
113+
else:
114+
caller = UserFactory(is_staff=True)
115+
return caller
116+
117+
def make_request(self, monkeypatch, request_path, site, caller, action):
118+
"""Convenience method to make the API request
119+
120+
Returns the response object
121+
"""
122+
request = APIRequestFactory().get(request_path)
123+
request.META['HTTP_HOST'] = site.domain
124+
monkeypatch.setattr(django.contrib.sites.shortcuts,
125+
'get_current_site',
126+
lambda req: site)
127+
force_authenticate(request, user=caller)
128+
view = self.view_class.as_view({'get': action})
129+
return view(request)
130+
131+
def test_list_method_all(self, monkeypatch, enrollment_test_data):
132+
"""Partial test coverage to check we get all site users
133+
134+
Checks returned user ids against all user ids for the site
135+
Checks top level keys
136+
137+
Does NOT check values in the `enrollments` key. This should be done as
138+
follow up work
139+
"""
140+
site = enrollment_test_data['site']
141+
users = enrollment_test_data['users']
142+
enrollments = enrollment_test_data['enrollments']
143+
144+
caller = self.make_caller(site, users)
145+
other_site = SiteFactory()
146+
assert site.domain != other_site.domain
147+
148+
response = self.make_request(request_path=self.base_request_path,
149+
monkeypatch=monkeypatch,
150+
site=site,
151+
caller=caller,
152+
action='list')
153+
154+
assert response.status_code == status.HTTP_200_OK
155+
assert is_response_paginated(response.data)
156+
results = response.data['results']
157+
158+
# Check user ids
159+
result_ids = [obj['id'] for obj in results]
160+
user_ids = get_user_ids_for_site(site=site)
161+
assert set(result_ids) == set(user_ids)
162+
# Spot check the first record
163+
top_keys = ['id', 'username', 'email', 'fullname', 'is_active',
164+
'date_joined', 'enrollments']
165+
assert set(results[0].keys()) == set(top_keys)

0 commit comments

Comments
 (0)