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

Jlc/certificates metrics #96

Merged
merged 1 commit into from
Sep 13, 2023
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
39 changes: 39 additions & 0 deletions eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Test backend for certificates module."""
from django.contrib.auth.models import User
from django.db import models
from mock import Mock
from model_utils import Choices
from opaque_keys.edx.django.models import CourseKeyField

from eox_nelp.edxapp_wrapper.test_backends import create_test_model


def get_generated_certificates_admin():
Expand All @@ -8,3 +14,36 @@ def get_generated_certificates_admin():
Mock class.
"""
return Mock()


MODES = Choices(
'verified',
'honor',
'audit',
'professional',
'no-id-professional',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
)


def get_generated_certificate():
"""Return test model.
Returns:
Generated Certificates dummy model.
"""
generated_certificate_fields = {
"user": models.ForeignKey(User, on_delete=models.CASCADE),
"course_id": CourseKeyField(max_length=255, blank=True, default=None),
"grade": models.CharField(max_length=5, blank=True, default=''),
"status": models.CharField(max_length=32, default='unavailable'),
"mode": models.CharField(max_length=32, choices=MODES, default=MODES.honor),
# not model fields :
"MODES": MODES,
}

return create_test_model(
"GeneratedCertificate", "eox_nelp", __package__, generated_certificate_fields
)
11 changes: 11 additions & 0 deletions eox_nelp/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ class Migration(migrations.Migration):
('org', models.CharField(blank=True, max_length=500, null=True))
],
),
migrations.CreateModel(
Copy link
Collaborator

@andrey-canon andrey-canon Sep 12, 2023

Choose a reason for hiding this comment

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

why are you creating a a backend if you are creating the model ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Because the backend allows me to load the class that passes the data to the test db.

GeneratedCertificate.objects.get_or_create(**{

def get_generated_certificate():
"""Return test model.
Returns:
Generated Certificates dummy model.
"""
generated_certificate_fields = {
"user": models.ForeignKey(User, on_delete=models.CASCADE),
"course_id": CourseKeyField(max_length=255, blank=True, default=None),
"verify_uuid": models.CharField(max_length=32, blank=True, default='', db_index=True),
"grade": models.CharField(max_length=5, blank=True, default=''),
"key": models.CharField(max_length=32, blank=True, default=''),
"distinction": models.BooleanField(default=False),
"status": models.CharField(max_length=32, default='unavailable'),
"mode": models.CharField(max_length=32, choices=MODES, default=MODES.honor),
"name": models.CharField(blank=True, max_length=255),
"created_date": models.DateTimeField(auto_now_add=True),
"modified_date": models.DateTimeField(auto_now=True),
"download_uuid": models.CharField(max_length=32, blank=True, default=''),
"download_url": models.CharField(max_length=128, blank=True, default=''),
"error_reason": models.CharField(max_length=512, blank=True, default=''),
# not model fields :
"MODES": MODES,
}
return create_test_model(
"GeneratedCertificate", "eox_nelp", __package__, generated_certificate_fields
)

Copy link
Collaborator

Choose a reason for hiding this comment

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

generally we don't duplicate models from edx-platform, why this case is so especial ?

Copy link
Collaborator Author

@johanseto johanseto Sep 12, 2023

Choose a reason for hiding this comment

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

Actually is because in the test I use the model to generate the certificates in test db. So the mainly reason is that, as you can see there course_overview also was recreated, but with less fields

migrations.CreateModel(
name='CourseOverview',
fields=[
(
'id',
opaque_keys.edx.django.models.CourseKeyField(
db_index=True,
primary_key=True,
max_length=255,
verbose_name='ID',
),
),
('org', models.CharField(blank=True, max_length=500, null=True))
],
),

The reasons is that I want to test against like "real certs data"...

Copy link
Collaborator Author

@johanseto johanseto Sep 12, 2023

Choose a reason for hiding this comment

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

So in the test, as you can see I don't mock any function of the certificates because the django test db has the model and data(GeneratedCertficates). So functions like GeneratedCertficates.objects... work and return data from test db.

GeneratedCertificate.objects.get_or_create(**{
'user': user,
'course_id': CourseKey.from_string("course-v1:test+Cx105+2022_T4"),
'verify_uuid': 'ddad6d87c5084a3facfd7925b0b2b9a3',
'download_uuid': '',
'download_url': '',
'grade': '71.0',
'key': '807e31d92ab6aeaab514d7669bb2b014',
'distinction': False,
'status': 'downloadable',
'mode': 'no-id-professional',
'name': 'Peter Park',
'error_reason': '',
})
GeneratedCertificate.objects.get_or_create(**{
'user': user2,
'course_id': CourseKey.from_string("course-v1:test+Cx105+2022_T4"),
'verify_uuid': 'ddad6d87c5084a3facfd7925b0b2b9a3',
'download_uuid': '',
'download_url': '',
'grade': '59.0',
'key': '807e31d92ab6aeaab514d7669bb2b014',
'distinction': False,
'status': 'notpassing',

Copy link
Collaborator

Choose a reason for hiding this comment

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

Course overview was created because another model use that as foreign key, there was no another option

IMO there are another alternatives like Mock that could fit here without adding fields that you even don't use

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually, I started trying to use the mock approach, therefore as I used different Django queryset attributes and mixed them, I found that I have to do a lot of different mocks approaches and also with sense. So I stated to be a little mixed, so finally, I preferred to load the model and get data as the django queryset would work.

# this block set the CourseEnrollment mock and its returned values.
filter_result = CourseEnrollment.objects.filter.return_value
values_result = filter_result.values.return_value
distinct_result = values_result.distinct.return_value
distinct_result.count.return_value = self.expected_returned_enrollments
# this block set the CourseAccessRole mock and its returned values.
filter_result = CourseAccessRole.objects.filter.return_value
values_result = filter_result.values.return_value
distinct_result = values_result.distinct.return_value
distinct_result.count.return_value = self.expected_returned_roles
# this block set the GeneratedCertificates mock and its returned values.

Also I set another fields, maybe extra but that could be useful if others created a certificate in the django db test model.

Actually all was loaded from here. Practically I have the same model that the openedx
https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/certificates/models.py#L224-L240

Copy link
Collaborator

Choose a reason for hiding this comment

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

That's the point we can not copy all the models that openedx use

name='GeneratedCertificate',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
('course_id', opaque_keys.edx.django.models.CourseKeyField(default=None, max_length=255, blank=True)),
('grade', models.CharField(default='', max_length=5, blank=True)),
('status', models.CharField(default='unavailable', max_length=32)),
('mode', models.CharField(default='honor', max_length=32, choices=[('verified', 'verified'), ('honor', 'honor'), ('audit', 'audit'), ('professional', 'professional'), ('no-id-professional', 'no-id-professional')])),
],
),
]

if getattr(settings, 'TESTING_MIGRATIONS', False):
Expand Down
2 changes: 1 addition & 1 deletion eox_nelp/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def plugin_settings(settings): # pylint: disable=function-redefined

EOX_CORE_COURSEWARE_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.courseware_m_v1"
EOX_CORE_GRADES_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.grades_m_v1"
EOX_CORE_CERTIFICATES_BACKEND = "eox_core.edxapp_wrapper.backends.certificates_h_v1_test"
EOX_CORE_CERTIFICATES_BACKEND = "eox_nelp.edxapp_wrapper.test_backends.certificates_m_v1"

GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_test_v1'
GET_THEMING_HELPERS = 'eox_tenant.edxapp_wrapper.backends.theming_helpers_test_v1'
Expand Down
46 changes: 44 additions & 2 deletions eox_nelp/stats/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class GeneralTenantStatsView(APIView):
"openassessment": 0,
"problem": 49,
"video": 0
},
"certiticates": {
"downloadable": 5,
"notpassing": 4
}
}
```
Expand All @@ -50,18 +54,22 @@ def get(self, request):
tenant = request.site.domain
courses = metrics.get_courses_metrics(tenant)
components = {}

certificates = {}
for metric in courses.get("metrics", []):
course_components = metric.get("components", {})
certificates_components = metric.get("certificates", {}).get("total", {})

for key, value in course_components.items():
components[key] = components.get(key, 0) + value
for key, value in certificates_components.items():
certificates[key] = certificates.get(key, 0) + value

return Response({
"learners": metrics.get_learners_metric(tenant),
"courses": courses.get("total_courses", 0),
"instructors": metrics.get_instructors_metric(tenant),
"components": components
"components": components,
"certificates": certificates,
})


Expand Down Expand Up @@ -98,6 +106,22 @@ class GeneralCourseStatsView(APIView):
"openassessment": 0,
"problem": 49,
"video": 0
},
"certificates" : {
"verified": {},
"honor": {},
"audit": {},
"professional": {},
"no-id-professional": {
"downloadable": 5,
"notpassing": 4,
},
"masters": {},
"executive-education": {},
"total": {
"downloadable": 5,
"notpassing": 4
}
}
},
...
Expand All @@ -124,6 +148,24 @@ class GeneralCourseStatsView(APIView):
"openassessment": 0,
"problem": 49,
"video": 0
},
"certificates" : {
"verified": {...},
"honor": {...},
"audit": {...},
"professional": {},
"no-id-professional": {
"downloadable": 5,
"notpassing": 4...
},
"masters": {...},
"executive-education": {...},
"paid-executive-education": {...},
"paid-bootcamp": {...},
"total": {
"downloadable": 5,
"notpassing": 4
}
}
}
```
Expand Down
57 changes: 56 additions & 1 deletion eox_nelp/stats/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
get_learners_metric: Return number of learners, for the visible courses.
get_instructors_metric: Return number of instructors, for the visible courses.
get_courses_metrics: Return metrics for the visible courses.
get_course_certificates_metric: Return a dict metric representin the certificates of a course.
"""
from django.conf import settings
from eox_core.edxapp_wrapper.certificates import get_generated_certificate

from eox_nelp.edxapp_wrapper.branding import get_visible_courses
from eox_nelp.edxapp_wrapper.modulestore import modulestore
from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers
from eox_nelp.edxapp_wrapper.student import CourseAccessRole, CourseEnrollment
from eox_nelp.stats.decorators import cache_method

GeneratedCertificate = get_generated_certificate()


@cache_method
def get_cached_courses(tenant): # pylint: disable=unused-argument
Expand Down Expand Up @@ -70,6 +74,7 @@ def get_course_metrics(course_key):
user__is_staff=False,
user__is_superuser=False
).values('user').distinct().count()
certificates = get_course_certificates_metric(course_key)

return {
"id": str(course_key),
Expand All @@ -79,7 +84,8 @@ def get_course_metrics(course_key):
"sections": len(chapters),
"sub_sections": len(sequentials),
"units": len(verticals),
"components": components
"components": components,
"certificates": certificates,
}


Expand Down Expand Up @@ -134,3 +140,52 @@ def get_courses_metrics(tenant):
metrics = [get_course_metrics(course.id) for course in courses]

return {"total_courses": courses.count(), "metrics": metrics}


def get_course_certificates_metric(course_key):
"""
Returns the total of certificates in a course.

