Skip to content

Commit

Permalink
Merge pull request openedx#34859 from raccoongang/kireiev/AXM-549/fea…
Browse files Browse the repository at this point in the history
…t/upstream_PR_active_inactive_courses_API

feat: [FC-0047] Implement user's enrolments status API (#2530)
  • Loading branch information
cmltaWt0 authored Nov 1, 2024
2 parents d6e0528 + 9494ee5 commit bd22449
Show file tree
Hide file tree
Showing 3 changed files with 280 additions and 3 deletions.
143 changes: 143 additions & 0 deletions lms/djangoapps/mobile_api/users/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import ddt
import pytz
from completion.models import BlockCompletion
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
from django.conf import settings
from django.db import transaction
Expand Down Expand Up @@ -1381,3 +1382,145 @@ def test_discussion_tab_url(self, discussion_tab_enabled):
assert isinstance(discussion_url, str)
else:
assert discussion_url is None


@ddt.ddt
class TestUserEnrollmentsStatus(MobileAPITestCase, MobileAuthUserTestMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>/enrollments_status/
"""

REVERSE_INFO = {'name': 'user-enrollments-status', 'params': ['username', 'api_version']}

def test_no_mobile_available_courses(self) -> None:
self.login()
courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)]
for course in courses:
self.enroll(course.id)

response = self.api_response(api_version=API_V1)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, [])

def test_no_enrollments(self) -> None:
self.login()
for _ in range(3):
CourseFactory.create(org="edx", mobile_available=True)

response = self.api_response(api_version=API_V1)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, [])

def test_user_have_only_active_enrollments_and_no_completions(self) -> None:
self.login()
courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)]
for course in courses:
self.enroll(course.id)

response = self.api_response(api_version=API_V1)

expected_response = [
{'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'recently_active': True},
{'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'recently_active': True},
{'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'recently_active': True},
]

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, expected_response)

def test_user_have_active_and_inactive_enrollments_and_no_completions(self) -> None:
self.login()
courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)]
for course in courses:
self.enroll(course.id)
old_course = CourseFactory.create(org="edx", mobile_available=True)
self.enroll(old_course.id)
old_enrollment = CourseEnrollment.objects.filter(user=self.user, course=old_course.course_id).first()
old_enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=31)
old_enrollment.save()

response = self.api_response(api_version=API_V1)

expected_response = [
{'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'recently_active': True},
{'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'recently_active': True},
{'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'recently_active': True},
{'course_id': str(old_course.course_id), 'course_name': old_course.display_name, 'recently_active': False}
]

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, expected_response)

@ddt.data(
(27, True),
(28, True),
(29, True),
(31, False),
(32, False),
)
@ddt.unpack
def test_different_enrollment_dates(self, enrolled_days_ago: int, recently_active_status: bool) -> None:
self.login()
course = CourseFactory.create(org="edx", mobile_available=True, run='1001')
self.enroll(course.id)
enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first()
enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=enrolled_days_ago)
enrollment.save()

response = self.api_response(api_version=API_V1)

expected_response = [
{
'course_id': str(course.course_id),
'course_name': course.display_name,
'recently_active': recently_active_status
}
]

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, expected_response)

@ddt.data(
(27, True),
(28, True),
(29, True),
(31, False),
(32, False),
)
@ddt.unpack
def test_different_completion_dates(self, completed_days_ago: int, recently_active_status: bool) -> None:
self.login()
course = CourseFactory.create(org="edx", mobile_available=True, run='1010')
section = BlockFactory.create(
parent=course,
category='chapter',
)
self.enroll(course.id)
enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first()
# make enrollment older 30 days ago
enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=50)
enrollment.save()
completion = BlockCompletion.objects.create(
user=self.user,
context_key=course.context_key,
block_type='course',
block_key=section.location,
completion=0.5,
)
completion.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=completed_days_ago)
completion.save()

response = self.api_response(api_version=API_V1)

expected_response = [
{
'course_id': str(course.course_id),
'course_name': course.display_name,
'recently_active': recently_active_status
}
]

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, expected_response)
7 changes: 5 additions & 2 deletions lms/djangoapps/mobile_api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.conf import settings
from django.urls import re_path

from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail
from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail, UserEnrollmentsStatus

urlpatterns = [
re_path('^' + settings.USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'),
Expand All @@ -17,5 +17,8 @@
),
re_path(f'^{settings.USERNAME_PATTERN}/course_status_info/{settings.COURSE_ID_PATTERN}',
UserCourseStatus.as_view(),
name='user-course-status')
name='user-course-status'),
re_path(f'^{settings.USERNAME_PATTERN}/enrollments_status/',
UserEnrollmentsStatus.as_view(),
name='user-enrollments-status')
]
133 changes: 132 additions & 1 deletion lms/djangoapps/mobile_api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"""


import datetime
import logging
from functools import cached_property
from typing import Optional
from typing import Dict, List, Optional, Set

import pytz
from completion.exceptions import UnavailableCompletionData
from completion.models import BlockCompletion
from completion.utilities import get_key_to_last_completed_block
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.auth.signals import user_logged_in
Expand All @@ -17,6 +20,7 @@
from django.utils.decorators import method_decorator
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import CourseLocator
from rest_framework import generics, views
from rest_framework.decorators import api_view
from rest_framework.permissions import SAFE_METHODS
Expand Down Expand Up @@ -530,6 +534,133 @@ def my_user_info(request, api_version):
return redirect("user-detail", api_version=api_version, username=request.user.username)


@mobile_view(is_user=True)
class UserEnrollmentsStatus(views.APIView):
"""
**Use Case**
Get information about user's enrolments status.
Returns active enrolment status if user was enrolled for the course
less than 30 days ago or has progressed in the course in the last 30 days.
Otherwise, the registration is considered inactive.
USER_ENROLLMENTS_LIMIT - adds users enrollments query limit to
safe API from possible DDOS attacks.
**Example Request**
GET /api/mobile/{api_version}/users/<user_name>/enrollments_status/
**Response Values**
If the request for information about the user's enrolments is successful, the
request returns an HTTP 200 "OK" response.
The HTTP 200 response has the following values.
* course_id (str): The course id associated with the user's enrollment.
* course_name (str): The course name associated with the user's enrollment.
* recently_active (bool): User's course enrolment status.
The HTTP 200 response contains a list of dictionaries that contain info
about each user's enrolment status.
**Example Response**
```json
[
{
"course_id": "course-v1:a+a+a",
"course_name": "a",
"recently_active": true
},
{
"course_id": "course-v1:b+b+b",
"course_name": "b",
"recently_active": true
},
{
"course_id": "course-v1:c+c+c",
"course_name": "c",
"recently_active": false
},
...
]
```
"""

USER_ENROLLMENTS_LIMIT = 500

def get(self, request, *args, **kwargs) -> Response:
"""
Gets user's enrollments status.
"""
active_status_date = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=30)
username = kwargs.get('username')
course_ids_where_user_has_completions = self._get_course_ids_where_user_has_completions(
username,
active_status_date,
)
enrollments_status = self._build_enrollments_status_dict(
username,
active_status_date,
course_ids_where_user_has_completions
)
return Response(enrollments_status)

