Merge pull request #28675 from edx/AA-905
Populate the course goals user activity table when a user visits course-specific pages
This commit is contained in:
@@ -13,6 +13,8 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -309,6 +311,10 @@ class BlocksInCourseView(BlocksView):
|
||||
response = super().list(request, course_usage_key,
|
||||
hide_access_denials=hide_access_denials)
|
||||
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True)
|
||||
|
||||
calculate_completion = any('completion' in param
|
||||
for param in request.query_params.getlist('requested_fields', []))
|
||||
if not calculate_completion:
|
||||
|
||||
@@ -3,14 +3,22 @@ Course Goals Models
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from edx_django_utils.cache import TieredCache
|
||||
from model_utils import Choices
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
from lms.djangoapps.courseware.masquerade import is_masquerading
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
|
||||
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
|
||||
|
||||
# Each goal is represented by a goal key and a string description.
|
||||
GOAL_KEY_CHOICES = Choices(
|
||||
('certify', _('Earn a certificate')),
|
||||
@@ -20,6 +28,7 @@ GOAL_KEY_CHOICES = Choices(
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseGoal(models.Model):
|
||||
@@ -84,3 +93,64 @@ class UserActivity(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
course_key = CourseKeyField(max_length=255)
|
||||
date = models.DateField()
|
||||
|
||||
@classmethod
|
||||
def record_user_activity(cls, user, course_key, request=None, only_if_mobile_app=False):
|
||||
'''
|
||||
Update the user activity table with a record for this activity.
|
||||
|
||||
Since we store one activity per date, we don't need to query the database
|
||||
for every activity on a given date.
|
||||
To avoid unnecessary queries, we store a record in a cache once we have an activity for the date,
|
||||
which times out at the end of that date (in the user's timezone).
|
||||
|
||||
The request argument is only used to check if the request is coming from a mobile app.
|
||||
Once the only_if_mobile_app argument is removed the request argument can be removed as well.
|
||||
|
||||
The return value is the id of the object that was created, or retrieved.
|
||||
A return value of None signifies that there was an issue with the parameters (or the user was masquerading).
|
||||
'''
|
||||
if not (user and user.id) or not course_key:
|
||||
return None
|
||||
|
||||
if only_if_mobile_app and request and not is_request_from_mobile_app(request):
|
||||
return None
|
||||
|
||||
if is_masquerading(user, course_key):
|
||||
return None
|
||||
|
||||
user_preferences = get_user_preferences(user)
|
||||
timezone = pytz.timezone(user_preferences.get('time_zone', 'UTC'))
|
||||
now = datetime.now(timezone)
|
||||
date = now.date()
|
||||
|
||||
cache_key = 'goals_user_activity_{}_{}_{}'.format(str(user.id), str(course_key), str(date))
|
||||
|
||||
cached_value = TieredCache.get_cached_response(cache_key)
|
||||
if cached_value.is_found:
|
||||
# Temporary debugging log for testing mobile app connection
|
||||
if request:
|
||||
log.info(
|
||||
'Retrieved cached value with request {} for user and course combination {} {}'.format(
|
||||
str(request.build_absolute_uri()), str(user.id), str(course_key)
|
||||
)
|
||||
)
|
||||
return cached_value.value, False
|
||||
|
||||
activity_object, __ = cls.objects.get_or_create(user=user, course_key=course_key, date=date)
|
||||
|
||||
# Cache result until the end of the day to avoid unnecessary database requests
|
||||
tomorrow = now + timedelta(days=1)
|
||||
midnight = datetime(year=tomorrow.year, month=tomorrow.month,
|
||||
day=tomorrow.day, hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
seconds_until_midnight = (midnight - now).seconds
|
||||
|
||||
TieredCache.set_all_tiers(cache_key, activity_object.id, seconds_until_midnight)
|
||||
# Temporary debugging log for testing mobile app connection
|
||||
if request:
|
||||
log.info(
|
||||
'Set cached value with request {} for user and course combination {} {}'.format(
|
||||
str(request.build_absolute_uri()), str(user.id), str(course_key)
|
||||
)
|
||||
)
|
||||
return activity_object.id
|
||||
|
||||
205
lms/djangoapps/course_goals/tests/test_user_activity.py
Normal file
205
lms/djangoapps/course_goals/tests/test_user_activity.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Unit tests for user activity methods.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
import ddt
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from edx_django_utils.cache import TieredCache
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from freezegun import freeze_time
|
||||
from mock import patch
|
||||
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.util.testing import UrlResetMixin
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from openedx.core.djangoapps.django_comment_common.models import ForumsConfig
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_waffle_flag(RECORD_USER_ACTIVITY_FLAG, active=True)
|
||||
class UserActivityTests(UrlResetMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Testing Course Goals User Activity
|
||||
"""
|
||||
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create(
|
||||
start=datetime(2020, 1, 1),
|
||||
end=datetime(2028, 1, 1),
|
||||
enrollment_start=datetime(2020, 1, 1),
|
||||
enrollment_end=datetime(2028, 1, 1),
|
||||
emit_signals=True,
|
||||
modulestore=self.store,
|
||||
discussion_topics={"Test Topic": {"id": "test_topic"}},
|
||||
)
|
||||
chapter = ItemFactory(parent=self.course, category='chapter')
|
||||
ItemFactory(parent=chapter, category='sequential')
|
||||
|
||||
self.client.login(username=self.user.username, password=self.user_password)
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
|
||||
self.request = RequestFactory().get('foo')
|
||||
self.request.user = self.user
|
||||
|
||||
config = ForumsConfig.current()
|
||||
config.enabled = True
|
||||
config.save()
|
||||
|
||||
def test_mfe_tabs_call_user_activity(self):
|
||||
'''
|
||||
New style tabs call one of two metadata endpoints
|
||||
These in turn call get_course_tab_list, which records user activity
|
||||
'''
|
||||
url = reverse('course-home:course-metadata', args=[self.course.id])
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
self.client.get(url)
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
url = f'/api/courseware/course/{self.course.id}'
|
||||
self.client.get(url)
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
def test_non_mfe_tabs_call_user_activity(self):
|
||||
'''
|
||||
Tabs that are not yet part of the learning microfrontend all include the course_navigation.html file
|
||||
This file calls the get_course_tab_list function, which records user activity
|
||||
'''
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
render_to_response('courseware/course_navigation.html', {'course': self.course, 'request': self.request})
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
def test_when_record_user_activity_does_not_perform_updates(self):
|
||||
'''
|
||||
Ensure that record user activity is not called when:
|
||||
1. user or course are not defined
|
||||
2. we have already recorded user activity for this user/course on this date
|
||||
and have a record in the cache
|
||||
'''
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(self.user, None)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
UserActivity.record_user_activity(None, self.course.id)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
cache_key = 'goals_user_activity_{}_{}_{}'.format(
|
||||
str(self.user.id), str(self.course.id), str(datetime.now().date())
|
||||
)
|
||||
TieredCache.set_all_tiers(cache_key, 'test', 3600)
|
||||
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
# Test that the happy path works to ensure that the measurement in this test isn't broken
|
||||
user2 = UserFactory()
|
||||
UserActivity.record_user_activity(user2, self.course.id)
|
||||
activity_cache_set.assert_called_once()
|
||||
|
||||
def test_that_user_activity_cache_works_properly(self):
|
||||
'''
|
||||
Ensure that the cache for user activity works properly
|
||||
1. user or course are not defined
|
||||
2. we have already recorded user activity for this user/course on this date
|
||||
and have a record in the cache
|
||||
'''
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_called_once()
|
||||
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
now_plus_1_day = datetime.now() + timedelta(days=1)
|
||||
with freeze_time(now_plus_1_day):
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_called_once()
|
||||
|
||||
def test_mobile_argument(self):
|
||||
'''
|
||||
Method only records activity if the request is coming from the mobile app
|
||||
when the only_if_mobile_app argument is true
|
||||
'''
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(
|
||||
self.user, self.course.id, request=self.request, only_if_mobile_app=True
|
||||
)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
with patch('lms.djangoapps.course_goals.models.is_request_from_mobile_app', return_value=True):
|
||||
UserActivity.record_user_activity(
|
||||
self.user, self.course.id, request=self.request, only_if_mobile_app=True
|
||||
)
|
||||
activity_cache_set.assert_called_once()
|
||||
|
||||
def test_masquerading(self):
|
||||
'''
|
||||
Method only records activity if the user is not masquerading
|
||||
'''
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_called_once()
|
||||
|
||||
with patch.object(TieredCache, 'set_all_tiers', wraps=TieredCache.set_all_tiers) as activity_cache_set:
|
||||
with patch('lms.djangoapps.course_goals.models.is_masquerading', return_value=True):
|
||||
UserActivity.record_user_activity(self.user, self.course.id)
|
||||
activity_cache_set.assert_not_called()
|
||||
|
||||
@ddt.data(
|
||||
'/api/course_home/v1/dates/{COURSE_ID}',
|
||||
'/api/mobile/v0.5/course_info/{COURSE_ID}/handouts',
|
||||
'/api/mobile/v0.5/course_info/{COURSE_ID}/updates',
|
||||
'/api/course_experience/v1/course_deadlines_info/{COURSE_ID}',
|
||||
'/api/course_home/v1/dates/{COURSE_ID}',
|
||||
'/api/courseware/course/{COURSE_ID}',
|
||||
'/api/discussion/v1/courses/{COURSE_ID}/',
|
||||
'/api/discussion/v1/course_topics/{COURSE_ID}',
|
||||
)
|
||||
def test_mobile_app_user_activity_calls(self, url):
|
||||
url = url.replace('{COURSE_ID}', str(self.course.id))
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
with patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}):
|
||||
self.client.get(url)
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
def test_mobile_app_user_activity_other_calls(self):
|
||||
# thread view call
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
try:
|
||||
self.client.get(reverse("thread-list"), {'course_id': str(self.course.id)})
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
# blocks call
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
url = '/api/courses/v2/blocks/'
|
||||
self.client.get(url, {'course_id': str(self.course.id), 'username': self.user.username})
|
||||
record_user_activity_mock.assert_called_once()
|
||||
|
||||
# xblock call
|
||||
with patch.object(UserActivity, 'record_user_activity') as record_user_activity_mock:
|
||||
url = '/xblock/' + str(self.course.scope_ids.usage_id)
|
||||
try:
|
||||
self.client.get(url)
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
record_user_activity_mock.assert_called_once()
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Unit tests for course_goals.api methods.
|
||||
Unit tests for course_goals.views methods.
|
||||
"""
|
||||
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
@@ -19,8 +18,6 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
EVENT_NAME_ADDED = 'edx.course.goal.added'
|
||||
EVENT_NAME_UPDATED = 'edx.course.goal.updated'
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestCourseGoalsAPI(SharedModuleStoreTestCase):
|
||||
"""
|
||||
@@ -19,3 +19,14 @@ WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='course_goals')
|
||||
# .. toggle_target_removal_date: 2021-09-01
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-859
|
||||
COURSE_GOALS_NUMBER_OF_DAYS_GOALS = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'number_of_days_goals', __name__)
|
||||
|
||||
# .. toggle_name: course_goals.record_user_activity
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables populating user activity for tracking a user's progress towards course goals
|
||||
# .. toggle_warnings: None
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2021-09-16
|
||||
# .. toggle_target_removal_date: 2021-11-16
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-905
|
||||
RECORD_USER_ACTIVITY_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'record_user_activity', __name__)
|
||||
|
||||
@@ -13,6 +13,8 @@ from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from lms.djangoapps.course_api.api import course_detail
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
|
||||
@@ -114,6 +116,10 @@ class CourseHomeMetadataView(RetrieveAPIView):
|
||||
request.user, enrollment, course, user_timezone if not None else browser_timezone
|
||||
)
|
||||
|
||||
# Record course goals user activity for (web) learning mfe course tabs
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
UserActivity.record_user_activity(request.user, course_key)
|
||||
|
||||
data = {
|
||||
'course_id': course.id,
|
||||
'username': username,
|
||||
|
||||
@@ -12,6 +12,8 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.course_home_api.dates.serializers import DatesTabSerializer
|
||||
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
@@ -93,6 +95,10 @@ class DatesTabView(RetrieveAPIView):
|
||||
reset_masquerade_data=True,
|
||||
)
|
||||
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(request.user, course.id, request=request, only_if_mobile_app=True)
|
||||
|
||||
if not CourseEnrollment.is_enrolled(request.user, course_key) and not is_staff:
|
||||
return Response('User not enrolled.', status=401)
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
|
||||
from lms.djangoapps.certificates import api as certs_api
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.course_home_api.toggles import (
|
||||
course_home_legacy_is_active,
|
||||
course_home_mfe_progress_tab_is_active
|
||||
@@ -1730,6 +1732,12 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
|
||||
staff_access,
|
||||
)
|
||||
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(
|
||||
request.user, usage_key.course_key, request=request, only_if_mobile_app=True
|
||||
)
|
||||
|
||||
# get the block, which verifies whether the user has access to the block.
|
||||
recheck_access = request.GET.get('recheck_access') == '1'
|
||||
block, _ = get_module_by_usage_id(
|
||||
|
||||
@@ -18,6 +18,8 @@ from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.instructor.access import update_forum_role
|
||||
from openedx.core.djangoapps.django_comment_common import comment_client
|
||||
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
|
||||
@@ -90,6 +92,9 @@ class CourseView(DeveloperErrorViewMixin, APIView):
|
||||
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?
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True)
|
||||
return Response(get_course(request, course_key))
|
||||
|
||||
|
||||
@@ -132,6 +137,9 @@ class CourseTopicsView(DeveloperErrorViewMixin, APIView):
|
||||
course_key,
|
||||
set(topic_ids.strip(',').split(',')) if topic_ids else None,
|
||||
)
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True)
|
||||
return Response(response)
|
||||
|
||||
|
||||
@@ -322,6 +330,13 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet):
|
||||
form = ThreadListGetForm(request.GET)
|
||||
if not form.is_valid():
|
||||
raise ValidationError(form.errors)
|
||||
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(
|
||||
request.user, form.cleaned_data["course_id"], request=request, only_if_mobile_app=True
|
||||
)
|
||||
|
||||
return get_thread_list(
|
||||
request,
|
||||
form.cleaned_data["course_id"],
|
||||
|
||||
@@ -5,13 +5,21 @@ Tests for course_info
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin
|
||||
from lms.djangoapps.mobile_api.utils import API_V1, API_V05
|
||||
from xmodule.html_module import CourseInfoBlock
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.xml_importer import import_course_from_xml
|
||||
|
||||
|
||||
@@ -219,3 +227,54 @@ class TestHandouts(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTes
|
||||
self.course.mobile_available = True
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
self.login_and_enroll()
|
||||
|
||||
|
||||
@override_waffle_flag(RECORD_USER_ACTIVITY_FLAG, active=True)
|
||||
class TestCourseGoalsUserActivityAPI(MobileAPITestCase, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Testing the Course Goals User Activity API.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.apiUrl = reverse('record_user_activity', args=['v1'])
|
||||
self.login_and_enroll()
|
||||
|
||||
def test_record_activity(self):
|
||||
'''
|
||||
Test the happy path of recording user activity
|
||||
'''
|
||||
post_data = {
|
||||
'course_key': self.course.id,
|
||||
'user_id': self.user.id,
|
||||
}
|
||||
|
||||
response = self.client.post(self.apiUrl, post_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_invalid_parameters(self):
|
||||
'''
|
||||
Ensure that we check that parameters meet the requirements
|
||||
and return a 400 otherwise.
|
||||
'''
|
||||
post_data = {
|
||||
'course_key': self.course.id,
|
||||
}
|
||||
|
||||
response = self.client.post(self.apiUrl, post_data)
|
||||
assert response.status_code == 400
|
||||
|
||||
post_data = {
|
||||
'user_id': self.user.id,
|
||||
}
|
||||
|
||||
response = self.client.post(self.apiUrl, post_data)
|
||||
assert response.status_code == 400
|
||||
|
||||
post_data = {
|
||||
'user_id': self.user.id,
|
||||
'course_key': 'invalidcoursekey',
|
||||
}
|
||||
|
||||
response = self.client.post(self.apiUrl, post_data)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -6,7 +6,7 @@ URLs for course_info API
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import CourseHandoutsList, CourseUpdatesList
|
||||
from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
@@ -19,4 +19,5 @@ urlpatterns = [
|
||||
CourseUpdatesList.as_view(),
|
||||
name='course-updates-list'
|
||||
),
|
||||
url(r'^record_user_activity$', CourseGoalsRecordUserActivity.as_view(), name='record_user_activity'),
|
||||
]
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
Views for course info API
|
||||
"""
|
||||
|
||||
|
||||
from rest_framework import generics
|
||||
from django.contrib.auth import get_user_model
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.static_replace import make_static_urls_absolute
|
||||
from lms.djangoapps.courseware.courses import get_course_info_section_module
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from openedx.core.lib.xblock_utils import get_course_update_items
|
||||
|
||||
from ..decorators import mobile_course_access, mobile_view
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@mobile_view()
|
||||
class CourseUpdatesList(generics.ListAPIView):
|
||||
@@ -107,3 +113,46 @@ def apply_wrappers_to_content(content, module, request):
|
||||
content = module.system.replace_jump_to_id_urls(content)
|
||||
|
||||
return make_static_urls_absolute(request, content)
|
||||
|
||||
|
||||
@mobile_view()
|
||||
class CourseGoalsRecordUserActivity(APIView):
|
||||
"""
|
||||
API that allows the mobile_apps to record activity for course goals to the user activity table
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handle the POST request
|
||||
|
||||
Populate the user activity table.
|
||||
"""
|
||||
user_id = request.data.get('user_id')
|
||||
course_key = request.data.get('course_key')
|
||||
|
||||
if not user_id or not course_key:
|
||||
return Response(
|
||||
'User id and course key are required',
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
'Provided user id does not correspond to an existing user',
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_key)
|
||||
except InvalidKeyError:
|
||||
return Response(
|
||||
'Provided course key is not valid',
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Populate user activity for tracking progress towards a user's course goals
|
||||
UserActivity.record_user_activity(user, course_key)
|
||||
return Response(status=(200))
|
||||
|
||||
@@ -10,6 +10,8 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.courseware.courses import get_course_with_access
|
||||
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
|
||||
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
|
||||
@@ -41,6 +43,11 @@ def mobile_course_access(depth=0):
|
||||
depth=depth,
|
||||
check_if_enrolled=True,
|
||||
)
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(
|
||||
request.user, course_id, request=request, only_if_mobile_app=True
|
||||
)
|
||||
except CoursewareAccessException as error:
|
||||
return Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND)
|
||||
except CourseAccessRedirect as error:
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<%!
|
||||
from lms.djangoapps.courseware.masquerade import is_masquerading_as_student
|
||||
from lms.djangoapps.courseware.tabs import get_course_tab_list
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -39,6 +42,10 @@ masquerading_as_student = is_masquerading_as_student(request.user, course.id)
|
||||
% if disable_tabs is UNDEFINED or not disable_tabs:
|
||||
<%
|
||||
tab_list = get_course_tab_list(request.user, course)
|
||||
|
||||
# Record course goals user activity for (web) courseware and course tabs that are outside of the learning mfe
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
UserActivity.record_user_activity(user, course.id)
|
||||
%>
|
||||
% if uses_bootstrap:
|
||||
<nav class="navbar course-tabs pb-0 navbar-expand" aria-label="${_('Course')}">
|
||||
|
||||
@@ -23,6 +23,8 @@ from lms.djangoapps.edxnotes.helpers import is_feature_enabled
|
||||
from lms.djangoapps.certificates.api import get_certificate_url
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from lms.djangoapps.course_api.api import course_detail
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.access_response import (
|
||||
CoursewareMicrofrontendDisabledAccessError,
|
||||
@@ -458,11 +460,15 @@ class CoursewareInformation(RetrieveAPIView):
|
||||
username = self.request.GET.get('username', '') or self.request.user.username
|
||||
else:
|
||||
username = self.request.user.username
|
||||
course_key = CourseKey.from_string(self.kwargs['course_key_string'])
|
||||
overview = CoursewareMeta(
|
||||
CourseKey.from_string(self.kwargs['course_key_string']),
|
||||
course_key,
|
||||
self.request,
|
||||
username=username,
|
||||
)
|
||||
# Record course goals user activity for learning mfe courseware on web
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
UserActivity.record_user_activity(self.request.user, course_key)
|
||||
|
||||
return overview
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from lms.djangoapps.course_api.api import course_detail
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_goals.toggles import RECORD_USER_ACTIVITY_FLAG
|
||||
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.courses import get_course_with_access
|
||||
@@ -152,5 +154,11 @@ class CourseDeadlinesMobileView(RetrieveAPIView):
|
||||
# Although this course data is not used this method will return 404 if course does not exist
|
||||
get_course_with_access(request.user, 'load', course_key)
|
||||
|
||||
if RECORD_USER_ACTIVITY_FLAG.is_enabled():
|
||||
# Record user activity for tracking progress towards a user's course goals (for mobile app)
|
||||
UserActivity.record_user_activity(
|
||||
request.user, course_key, request=request, only_if_mobile_app=True
|
||||
)
|
||||
|
||||
serializer = self.get_serializer({})
|
||||
return Response(serializer.data)
|
||||
|
||||
Reference in New Issue
Block a user