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: add learning hours count to the stats api #169

Merged
merged 2 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions futurex_openedx_extensions/dashboard/statistics/certificates.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""functions for getting statistics about certificates"""
from __future__ import annotations

import logging
from typing import Dict

from django.conf import settings
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Lower
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses

log = logging.getLogger(__name__)


def get_certificates_count(
fx_permission_info: dict, visible_courses_filter: bool | None = True, active_courses_filter: bool | None = None
Expand Down Expand Up @@ -41,3 +45,77 @@ def get_certificates_count(
)).values('course_org').annotate(certificates_count=Count('id')).values_list('course_org', 'certificates_count'))

return dict(result)


def get_learning_hours_count(
fx_permission_info: dict, visible_courses_filter: bool | None = True, active_courses_filter: bool | None = None
Copy link
Collaborator

Choose a reason for hiding this comment

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

✅ same default values for argument as in get_certificates_count. Thank you!

) -> int:
"""
Get the count of learning hours in the given tenants. The count is grouped by course_effort. Certificates
for admins, staff, and superusers are also included.

:param fx_permission_info: Dictionary containing permission information
:type fx_permission_info: dict
:param visible_courses_filter: Value to filter courses on catalog visibility. None means no filter.
:type visible_courses_filter: bool | None
:param active_courses_filter: Value to filter courses on active status. None means no filter.
:type active_courses_filter: bool | None
:return: Count of certificates per organization
:rtype: Dict[str, int]
"""

def parse_course_effort(effort: str, course_id: str) -> float:
"""Parses course effort in HH:MM format and returns total hours as a float."""
try:
if not effort:
raise ValueError('Course effort is not set.')

parts = effort.split(':')
hours = int(parts[0])
minutes = int(parts[1]) if len(parts) > 1 else 0

if hours < 0 or minutes < 0:
raise ValueError('Hours and minutes must be non-negative values.')
if minutes >= 60:
raise ValueError('Minutes cannot be 60 or more.')

total_hours = hours + minutes / 60

if total_hours < 0.5:
raise ValueError('course effort value is too small')

return round(total_hours, 1)

except (ValueError, IndexError) as exc:
log.exception(
'Invalid course-effort for course %s. Assuming default value (%s hours). Error: %s',
course_id, settings.FX_DEFAULT_COURSE_EFFORT, str(exc)
)
return settings.FX_DEFAULT_COURSE_EFFORT

result = list(
GeneratedCertificate.objects.filter(
status='downloadable',
course_id__in=get_base_queryset_courses(
fx_permission_info,
visible_filter=visible_courses_filter,
active_filter=active_courses_filter,
),
).annotate(
course_effort=Subquery(
CourseOverview.objects.filter(
id=OuterRef('course_id')
).values('effort')
)
).annotate(
certificates_count=Count('id')
).values('course_effort', 'certificates_count', 'course_id')
)

return sum(
Copy link
Collaborator

@shadinaif shadinaif Dec 20, 2024

Choose a reason for hiding this comment

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

✅ this is great, let's see the performance on production. We can cache it if we find it slowing down the dashboard

parse_course_effort(
entry.get('course_effort', settings.FX_DEFAULT_COURSE_EFFORT),
entry.get('course_id')
) * entry.get('certificates_count', 0)
for entry in result
)
22 changes: 20 additions & 2 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
get_learners_enrollments_queryset,
get_learners_queryset,
)
from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count
from futurex_openedx_extensions.dashboard.statistics.certificates import (
get_certificates_count,
get_learning_hours_count,
)
from futurex_openedx_extensions.dashboard.statistics.courses import (
get_courses_count,
get_courses_count_by_status,
Expand Down Expand Up @@ -84,14 +87,19 @@ class TotalCountsView(FXViewRoleInfoMixin, APIView):
STAT_ENROLLMENTS = 'enrollments'
STAT_HIDDEN_COURSES = 'hidden_courses'
STAT_LEARNERS = 'learners'
STAT_LEARNING_HOURS = 'learning_hours'

valid_stats = [
STAT_CERTIFICATES, STAT_COURSES, STAT_ENROLLMENTS, STAT_HIDDEN_COURSES, STAT_LEARNERS, STAT_LEARNING_HOURS
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
STAT_CERTIFICATES, STAT_COURSES, STAT_ENROLLMENTS, STAT_HIDDEN_COURSES, STAT_LEARNERS, STAT_LEARNING_HOURS
STAT_CERTIFICATES, STAT_COURSES, STAT_ENROLLMENTS, STAT_HIDDEN_COURSES, STAT_LEARNERS, STAT_LEARNING_HOURS,

]

valid_stats = [STAT_CERTIFICATES, STAT_COURSES, STAT_ENROLLMENTS, STAT_HIDDEN_COURSES, STAT_LEARNERS]
STAT_RESULT_KEYS = {
STAT_CERTIFICATES: 'certificates_count',
STAT_COURSES: 'courses_count',
STAT_ENROLLMENTS: 'enrollments_count',
STAT_HIDDEN_COURSES: 'hidden_courses_count',
STAT_LEARNERS: 'learners_count',
STAT_LEARNING_HOURS: 'learning_hours_count',
}

authentication_classes = default_auth_classes
Expand Down Expand Up @@ -127,6 +135,11 @@ def _get_learners_count_data(one_tenant_permission_info: dict, include_staff: bo
"""Get the count of learners for the given tenant"""
return get_learners_count(one_tenant_permission_info, include_staff=include_staff)

@staticmethod
def _get_learning_hours_count_data(one_tenant_permission_info: dict) -> int:
"""Get the count of learning_hours for the given tenant"""
return get_learning_hours_count(one_tenant_permission_info)

def _get_stat_count(self, stat: str, tenant_id: int, include_staff: bool) -> int:
"""Get the count of the given stat for the given tenant"""
one_tenant_permission_info = get_tenant_limited_fx_permission_info(self.fx_permission_info, tenant_id)
Expand All @@ -144,6 +157,11 @@ def _get_stat_count(self, stat: str, tenant_id: int, include_staff: bool) -> int
if stat == self.STAT_HIDDEN_COURSES:
return self._get_courses_count_data(one_tenant_permission_info, visible_filter=False)

if stat == self.STAT_LEARNING_HOURS:
return self._get_learning_hours_count_data(
one_tenant_permission_info,
)

return self._get_learners_count_data(one_tenant_permission_info, include_staff)

def get(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ def plugin_settings(settings: Any) -> None:
'FX_DASHBOARD_STORAGE_DIR',
'fx_dashboard'
)

# Default Course EFfort
settings.FX_DEFAULT_COURSE_EFFORT = getattr(
settings,
'FX_DEFAULT_COURSE_EFFORT',
'12',
)
1 change: 1 addition & 0 deletions test_utils/edx_platform_mocks_shared/fake_models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class CourseOverview(models.Model):
self_paced = models.BooleanField(default=False)
course_image_url = models.TextField()
visible_to_staff_only = models.BooleanField(default=False)
effort = models.TextField(null=True)

class Meta:
app_label = 'fake_models'
Expand Down
2 changes: 2 additions & 0 deletions test_utils/test_settings_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ def root(*args):
NELC_DASHBOARD_BASE = 'dashboard.example.com'

FX_DASHBOARD_STORAGE_DIR = 'test_dir'

FX_DEFAULT_COURSE_EFFORT = 20
9 changes: 9 additions & 0 deletions tests/base_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,39 +316,48 @@
'enrollments_count': 26,
'hidden_courses_count': 0,
'learners_count': 16,
'learning_hours_count': 14 * 20,
},
'2': {
'certificates_count': 9,
'courses_count': 5,
'enrollments_count': 21,
'hidden_courses_count': 0,
'learners_count': 21,
'learning_hours_count': 9 * 20,
},
'3': {
'certificates_count': 0,
'courses_count': 1,
'enrollments_count': 4,
'hidden_courses_count': 0,
'learners_count': 6,
'learning_hours_count': 0,

},
'7': {
'certificates_count': 7,
'courses_count': 3,
'enrollments_count': 14,
'hidden_courses_count': 0,
'learners_count': 17,
'learning_hours_count': 7 * 20,

},
'8': {
'certificates_count': 2,
'courses_count': 2,
'enrollments_count': 7,
'hidden_courses_count': 0,
'learners_count': 9,
'learning_hours_count': 2 * 20

},
'total_certificates_count': 32,
'total_courses_count': 23,
'total_enrollments_count': 72,
'total_hidden_courses_count': 0,
'total_learners_count': 69,
'total_learning_hours_count': 640,
'limited_access': False,
}
48 changes: 48 additions & 0 deletions tests/test_dashboard/test_statistics/test_certificates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Tests for certificates statistics."""
import pytest
from django.test import override_settings
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

