Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [FC-0047] Extend mobile API with course progress and primary courses on dashboard view #34848

Conversation

KyryloKireiev
Copy link
Contributor

@KyryloKireiev KyryloKireiev commented May 23, 2024

Description

Two APIs was updated: UserCourseEnrollmentsList and BlocksInfoInCourseView:

  1. UserCourseEnrollmentsList - In v4 we added to the response primary object. Primary object contains the latest user's enrollment or course where user has the latest progress. Primary object has been cut from user's enrolments array and inserted into separated section with key primary.
    Also we add to Primary enrollment object additional fields:
    * course_progress: Contains information about how many assignments are in the course
    and how many assignments the student has completed.
    * total_assignments_count: Total course's assignments count.
    * assignments_completed: Assignments witch the student has completed.
    We added ?requested_fields=course_progress query param to GET request:
    GET /api/mobile/v4/users/kyrylo/course_enrollments/?requested_fields=course_progress With using this query param we can get course_progress field for the each student enrollment.
    Also, for the 4th version of this API, we changed the pagination - now the API returns 5 elements per page. This is necessary to improve performance.
    Response example:
{
    "configs": {
        "iap_configs": {}
    },
    "user_timezone": "Europe/Kiev",
    "enrollments": {
        "next": null,
        "previous": null,
        "count": 2,
        "num_pages": 1,
        "current_page": 1,
        "start": 0,
        "results": [
            {
                "audit_access_expires": null,
                "created": "2024-05-06T15:48:23.943185Z",
                "mode": "audit",
                "is_active": true,
                "course": {
                    "id": "course-v1:assignments+assignments+assignments",
                    "name": "assignments",
                    "number": "assignments",
                    "org": "assignments",
                    "start": "2020-01-01T00:00:00Z",
                    "start_display": "Jan. 1, 2020",
                    "start_type": "timestamp",
                    "end": null,
                    "dynamic_upgrade_deadline": null,
                    "subscription_id": "course_MNXXK4TTMUWXMMJ2MFZXG2LHNZWWK3TUOMVWC43TNFTW43LFNZ2HGK3BONZWSZ3ONVSW45DT",
                    "courseware_access": {
                        "has_access": true,
                        "error_code": null,
                        "developer_message": null,
                        "user_message": null,
                        "additional_context_user_message": null,
                        "user_fragment": null
                    },
                    "media": {
                        "course_image": {
                            "uri": "/asset-v1:assignments+assignments+assignments+type@asset+block@15581993_5643241.jpg",
                            "name": "Course Image"
                        }
                    },
                    "course_image": "/asset-v1:assignments+assignments+assignments+type@asset+block@15581993_5643241.jpg",
                    "course_about": "http://local.edly.io:8000/courses/course-v1:assignments+assignments+assignments/about",
                    "course_sharing_utm_parameters": {
                        "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook",
                        "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter"
                    },
                    "course_updates": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:assignments+assignments+assignments/updates",
                    "course_handouts": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:assignments+assignments+assignments/handouts",
                    "discussion_url": null,
                    "video_outline": null,
                    "is_self_paced": false
                },
                "certificate": {},
                "course_modes": [
                    {
                        "slug": "audit",
                        "sku": null,
                        "android_sku": null,
                        "ios_sku": null,
                        "min_price": 0
                    }
                ],
                "course_progress": {
                    "total_assignments_count": 7,
                    "assignments_completed": 4
                }
            },
            {
                "audit_access_expires": null,
                "created": "2024-05-06T14:49:25.211198Z",
                "mode": "audit",
                "is_active": true,
                "course": {
                    "id": "course-v1:a+a+a",
                    "name": "a",
                    "number": "a",
                    "org": "a",
                    "start": "2030-01-01T00:00:00Z",
                    "start_display": null,
                    "start_type": "empty",
                    "end": null,
                    "dynamic_upgrade_deadline": null,
                    "subscription_id": "course_MNXXK4TTMUWXMMJ2MEVWCK3B",
                    "courseware_access": {
                        "has_access": true,
                        "error_code": null,
                        "developer_message": null,
                        "user_message": null,
                        "additional_context_user_message": null,
                        "user_fragment": null
                    },
                    "media": {
                        "course_image": {
                            "uri": "/asset-v1:a+a+a+type@asset+block@images_course_image.jpg",
                            "name": "Course Image"
                        }
                    },
                    "course_image": "/asset-v1:a+a+a+type@asset+block@images_course_image.jpg",
                    "course_about": "http://local.edly.io:8000/courses/course-v1:a+a+a/about",
                    "course_sharing_utm_parameters": {
                        "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook",
                        "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter"
                    },
                    "course_updates": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:a+a+a/updates",
                    "course_handouts": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:a+a+a/handouts",
                    "discussion_url": null,
                    "video_outline": null,
                    "is_self_paced": false
                },
                "certificate": {},
                "course_modes": [
                    {
                        "slug": "audit",
                        "sku": null,
                        "android_sku": null,
                        "ios_sku": null,
                        "min_price": 0
                    }
                ],
                "course_progress": {
                    "total_assignments_count": 0,
                    "assignments_completed": 0
                }
            }
        ]
    },
    "primary": {
        "audit_access_expires": null,
        "created": "2024-05-07T11:39:23.149565Z",
        "mode": "audit",
        "is_active": true,
        "course": {
            "id": "course-v1:b+b+b",
            "name": "b",
            "number": "b",
            "org": "b",
            "start": "2020-01-01T00:00:00Z",
            "start_display": "Jan. 1, 2020",
            "start_type": "timestamp",
            "end": null,
            "dynamic_upgrade_deadline": null,
            "subscription_id": "course_MNXXK4TTMUWXMMJ2MIVWEK3C",
            "courseware_access": {
                "has_access": true,
                "error_code": null,
                "developer_message": null,
                "user_message": null,
                "additional_context_user_message": null,
                "user_fragment": null
            },
            "media": {
                "course_image": {
                    "uri": "/asset-v1:b+b+b+type@asset+block@smile_742751.png",
                    "name": "Course Image"
                }
            },
            "course_image": "/asset-v1:b+b+b+type@asset+block@smile_742751.png",
            "course_about": "http://local.edly.io:8000/courses/course-v1:b+b+b/about",
            "course_sharing_utm_parameters": {
                "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook",
                "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter"
            },
            "course_updates": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:b+b+b/updates",
            "course_handouts": "http://local.edly.io:8000/api/mobile/v4/course_info/course-v1:b+b+b/handouts",
            "discussion_url": null,
            "video_outline": null,
            "is_self_paced": false
        },
        "certificate": {},
        "course_modes": [
            {
                "slug": "audit",
                "sku": null,
                "android_sku": null,
                "ios_sku": null,
                "min_price": 0
            }
        ],
        "course_status": null,
        "course_progress": {
            "total_assignments_count": 10,
            "assignments_completed": 2
        },
        "course_assignments": {
            "future_assignments": [
                {
                    "assignment_type": "Homework",
                    "complete": false,
                    "date": "2024-05-31T00:00:00Z",
                    "date_type": "assignment-due-date",
                    "description": "",
                    "learner_has_access": true,
                    "link": "http://local.edly.io:8000/courses/course-v1:b+b+b/jump_to/block-v1:b+b+b+type@sequential+block@47eef9493e5b4d5b93a512d9f7751ba8",
                    "link_text": "",
                    "title": "Subsection 1-1",
                    "extra_info": null,
                    "first_component_block_id": "block-v1:b+b+b+type@problem+block@b4c5b0fef70643a581bd34471abf45b9"
                }
            ],
            "past_assignments": [
                {
                    "assignment_type": "exam 3",
                    "complete": false,
                    "date": "2024-04-24T00:00:00Z",
                    "date_type": "assignment-due-date",
                    "description": "",
                    "learner_has_access": true,
                    "link": "http://local.edly.io:8000/courses/course-v1:import+import+import/jump_to/block-v1:import+import+import+type@sequential+block@fee71303296a4a8cbc9542473ae94bd2",
                    "link_text": "",
                    "title": "Subsection 3-3",
                    "extra_info": null,
                    "first_component_block_id": "block-v1:import+import+import+type@problem+block@15dbe4ae8ca841bdb0f95ef86766d815"
                },
                {
                    "assignment_type": "Exam 1",
                    "complete": true,
                    "date": "2024-04-10T00:00:00Z",
                    "date_type": "assignment-due-date",
                    "description": "",
                    "learner_has_access": true,
                    "link": "http://local.edly.io:8000/courses/course-v1:import+import+import/jump_to/block-v1:import+import+import+type@sequential+block@82493e74c526482b9a077df1ebb32d2d",
                    "link_text": "",
                    "title": "Subsection",
                    "extra_info": null,
                    "first_component_block_id": "block-v1:import+import+import+type@problem+block@d7a5187c42454f99b96f468e0d9c1f1a"
                }
             ]
        }
    }
}
  1. BlocksInfoInCourseView - we added to response 2 new fields:
  • assignment_progress:
            "assignment_progress": {
                "assignment_type": "one",
                "num_points_earned": 2.0,
                "num_points_possible": 3.0
            }
  • course_progress :
    "course_progress": {
        "total_assignments_count": 3,
        "assignments_completed": 2
    }

