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:
Kyrylo Kireiev
2024-04-08 13:03:33 +03:00
parent 2adee016aa
commit 0d0503a716
3 changed files with 269 additions and 2 deletions

View File

@@ -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)

View File

@@ -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')
]

View File

@@ -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]