Args:
course_key<opaque-key>: Course identifier.

Return:
<Dictionary>: Contains certificates of course metric.
{
"verified": {...},
"honor": {...},
"audit": {},
"professional": {...},
"no-id-professional": {
"downloadable": 5,
"notpassing": 4,
},
"masters": {...},
"executive-education": {...}
"paid-executive-education":{...},
"paid-bootcamp": {...},
"total": 0
Copy link
Collaborator

@andrey-canon andrey-canon Sep 12, 2023

Choose a reason for hiding this comment

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

since you are splitting the certificates based on their status the total metric should reflect also that division

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could you explain this one a little more, I mean I am setting the total without the division.
So the division would reflect something like the total:

"total" : {
"no-id-professional": 5,
"audit": 5
}

Is that what you say?
Because if is that I prefer to set the total of each one inside each key?? But I didn't find it necessary due you only have to sum the field of each one

Copy link
Collaborator

Choose a reason for hiding this comment

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

I didn't say mode

                "total": {
                    "downloadable": 5,
                    "notpassing": 4,
                },

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I didn't say mode

                "total": {
                    "downloadable": 5,
                    "notpassing": 4,
                },

@andrey-canon what about this approach ?
Peek 2023-09-12 17-55

Copy link
Collaborator

Choose a reason for hiding this comment

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

did you remove the modes ?

}
"""
certificates = {}
course_certificates_qs = GeneratedCertificate.objects.filter(
course_id=course_key,
)
cert_statuses = [cert["status"] for cert in course_certificates_qs.values("status").distinct()]

for mode in GeneratedCertificate.MODES:
db_mode = mode[0]
human_mode = mode[1]
certificates[human_mode] = {
cert_status: course_certificates_qs.filter(
mode=db_mode,
status=cert_status,
).values("user").distinct().count()
for cert_status in cert_statuses
}
certificates["total"] = {
cert_status: course_certificates_qs.filter(status=cert_status).values("user").distinct().count()
for cert_status in cert_statuses
}

return certificates
57 changes: 55 additions & 2 deletions eox_nelp/stats/tests/api/v1/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ def test_default(self, mock_metrics):
response = self.client.get(url_endpoint)

self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertTrue(["learners", "courses", "instructors", "components"] == list(response.data.keys()))
self.assertTrue(
["learners", "courses", "instructors", "components", "certificates"] == list(response.data.keys())
)

@override_settings(
MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"],
Expand Down Expand Up @@ -86,6 +88,56 @@ def test_total_components(self, mock_metrics):
self.assertEqual(expected_components, response.data["components"])
mock_metrics.get_courses_metrics.assert_called_once_with("testserver")

@override_settings(
MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"],
)
@patch("eox_nelp.stats.api.v1.views.metrics")
def test_total_certificates(self, mock_metrics):
"""
Test that the view will calculate the total of certificates based on the metrics values