All new functionality was covered with unit tests.

Supporting information

https://openedx.atlassian.net/wiki/spaces/COMM/pages/3935928321/FC-0047+-+Mobile+Development+Phase+3

Testing instructions

pytest lms/djangoapps/mobile_api/tests - this command runs all tests related to mobile_api aplication. This command should be running in the LMS container.

Also, this API can be tested manual with Postman or another API client:

  1. The request must be executed by an authorized user. JwtAuthentication, BearerAuthentication, SessionAuthentication can be used;
  2. GET {{LMS}}/api/mobile/<API_version>/users/<username>/course_enrollments/;
  3. Additional fields can be requested only with API_version == v4;
  4. Also additional query params can be added to API request:
  • ?requested_fields=course_progress - returns course_progress for each user's enrollment.
  • ?status=in_progress - returns user's enrollments filtered by status. It can by next statuses:
    ALL = 'all'
    IN_PROGRESS = 'in_progress'
    COMPLETED = 'completed'
    EXPIRED = 'expired'
    
  • If ?status=in_progress query param is used - the response contains only filtered user's enrollments by status. And doesn't contain primary enrollment object.

Deadline

"None" if there's no rush, or provide a specific date or event (and reason) if there is one.

Other information

Related mobile PRs:

@KyryloKireiev KyryloKireiev requested a review from a team as a code owner May 23, 2024 16:34
@openedx-webhooks
Copy link