def _build_enrollments_status_dict(
self,
username: str,
active_status_date: datetime,
course_ids: Set[CourseLocator],
) -> List[Dict[str, bool]]:
"""
Builds list with dictionaries with user's enrolments statuses.
"""
user = get_object_or_404(User, username=username)
user_enrollments = (
CourseEnrollment
.enrollments_for_user(user)
.select_related('course')
[:self.USER_ENROLLMENTS_LIMIT]
)
mobile_available = [
enrollment for enrollment in user_enrollments
if is_mobile_available_for_user(user, enrollment.course_overview)
]
enrollments_status = []
for user_enrollment in mobile_available:
course_id = user_enrollment.course_overview.id
enrollments_status.append(
{
'course_id': str(course_id),
'course_name': user_enrollment.course_overview.display_name,
'recently_active': bool(
course_id in course_ids
or user_enrollment.created > active_status_date
)
}
)
return enrollments_status

@staticmethod
def _get_course_ids_where_user_has_completions(
username: str,
active_status_date: datetime,
) -> Set[CourseLocator]:
"""
Gets course keys where user has completions.
"""
context_keys = BlockCompletion.objects.filter(
user__username=username,
created__gte=active_status_date
).values_list('context_key', flat=True).distinct()

return set(context_keys)


class UserCourseEnrollmentsV4Pagination(DefaultPagination):
"""
Pagination for `UserCourseEnrollments` API v4.
Expand Down

0 comments on commit bd22449

Please sign in to comment.