Expected behavior:
- Status code 200.
- Components total values are the expected.
- get_courses_metrics is called once.
"""
total_courses = 4
fake_metric = {
"certificates": {
"verified": {"downloadable": 0, "notpassing": 0},
"honor": {"downloadable": 0, "notpassing": 0},
"audit": {"downloadable": 0, "notpassing": 0},
"professional": {"downloadable": 0, "notpassing": 0},
"no-id-professional": {"downloadable": 5, "notpassing": 4},
"masters": {"downloadable": 10, "notpassing": 0},
"executive-education": {"downloadable": 0, "notpassing": 1},
"paid-executive-education": {"downloadable": 0, "notpassing": 0},
"paid-bootcamp": {"downloadable": 0, "notpassing": 0},
"total": {
"downloadable": 15,
"notpassing": 5,
}
},
}

mock_metrics.get_learners_metric.return_value = 5
mock_metrics.get_instructors_metric.return_value = 4875
mock_metrics.get_courses_metrics.return_value = {
"total_courses": total_courses,
"metrics": [fake_metric for c in range(total_courses)],
}
expected_certificates = {
"downloadable": fake_metric["certificates"]["total"]["downloadable"] * total_courses,
"notpassing": fake_metric["certificates"]["total"]["notpassing"] * total_courses,
}
url_endpoint = reverse("stats-api:v1:general-stats")

response = self.client.get(url_endpoint)

self.assertEqual(status.HTTP_200_OK, response.status_code)
self.assertEqual(expected_certificates, response.data["certificates"])
mock_metrics.get_courses_metrics.assert_called_once_with("testserver")

@override_settings(MIDDLEWARE=["eox_tenant.middleware.CurrentSiteMiddleware"])
@data("post", "put", "patch", "delete")
def test_invalid_method(self, method):
Expand Down Expand Up @@ -174,7 +226,8 @@ def test_get_detail(self, mock_metrics):
"openassessment": 0,
"problem": 49,
"video": 0
}
},
"certificates": 12
}
url_endpoint = reverse("stats-api:v1:course-stats", args=[course_id])

Expand Down
Loading
Loading