openedx-webhooks commented May 23, 2024

Thanks for the pull request, @KyryloKireiev! Please note that it may take us up to several weeks or months to complete a review and merge your PR.

Feel free to add as much of the following information to the ticket as you can:

  • supporting documentation
  • Open edX discussion forum threads
  • timeline information ("this must be merged by XX date", and why that is)
  • partner information ("this is a course on edx.org")
  • any other information that can help Product understand the context for the PR

All technical communication about the code itself will be done via the GitHub pull request interface. As a reminder, our process documentation is here.

Please let us know once your PR is ready for our review and all tests are green.

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label May 23, 2024
@KyryloKireiev KyryloKireiev changed the title feat: [FC-0047] Update UserCourseEnrollmentsList and BlocksInfoInCourseView APIs with new fields feat: [FC-0047] Extend mobile API with course progress and primary courses on dashboard view May 23, 2024
@KyryloKireiev KyryloKireiev force-pushed the kireiev/AXM-506/feat/update_UserCourseEnrollmentsList_and_BlocksInfoInCourseView_APIs branch from a959abe to 104b142 Compare May 27, 2024 10:46
@e0d
Copy link
Contributor

e0d commented May 28, 2024

I find the content of the API response to be confusing. I'll spend some more time to try to grok it better, but I have a couple of questions immediately.

  1. Why are the configs included in this response?
  2. If I understand the way primary works, I don't like it :) I don't think we should be changing the shape of the response data for convenience. Why wouldn't primary just be an attribute on the enrollment record?

Can you explain a bit more about why we need the primary indicator in the first place? I seems to combine to things, recency of registration and recency of activity, into one concept.

Given that it is hiding some pretty specific business logic behind the API. I'm not deeply familiar with the new mobile APIs, but this would seem to indicate we have a "backend-for-frontend" here, not really a general purpose API. Is that right?

@KyryloKireiev
Copy link
Contributor Author

KyryloKireiev commented May 31, 2024