from futurex_openedx_extensions.dashboard.statistics import certificates
from tests.fixture_helpers import get_tenants_orgs
Expand Down Expand Up @@ -44,3 +46,49 @@ def test_get_certificates_count_not_downloadable(base_data, fx_permission_info):
).update(status=some_status_not_downloadable)
result = certificates.get_certificates_count(fx_permission_info)
assert result == {'org1': 3, 'org2': 10}, f'Wrong certificates result. expected: {result}'


@pytest.mark.django_db
@override_settings(FX_DEFAULT_COURSE_EFFORT=10)
@pytest.mark.parametrize('tenant_ids, expected_result', [
([1], 14 * 10),
([2], 9 * 10),
([3], 0 * 10),
])
def test_get_learning_hours_count_for_default_course_effort(
base_data, fx_permission_info, tenant_ids, expected_result
): # pylint: disable=unused-argument
"""Verify get_learning_hours_count function."""
fx_permission_info['view_allowed_full_access_orgs'] = get_tenants_orgs(tenant_ids)
fx_permission_info['view_allowed_any_access_orgs'] = get_tenants_orgs(tenant_ids)
result = certificates.get_learning_hours_count(fx_permission_info)
assert result == expected_result, \
f'Wrong learning hours count for tenant(s) {tenant_ids}. expected: {expected_result}, got: {result}'


