From 2c15cf6d5ac69fb5c961c9185428e618500c24a1 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Mon, 15 Jul 2024 18:42:30 +0500 Subject: [PATCH] feat: add new APIs for stats --- .../course_progress/api/v1/urls.py | 3 +- .../course_progress/api/v1/views.py | 99 ++++++++++++++++++- .../sdaia_features/course_progress/utils.py | 76 +++++++++++++- 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/openedx/features/sdaia_features/course_progress/api/v1/urls.py b/openedx/features/sdaia_features/course_progress/api/v1/urls.py index 53cda962ae5f..2ecb2c59ea69 100644 --- a/openedx/features/sdaia_features/course_progress/api/v1/urls.py +++ b/openedx/features/sdaia_features/course_progress/api/v1/urls.py @@ -3,11 +3,12 @@ """ from django.urls import path # pylint: disable=unused-import -from .views import UserStatsAPIView +from .views import UserStatsAPIView, DashboardStatsAPIView app_name = "nafath_api_v1" urlpatterns = [ path(r"user_stats", UserStatsAPIView.as_view()), + path(r"dashboard_stats", DashboardStatsAPIView.as_view()), ] diff --git a/openedx/features/sdaia_features/course_progress/api/v1/views.py b/openedx/features/sdaia_features/course_progress/api/v1/views.py index 7ebf415d3d5b..cb54a50e4d73 100644 --- a/openedx/features/sdaia_features/course_progress/api/v1/views.py +++ b/openedx/features/sdaia_features/course_progress/api/v1/views.py @@ -7,11 +7,14 @@ from ccx_keys.locator import CCXLocator from django.conf import settings +from django.contrib.auth.models import User +from django.db.models import F from django.utils.decorators import method_decorator from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import permissions, status from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated from common.djangoapps.student.roles import ( CourseInstructorRole, @@ -19,6 +22,11 @@ UserBasedRole, ) from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) +from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardEntry +from lms.djangoapps.certificates.models import GeneratedCertificate from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain from openedx.core.djangoapps.enrollments.errors import CourseEnrollmentError from openedx.core.djangoapps.enrollments.data import get_course_enrollments @@ -26,6 +34,9 @@ from openedx.core.djangoapps.programs.utils import ProgramProgressMeter from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import ApiKeyHeaderPermissionIsAuthenticated +from openedx.features.sdaia_features.course_progress.utils import ( + get_user_certificates, +) log = logging.getLogger(__name__) @@ -34,16 +45,17 @@ @can_disable_rate_limit class UserStatsAPIView(APIView): """ - APIView to get the total watch hours for a user. + APIView to get the user stats. **Example Requests** GET /sdaia/api/v1/user_stats - It return watch_time in hours Response: { "watch_hours": 0.00043390860160191856, "enrolled_courses": enrolled_courses, "enrolled_programs": enrolled_programs, + "user_certificates": user_certificates, + "score": score, } """ @@ -57,7 +69,7 @@ class UserStatsAPIView(APIView): @method_decorator(ensure_csrf_cookie_cross_domain) def get(self, request): """ - Gets the total watch hours for a user. + Gets the stats for a user. """ user = request.user user_id = user.id @@ -94,6 +106,29 @@ def get(self, request): }, ) + ############ USER CERTIFICATES ############ + user_certificates = get_user_certificates(username) + + ############ USER BADGES ############ + user_badges = BadgeAssertion.objects.values( + "image_url", + "assertion_url", + "created", + slug=F("badge_class__slug"), + issuing_component=F("badge_class__issuing_component"), + display_name=F("badge_class__display_name"), + course_id=F("badge_class__course_id"), + description=F("badge_class__description"), + criteria=F("badge_class__criteria"), + image=F("badge_class__image"), + ) + for badge in user_badges: + badge["course_id"] = str(badge["course_id"]) + + ############ USER SCORE ############ + leaderboard = LeaderboardEntry.objects.filter(user=user) + score = leaderboard and leaderboard.first().score + ############ Response ############ return Response( status=status.HTTP_200_OK, @@ -101,5 +136,63 @@ def get(self, request): "watch_hours": watch_time, "enrolled_courses": enrolled_courses, "enrolled_programs": no_of_programs, + "score": score, + "user_certificates": user_certificates, + "user_badges": user_badges, + }, + ) + + +@can_disable_rate_limit +class DashboardStatsAPIView(APIView): + """ + APIView to get the dashboard stats. + + **Example Requests** + GET /sdaia/api/v1/dashboard_stats + + Response: { + + } + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated,) + + @method_decorator(ensure_csrf_cookie_cross_domain) + def get(self, request): + """ + Gets the stats for dashboard. + """ + user = request.user + users_count = User.objects.all().count() + certificates_count = GeneratedCertificate.objects.all().count() + clickhouse_uri = ( + f"{settings.CAIRN_CLICKHOUSE_HTTP_SCHEME}://{settings.CAIRN_CLICKHOUSE_USERNAME}:{settings.CAIRN_CLICKHOUSE_PASSWORD}@" + f"{settings.CAIRN_CLICKHOUSE_HOST}:{settings.CAIRN_CLICKHOUSE_HTTP_PORT}/?database={settings.CAIRN_CLICKHOUSE_DATABASE}" + ) + query = f"SELECT SUM(duration) as `Watch time` FROM `openedx`.`video_view_segments`;" + + ############ TOTAL WATCH HOURS ############ + try: + response = requests.get(clickhouse_uri, data=query.encode("utf8")) + watch_time = float(response.content.decode().strip()) / (60 * 60) + except Exception as e: + log.error( + f"Unable to fetch total watch hours due to this exception: {str(e)}" + ) + raise HTTPException(status_code=500, detail=str(e)) + + ############ Response ############ + return Response( + status=status.HTTP_200_OK, + data={ + "users_count": users_count, + "certificates_count": certificates_count, + "total_watch_time": watch_time, }, ) diff --git a/openedx/features/sdaia_features/course_progress/utils.py b/openedx/features/sdaia_features/course_progress/utils.py index b618ac03ec5d..0f3f5b08a4dd 100644 --- a/openedx/features/sdaia_features/course_progress/utils.py +++ b/openedx/features/sdaia_features/course_progress/utils.py @@ -1,7 +1,16 @@ """ Utility functions for the course progress emails """ + +from lms.djangoapps.certificates.api import ( + certificates_viewable_for_course, + get_certificates_for_user, +) from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from openedx.core.djangoapps.content.course_overviews.api import ( + get_course_overviews_from_ids, + get_pseudo_course_overview, +) def get_user_course_progress(user, course_key): @@ -13,10 +22,69 @@ def get_user_course_progress(user, course_key): """ completion_summary = get_course_blocks_completion_summary(course_key, user) - complete_count = completion_summary.get('complete_count', 0) - incomplete_count = completion_summary.get('incomplete_count', 0) - locked_count = completion_summary.get('locked_count', 0) + complete_count = completion_summary.get("complete_count", 0) + incomplete_count = completion_summary.get("incomplete_count", 0) + locked_count = completion_summary.get("locked_count", 0) total_count = complete_count + incomplete_count + locked_count completion_percentage = round((complete_count / total_count) * 100) - return completion_percentage \ No newline at end of file + return completion_percentage + + +def get_user_certificates(username): + user_certs = [] + for user_cert in _get_certificates_for_user(username): + user_certs.append( + { + "username": user_cert.get("username"), + "course_id": str(user_cert.get("course_key")), + "course_display_name": user_cert.get("course_display_name"), + "course_organization": user_cert.get("course_organization"), + "certificate_type": user_cert.get("type"), + "created_date": user_cert.get("created"), + "modified_date": user_cert.get("modified"), + "status": user_cert.get("status"), + "is_passing": user_cert.get("is_passing"), + "download_url": user_cert.get("download_url"), + "grade": user_cert.get("grade"), + } + ) + return user_certs + + +def _get_certificates_for_user(username): + """ + Returns a user's viewable certificates sorted by course name. + """ + course_certificates = get_certificates_for_user(username) + passing_certificates = {} + for course_certificate in course_certificates: + if course_certificate.get("is_passing", False): + course_key = course_certificate["course_key"] + passing_certificates[course_key] = course_certificate + + viewable_certificates = [] + course_ids = list(passing_certificates.keys()) + course_overviews = get_course_overviews_from_ids(course_ids) + for course_key, course_overview in course_overviews.items(): + if not course_overview: + # For deleted XML courses in which learners have a valid certificate. + # i.e. MITx/7.00x/2013_Spring + course_overview = get_pseudo_course_overview(course_key) + if certificates_viewable_for_course(course_overview): + course_certificate = passing_certificates[course_key] + # add certificate into viewable certificate list only if it's a PDF certificate + # or there is an active certificate configuration. + if course_certificate["is_pdf_certificate"] or ( + course_overview and course_overview.has_any_active_web_certificate + ): + course_certificate["course_display_name"] = ( + course_overview.display_name_with_default + ) + course_certificate["course_organization"] = ( + course_overview.display_org_with_default + ) + viewable_certificates.append(course_certificate) + + viewable_certificates.sort(key=lambda certificate: certificate["created"]) + return viewable_certificates