Hi Edward! This explains some of the things you asked about.

  1. We took the primary_course out of the array so that pagination doesn’t apply to it. This way, we have access to the primary_course on every page. So, on each page, we have access to the attributes of the primary_course.
  2. The primary_course has a different structure and this object goes through a different serializer compared to the enrollments in the array. I think this is wrong because we will have an object in the array that is not similar to the other objects. Serializer for primary_course - CourseEnrollmentSerializerModifiedForPrimary contains expensive calculations. But this serializer used only API v4. That is, you can easily switch to another version of the API and not receive the primary_course object if there is no need for it.
  3. Sorting all courses by the last visit is too expensive, so it seems that getting 1 course is much more efficient.
  4. We have configs in the response, since we inherited from the previous version of this api. configs appeared in the API starting from version 2.

@kdmccormick kdmccormick self-assigned this Jun 13, 2024
@kdmccormick
Copy link
Member

Hi @KyryloKireiev , I can review this soon. In the meantime, could you help me understand the background of this PR better? I see https://openedx.atlassian.net/wiki/spaces/COMM/pages/3935928321/FC-0047+-+Mobile+Development+Phase+3, but that page is mostly blank.

@GlugovGrGlib
Copy link
Member

GlugovGrGlib commented Jun 13, 2024

Hi @kdmccormick thank you!
I see how this may be confusing when onboarding into context of FC-0047.
I believe the following references will allow you to understand the wider background.

  1. As Michelle commented on the FC page, the scope for this project consists of several mobile initiatives that are interdependent.
  1. Please refer to product document, outlining key requirements for Navigation improvements: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3781754910/Project+-+Streamlined+Course+Navigation+Sequences+FC-0046. Additionally you can find sibling documents with product requirements for 3 other initiatives included into FC-0047 in the same space.

  2. Also you can check these 4 mobile PRs to see the final results for implemented UI as well as demo of the functionality.
    The code in the PRs below is built on top of the API changes introduced in this PR.
    Android:
    feat: [FC-0047] Improved Dashboard Level Navigation openedx-app-android#308
    feat: [FC-0047] Course progress and collapsing sections openedx-app-android#323
    IOS:
    feat: [FC-0047] Improved Dashboard Level Navigation openedx-app-ios#434
    feat: [FC-0047] Course progress and collapsing sections openedx-app-ios#446

@GlugovGrGlib
Copy link
Member

Hi @kdmccormick would you be able to take a look this week?
Also this PR #34859 can be considered as a follow up to mobile API extensions

@GlugovGrGlib
Copy link
Member

Hey @jawad-khan If you could provide a review from your side as well, it would be really helpful. Thanks!

@kdmccormick
Copy link
Member

@GlugovGrGlib thanks for the context. Yes, I should be able to review this week.

@kdmccormick kdmccormick self-requested a review June 20, 2024 17:52
@staticmethod
def _get_last_visited_block_path_and_unit_name(
block_id: str
) -> Union[Tuple[None, None], Tuple[List['XBlock'], str]]: # noqa: F821
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than noqa codes could you use the human-readable pylint: ignore=... ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, sure we will be able to update the format of linter directives, but what do you think about switching to other linters, for example ruff in the future?
Maybe it's worth discussing smooth transition from pylint only supported comments to flake8 like # noqa, that are supported by most python linters nowadays, so it might ease the migration?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting point, and I think it could be worth discussing separately. Currently, though, we almost exclusively use pylint directives instead of noqa directives, so I'd like to stay consistent rather than starting a new pattern in this PR.

If we want to switch from pylint directives to noqa in the future, we could use a script to do it in bulk.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I see, I'm going to change # noqa to pylint: ignore=...