@pytest.mark.django_db
@override_settings(FX_DEFAULT_COURSE_EFFORT=10)
@pytest.mark.parametrize('effort, expected_result, usecase', [
# 2 is the certificate_count in tenant_id 8
('20:30', 20.5 * 2, 'Valid, proper HH:MM format with 2-digit minutes'),
('20:05', 20.1 * 2, 'Valid, minutes as a single digit with leading zero'),
('20:5', 20.1 * 2, 'Valid, minutes as a single digit without leading zero'),
('5:5', 5.1 * 2, 'Valid, both hours and minutes are single digits'),
('30', 30 * 2, 'Valid, only hours provided (no minutes)'),
('0:30', 0.5 * 2, 'Valid, only min with 0 hours'),
('5:120', 10 * 2, 'Invalid, minutes exceed 60, use default value'),
('invalid', 10 * 2, 'Invalid, non-numeric value for hours, use default value'),
('20:invalid', 10 * 2, 'Invalid, non-numeric value for minutes, use default value'),
Copy link
Collaborator

Choose a reason for hiding this comment

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

two more tests, please

Suggested change
('20:invalid', 10 * 2, 'Invalid, non-numeric value for minutes, use default value'),
('20:invalid', 10 * 2, 'Invalid, non-numeric value for minutes, use default value'),
('0:25', 10 * 2, 'Invalid, less than 30 minutes'),
('0:-55', 10 * 2, 'Invalid, less than 30 minutes'),

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

that's a good catch there.

('0:-55', 10 * 2, 'Invalid, neg min, use default value'),
('-55', 10 * 2, 'Invalid, neg hour, use default value'),
('0:10', 10 * 2, 'Invalid, course effort value is too small less then 0.5, use default value'),
])
def test_get_learning_hours_count_for_different_course_effor_format(
base_data, fx_permission_info, effort, expected_result, usecase
): # pylint: disable=unused-argument
"""Verify get_learning_hours_count function for different course format."""
fx_permission_info['view_allowed_full_access_orgs'] = get_tenants_orgs([8])
fx_permission_info['view_allowed_any_access_orgs'] = get_tenants_orgs([8])
CourseOverview.objects.filter(id='course-v1:ORG8+1+1').update(effort=effort)
result = certificates.get_learning_hours_count(fx_permission_info)
assert result == expected_result, f'Wrong learning hours count usecase: {usecase}'
4 changes: 3 additions & 1 deletion tests/test_dashboard/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ def test_invalid_stats(self):
def test_all_stats(self):
"""Test get method"""
self.login_user(self.staff_user)
response = self.client.get(self.url + '?stats=certificates,courses,hidden_courses,learners,enrollments')
response = self.client.get(
self.url + '?stats=certificates,courses,hidden_courses,learners,enrollments,learning_hours'
)
self.assertTrue(isinstance(response, JsonResponse))
self.assertEqual(response.status_code, http_status.HTTP_200_OK)
self.assertDictEqual(json.loads(response.content), expected_statistics)
Expand Down