feat: [AXM-200] Implement user's enrolments status API (#2530)
* feat: [AXM-24] Update structure for course enrollments API (#2515)
* feat: [AXM-24] Update structure for course enrollments API
* style: [AXM-24] Improve code style
* fix: [AXM-24] Fix student's latest enrollment filter
* feat: [AXM-47] Add course_status field to primary object (#2517)
* feat: [AXM-40] add courses progress to enrollment endpoint (#2519)
* fix: workaround for staticcollection introduced in e40a01c
* feat: [AXM-40] add courses progress to enrollment endpoint
* refactor: [AXM-40] add caching to improve performance
* refactor: [AXM-40] add progress only for primary course
* refactor: [AXM-40] refactor enrollment caching optimization
---------
Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
* feat: [AXM-53] add assertions for primary course (#2522)
* feat: [AXM-53] add assertions for primary course
* test: [AXM-53] fix tests
* style: [AXM-53] change future_assignment default value to None
* refactor: [AXM-53] add some optimization for assignments collecting
* feat: [AXM-200] Implement user's enrolments status API
* style: [AXM-200] Improve code style
* refactor: [AXM-200] Divide get method into smaller methods
---------
Co-authored-by: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com>
Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs
|
||||
|
||||
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
|
||||
@@ -768,3 +769,137 @@ class TestDiscussionCourseEnrollmentSerializer(UrlResetMixin, MobileAPITestCase,
|
||||
assert isinstance(discussion_url, str)
|
||||
else:
|
||||
assert discussion_url is None
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class UserEnrollmentsStatus(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, 'is_active': True},
|
||||
{'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'is_active': True},
|
||||
{'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'is_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, 'is_active': True},
|
||||
{'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'is_active': True},
|
||||
{'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'is_active': True},
|
||||
{'course_id': str(old_course.course_id), 'course_name': old_course.display_name, 'is_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, is_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, 'is_active': is_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, is_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, 'is_active': is_active_status}
|
||||
]
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertListEqual(response.data, expected_response)
|
||||
|
||||
@@ -6,7 +6,7 @@ URLs for user API
|
||||
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'),
|
||||
@@ -17,5 +17,8 @@ urlpatterns = [
|
||||
),
|
||||
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')
|
||||
]
|
||||
|
||||
@@ -3,9 +3,13 @@ Views for user API
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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
|
||||
@@ -410,3 +414,128 @@ def my_user_info(request, api_version):
|
||||
# updating it from the oauth2 related code is too complex
|
||||
user_logged_in.send(sender=User, user=request.user, request=request)
|
||||
return redirect("user-detail", api_version=api_version, username=request.user.username)
|
||||
|
||||
|
||||
class UserCourseEnrollmentsV4Pagination(DefaultPagination):
|
||||
"""
|
||||
Pagination for `UserCourseEnrollments` API v4.
|
||||
"""
|
||||
page_size = 5
|
||||
max_page_size = 50
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
**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.
|
||||
* is_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",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"course_id": "course-v1:b+b+b",
|
||||
"course_name": "b",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"course_id": "course-v1:c+c+c",
|
||||
"course_name": "c",
|
||||
"is_active": false
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
"""
|
||||
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: List[str],
|
||||
) -> List[Dict[str, bool]]:
|
||||
"""
|
||||
Builds list with dictionaries with user's enrolments statuses.
|
||||
"""
|
||||
user_enrollments = CourseEnrollment.objects.filter(
|
||||
user__username=username,
|
||||
is_active=True,
|
||||
)
|
||||
mobile_available = [
|
||||
enrollment for enrollment in user_enrollments
|
||||
if is_mobile_available_for_user(self.request.user, enrollment.course_overview)
|
||||
]
|
||||
enrollments_status = []
|
||||
for user_enrollment in mobile_available:
|
||||
course_id = str(user_enrollment.course_overview.id)
|
||||
enrollments_status.append(
|
||||
{
|
||||
'course_id': course_id,
|
||||
'course_name': user_enrollment.course_overview.display_name,
|
||||
'is_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,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Gets course ids where user has completions.
|
||||
"""
|
||||
user_completions_last_month = BlockCompletion.objects.filter(
|
||||
user__username=username,
|
||||
created__gte=active_status_date
|
||||
)
|
||||
return [str(completion.block_key.course_key) for completion in user_completions_last_month]
|
||||
|
||||
Reference in New Issue
Block a user