lms/djangoapps/mobile_api/users/serializers.py Outdated Show resolved Hide resolved
"""
return self.calculate_progress(model)

def get_course_assignments(self, model: CourseEnrollment) -> Dict[str, Optional[List[Dict[str, str]]]]:
Copy link
Member

@kdmccormick kdmccormick Jun 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like this logic to exist in a central platform Python API instead of the Mobile API. If the Learning MFE or Learner Dash MFE ever needs this same data, they should be able to call this function instead of reimplementing it. Could you move it? Maybe lms/djangoapps/courseware/courses.py is the right place?

In general, when implementing Mobile API features, try to keep the student/grading/progress/access logic in core applications, and then just use the mobile_api as a thin wrapper for presenting that information to the mobile apps. That way, both the Web and Mobile versions of the platform can have consistent behavior and we will have less business logic to test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick I agree, it sounds logical. I moved get_course_assignments here: lms/djangoapps/courseware/courses.py

log = logging.getLogger(__name__)


def calculate_progress(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as my comment on get_course_assignments, this is core business logic that needs to be implemented in core APIs for consistency with the Web views.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this calculate_progress function I also move to lms/djangoapps/courseware/courses.py. Everything works correctly and I'm going to push these changes

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Let me know when you're ready for another review pass.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick the comments were addressed, It's ready for the second round now

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KyryloKireiev I see that you moved one definition of calculate_progress to lms/djangoapps/courseware/courses.py, thank you. However, there is this second definition of calculate_progress below, still in the mobile API.

Firstly, I think this function should be called something else, as it returns subsection grades, not progress.

Secondly, and more importantly, I am concerned whether this implementation matches the other ways in which we show a user's subsection grades in edx-platform. For example, I assume that the gradebook and the progress page on the Learning MFE both show subsection grades. Does those implementations perform the same steps that we do here (load the block structure, cache it, calculate grades, and then re-calculate from visible-only) ? If so, why not use a shared API function? If not, why?

cc @jawad-khan

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. @kdmccormick I renamed and moved all calculate_progress methods to lms/djangoapps/courseware/courses.py;
  2. @GlugovGrGlib will be able to answer the second question better. This decision was made taking into account the further development of the mobile API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick that's true this approach for the assignments completions and progress calculation is not similar to the one on progress page in LMS. This was a product decision made in collaboration with @marcotuts
Please refer to FIGMA designs and to the product requirements write up for more info.

We discussed that now data about assignments on progress page, and course home in web differs from mobile course home but in the future iterations the same feture may be applied to the web course home as well.

@KyryloKireiev KyryloKireiev force-pushed the kireiev/AXM-506/feat/update_UserCourseEnrollmentsList_and_BlocksInfoInCourseView_APIs branch 2 times, most recently from bb91f42 to cdd2d7e Compare June 21, 2024 09:48
@jawad-khan
Copy link
Contributor

@KyryloKireiev @GlugovGrGlib I have started reviewing this PR.
Since this PR is now adding code in student and courseware app, I'll recommend you to get a review from TNL and Aurora squad too.

@GlugovGrGlib
Copy link
Member

Hey @jawad-khan, thank you, so much!
I don't think we must wait for a review from other 2U teams for this PR as the pieces of code being added to the courseware and student apps are utility functions that don't change the external API in any way and are used only in the mobile_api app. However, it will be good to inform someone from these teams in the Slack. Thanks!

@KyryloKireiev KyryloKireiev force-pushed the kireiev/AXM-506/feat/update_UserCourseEnrollmentsList_and_BlocksInfoInCourseView_APIs branch 2 times, most recently from fa9f50f to bbfb34d Compare June 26, 2024 06:41
@@ -81,7 +81,7 @@ def get_user_course_expiration_date(user, course):
if access_duration is None:
return None

enrollment = CourseEnrollment.get_enrollment(user, course.id)
enrollment = CourseEnrollment.get_enrollment(user, course.id) if not enrollment else enrollment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another approach(optional).
enrollment = enrollment or CourseEnrollment.get_enrollment(user, course.id)

@@ -97,7 +107,7 @@ def get_audit_access_expires(self, model):
"""
Returns expiration date for a course audit expiration, if any or null
"""
return get_user_course_expiration_date(model.user, model.course)
return get_user_course_expiration_date(model.user, model.course, model)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why there is a need to send another param when function can work with two params?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can pass CourseEnrollment object into get_user_course_expiration_date function and avoid the additional query to the database here:
enrollment = enrollment or CourseEnrollment.get_enrollment(user, course.id)

@@ -124,6 +134,17 @@ def get_course_modes(self, obj):
for mode in course_modes
]

def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # lint-amnesty, pylint: disable=unused-variable, line-too-long
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we add course_progress as serializer field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't want to have a course_progress field for each user's enrollment by default. So, we have made a decision to add course_progress field into each user's enrollment only when course_progress is passed into requested_fields query param.

So if we were to add this field course_progress as serializer field - we would have this field in every version (v0.5, v1, v2, v3, v4) of this API. We would receive something like this: "course_progress": null. We didn't want to change the response for older versions of the API, so we added this field as an additional one to to_representation method.

For primary user enrollment we added course_progress as serializer field. Because this is new functionality and we don't change old functionality.

@@ -396,6 +517,8 @@ def paginator(self):

if self._paginator is None and api_version == API_V3:
self._paginator = DefaultPagination()
if self._paginator is None and api_version == API_V4:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we expecting a default paginator for v4?
If no then why api_version == API_V isn't enough here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to use something like this here:
if self._paginator is None and api_version == API_V4:
Because the method paginator is called 4 times when we requesting the API. And only the first time we don't have pagination object. Then we have pagination object:
<lms.djangoapps.mobile_api.users.views.UserCourseEnrollmentsV4Pagination object at 0x7f57017f5950>
And we can't change pagination object, we need only return pagination object for the next method calls.

return list(mobile_available)
return mobile_available

def get_mobile_available_enrollments(self) -> List[Optional[CourseEnrollment]]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method also filters same organization check, can we add this in method name?
maybe:
get_same_org_mobile_available_enrollments

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, renamed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type should be simply list[CourseEnrollment] -- the list items will never be None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick yes, you are right. It should be list[CourseEnrollment] return type. I lost this comment. I'll add this fix to the next push.


mobile_available_course_ids = [enrollment.course_id for enrollment in mobile_available]

latest_enrollment = self.queryset.filter(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't we already filtered is_activer and username in queryset method?
will it work the same way if we don't apply username and is_active check here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. It was extra filtration. I removed these filtrations.

course__id__in=mobile_available_course_ids,
).order_by('-created').first()

if not latest_enrollment:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latest_enrollment code looks duplicated, we are doing same thing in queryset?
can we avoid this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

furthermore, could we centralize the latest_enrollment calculation in a Python API function outside of the mobile_api? For example, common/djangoapps/student/api.py:get_last_active_enrollment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we don't filter user's enrollments by mobile_available in queryset . Should we do this filtration is queryset method?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to redesign our queryset and get_queryset approach. once its finalized we can apply mobile_available filter in get_queryset and use it everywhere else.

@@ -341,47 +362,147 @@ def get_serializer_class(self):
return CourseEnrollmentSerializerv05
return CourseEnrollmentSerializer

def get_queryset(self):
@cached_property
def queryset(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be very careful when using a class level attribute.
Unless we have a strong reason of doing this I'll advise that we move instance or request level logic to get_queryset.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. For the first time we try to put all logic into get_queryset , but we received more then 400 SQL queries for the one request (!). So, we need to use property method for received queryset. Caching helps maximize performance, so we need to @cached_property decorator to make this API faster. Now we have about 120 SQL queries for the one API request. This is the maximum number of SQL queries when requesting additional fields and a large number of the user's enrollments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stackflow answer the best example of when to use queryset and get_queryset.
In our case we should move all request level logic to get_queryset and use caching there. queryset is already cached since it is evaluated only once.

'enrollments': response.data
}
if api_version == API_V4 and status not in EnrollmentStatuses.values():
if status in EnrollmentStatuses.values():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this condition have a conflict with above condition?
We are here because we already know that status is not in EnrollmentStatuses.values()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is actually not a necessary condition. I removed it.

Here the logic may be a little unclear in isolation from mobile applications. If the status parameter is passed to the query params, then the primary enrollment object field should not exist. Only enrollments filtered by status should be returns by this API.

super().__init__(*args, **kwargs)
self.course = modulestore().get_course(self.instance.course.id)

def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like most of this logic is already written in this view.
Can we reuse this logic without duplicating it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I see. This is exactly what we did initially. We tried to use methods from this view. We used functions from this method _last_visited_block_path. We also moved the repetitive logic into a separate function. But our QAs found out that the method _get_course_info returns the wrong path to the last visited block. So, we decided to create new method that returns correct path to the last visited block and don't use logic from _last_visited_block_path method.

Maybe some problems are in this function:

def get_current_child(xblock, min_depth=None, requested_child=None):
    """
    Get the xblock.position's display item of an xblock that has a position and
    children.  If xblock has no position or is out of bounds, return the first
    child with children of min_depth.

We may not understand how this function is supposed to work. But the path to last visited block that is built using this function is not correct

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KyryloKireiev Does get_current_child return the wrong block, or is the right block with the wrong path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick get_current_child function is used in UserCourseStatus._last_visited_block_path method. This method _last_visited_block_path returns incorrect path to the last visited block. Maybe get_current_child function works correctly. But I think it is not correct to use it to find path to the last visited block.

@jawad-khan
Copy link
Contributor

I have added my first review, will deploy these changes and test it locally once you give feedback on this.
Also I have asked TNL squad on their slack channel to look at these changes.

Copy link
Member

@kdmccormick kdmccormick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the requests @jawad-khan has raised and I've added a couple comments to his threads. Otherwise, my only other request is that under all PRs' "Testing Instructions" you describe which flags/data you used to manually smoke-test test the changes.

@kdmccormick
Copy link
Member

Regarding TNL/Aurora review-- if someone from that teams wants to jump in and review as well they are welcome to, but between you and I @jawad-khan, I think our approvals are enough to merge this.

@KyryloKireiev
Copy link
Contributor Author

I agree with the requests @jawad-khan has raised and I've added a couple comments to his threads. Otherwise, my only other request is that under all PRs' "Testing Instructions" you describe which flags/data you used to manually smoke-test test the changes.

@kdmccormick I added detail instruction how make manual API testing to the PR's description.

* feat: [AXM-24] Update structure for course enrollments API

* style: [AXM-24] Improve code style

* fix: [AXM-24] Fix student's latest enrollment filter
"""
return self.filter(is_active=True)

def without_certificates(self, user_username):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

username is enough.

from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel
course_ids_with_certificates = GeneratedCertificate.objects.filter(
user__username=user_username
).values_list('course_id', flat=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two lines are duplicated, can we write a method for user_course_ids_with_certificates?

@@ -341,47 +362,147 @@ def get_serializer_class(self):
return CourseEnrollmentSerializerv05
return CourseEnrollmentSerializer

def get_queryset(self):
@cached_property
def queryset(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stackflow answer the best example of when to use queryset and get_queryset.
In our case we should move all request level logic to get_queryset and use caching there. queryset is already cached since it is evaluated only once.

course__id__in=mobile_available_course_ids,
).order_by('-created').first()

if not latest_enrollment:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to redesign our queryset and get_queryset approach. once its finalized we can apply mobile_available filter in get_queryset and use it everywhere else.

not_duration_limited = (
enrollment for enrollment in mobile_available
if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED
)

if api_version == API_V4 and status not in EnrollmentStatuses.values():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I added comment on the wrong line.
I was just suggesting why not we send mobile_available to get_primary_enrollment_by_latest_enrollment_or_progress in the following line.

@jawad-khan
Copy link
Contributor

@KyryloKireiev Once we agree on queryset implementation apparoach I'll test PR locally and will approve it.

@KyryloKireiev
Copy link
Contributor Author

@KyryloKireiev Once we agree on queryset implementation apparoach I'll test PR locally and will approve it.

Hi @jawad-khan, I pushed new queryset implementation. I thought a lot about how this could be changed. It seems to me that the only available way - create new property method queryset_for_user and call this method where we need. Now we didn't override queryset method.

The main problem of the queryset implementation is get_same_org_mobile_available_enrollments and mobile_available filtration in this method. Because mobile_available has type list and we can't to do new filtrations with mobile_available list, but we need to do filtrations with queryset in get_primary_enrollment_by_latest_enrollment_or_progress. Unfortunately I don't see the possibility to get mobile_available enrollments in get_queryset method and with QuerySet type. I pushed code with current changes.

@jawad-khan, if my approach seems wrong to you, can you explain in more detail how it should be?

@jawad-khan
Copy link
Contributor

@KyryloKireiev Once we agree on queryset implementation apparoach I'll test PR locally and will approve it.

Hi @jawad-khan, I pushed new queryset implementation. I thought a lot about how this could be changed. It seems to me that the only available way - create new property method queryset_for_user and call this method where we need. Now we didn't override queryset method.

The main problem of the queryset implementation is get_same_org_mobile_available_enrollments and mobile_available filtration in this method. Because mobile_available has type list and we can't to do new filtrations with mobile_available list, but we need to do filtrations with queryset in get_primary_enrollment_by_latest_enrollment_or_progress. Unfortunately I don't see the possibility to get mobile_available enrollments in get_queryset method and with QuerySet type. I pushed code with current changes.

@jawad-khan, if my approach seems wrong to you, can you explain in more detail how it should be?

This implementation can be another of not using class level attribute(which is recommended). I'll test it locally. Meanwhile can you please fix failing test cases?

@KyryloKireiev
Copy link
Contributor Author

@jawad-khan pipelines is passed successfully, ready to local review

@jawad-khan
Copy link
Contributor

@kdmccormick I have approved these changes and its clear from my end, can you please finalize your review because we need theses changes asap.

@GlugovGrGlib
Copy link
Member

Hi @kdmccormick, would you be able to do another round of review?

@kdmccormick
Copy link
Member

I am out on holiday and unable to review until 16 Jul, but I do trust @jawad-khan 's review so don't hesistate to merge if this is ready to go.

@GlugovGrGlib
Copy link
Member

GlugovGrGlib commented Jul 9, 2024

@kdmccormick Ok, thanks for the update, have a nice holiday!

@jawad-khan Let's merge it as soon as you're ready?

@jawad-khan
Copy link
Contributor

@kdmccormick can you please resolve your concerns if they are addressed?

@jristau1984
Copy link
Contributor

I have checked over the student/models/ file changes, and they seem acceptable from the SOX perspective.

@miankhalid miankhalid removed the request for review from kdmccormick July 10, 2024 14:54
@miankhalid
Copy link

was trying to see if removing Kyle's review would enable the merge button 👆

@miankhalid miankhalid dismissed kdmccormick’s stale review July 10, 2024 15:00

Dimissing based on this comment from Kyle: I am out on holiday and unable to review until 16 Jul, but I do trust @jawad-khan 's review so don't hesistate to merge if this is ready to go.

@jawad-khan jawad-khan merged commit 5317417 into openedx:master Jul 10, 2024
49 checks passed
@openedx-webhooks
Copy link

@KyryloKireiev 🎉 Your pull request was merged! Please take a moment to answer a two question survey so we can improve your experience in the future.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX staging environment in preparation for a release to production.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX production environment.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX staging environment in preparation for a release to production.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX production environment.

OmarIthawi pushed a commit to nelc/edx-platform that referenced this pull request Jul 24, 2024
…urses on dashboard view (openedx#34848)

* feat: [AXM-24] Update structure for course enrollments API (openedx#2515)
---------
Co-authored-by: Glib Glugovskiy <[email protected]>

* feat: [AXM-53] add assertions for primary course (openedx#2522)
---------
Co-authored-by: monteri <[email protected]>

* feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (openedx#2546)
---------
Co-authored-by: NiedielnitsevIvan <[email protected]>
Co-authored-by: Glib Glugovskiy <[email protected]>
Co-authored-by: monteri <[email protected]>

Conflicts:
	lms/djangoapps/courseware/courses.py
mudassir-hafeez pushed a commit to mudassir-hafeez/edx-platform that referenced this pull request Jul 24, 2024
…urses on dashboard view (openedx#34848)

* feat: [AXM-24] Update structure for course enrollments API (openedx#2515)
---------
Co-authored-by: Glib Glugovskiy <[email protected]>

* feat: [AXM-53] add assertions for primary course (openedx#2522)
---------
Co-authored-by: monteri <[email protected]>

* feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (openedx#2546)
---------
Co-authored-by: NiedielnitsevIvan <[email protected]>
Co-authored-by: Glib Glugovskiy <[email protected]>
Co-authored-by: monteri <[email protected]>
OmarIthawi pushed a commit to nelc/edx-platform that referenced this pull request Jul 25, 2024
…urses on dashboard view (openedx#34848)

* feat: [AXM-24] Update structure for course enrollments API (openedx#2515)
---------
Co-authored-by: Glib Glugovskiy <[email protected]>

* feat: [AXM-53] add assertions for primary course (openedx#2522)
---------
Co-authored-by: monteri <[email protected]>

* feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (openedx#2546)
---------
Co-authored-by: NiedielnitsevIvan <[email protected]>
Co-authored-by: Glib Glugovskiy <[email protected]>
Co-authored-by: monteri <[email protected]>

Conflicts:
	lms/djangoapps/courseware/courses.py
	lms/djangoapps/mobile_api/users/tests.py
@hassan6190
Copy link

@GlugovGrGlib Can you help me understand how does this progress bar track progress? Are there any specific xblock completion it is tracking?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
open-source-contribution PR author is not from Axim or 2U
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.