[FC-005] feat: add necessary models for Openedx survey report (#31183)
* feat: add survey_report djangoapp * feat: add survey report cli command and query methods * fix: add init file * refactor: change model fields * refactor: rename application file and rename methods * refactor: add is_active to get course enrollments * refactor: rename method to get active users * refactor: remove fields useless * test: rename mocks in command tests * test: update test name * docs: add README file * docs: add selection criteria to get unique courses * docs: update README * test: remove useless mocks and use default modulestore * docs: change command error message * docs: add docs decisions * docs: Update openedx/features/survey_report/management/commands/generate_report.py * docs: add fields descriptions * docs: add logs for each query * style: add blank lines * refactor: rename variables and add a constant for weeks * refactor: add constant MIN_ENROLLS_ACTIVE_COURSE Co-authored-by: henrrypg <henrry.pulgarin@edunext.co> Co-authored-by: David Ormsbee <dave@tcril.org> Co-authored-by: Maria Grimaldi <maria.grimaldi@edunext.co>
This commit is contained in:
committed by
GitHub
parent
f419d6b194
commit
bfd212b6d8
@@ -3239,6 +3239,9 @@ INSTALLED_APPS = [
|
||||
# Agreements
|
||||
'openedx.core.djangoapps.agreements',
|
||||
|
||||
# Survey reports
|
||||
'openedx.features.survey_report',
|
||||
|
||||
# User and group management via edx-django-utils
|
||||
'edx_django_utils.user',
|
||||
|
||||
|
||||
@@ -1080,3 +1080,6 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = {
|
||||
"SECRET": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET', None),
|
||||
"URL": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL', None),
|
||||
}
|
||||
|
||||
############## Settings for survey report ##############
|
||||
SURVEY_REPORT_EXTRA_DATA = ENV_TOKENS.get('SURVEY_REPORT_EXTRA_DATA', {})
|
||||
|
||||
@@ -671,3 +671,6 @@ MFE_CONFIG_OVERRIDES = {
|
||||
"LOGO_URL": "https://courses.example.com/yourmfe-logo.png",
|
||||
},
|
||||
}
|
||||
|
||||
############## Settings for survey report ##############
|
||||
SURVEY_REPORT_EXTRA_DATA = {}
|
||||
|
||||
11
openedx/features/survey_report/README.rst
Normal file
11
openedx/features/survey_report/README.rst
Normal file
@@ -0,0 +1,11 @@
|
||||
Survey Report
|
||||
--------------------
|
||||
This Django app was created for the purpose of gathering aggregated, anonymized data
|
||||
about Open edX courses at scale, so that we can begin to track the growth
|
||||
and trends in Open edX usage over time, namely in the annual Open edX
|
||||
Impact Report.
|
||||
|
||||
You could find in this directory some methods to manage survey
|
||||
reports, one command to generate the report, some queries to get the
|
||||
information from database and one method to send the report to openedx
|
||||
api.
|
||||
0
openedx/features/survey_report/__init__.py
Normal file
0
openedx/features/survey_report/__init__.py
Normal file
37
openedx/features/survey_report/api.py
Normal file
37
openedx/features/survey_report/api.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Contains the logic to manage survey report model.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.features.survey_report.models import SurveyReport
|
||||
from openedx.features.survey_report.queries import (
|
||||
get_course_enrollments,
|
||||
get_recently_active_users,
|
||||
get_generated_certificates,
|
||||
get_registered_learners,
|
||||
get_unique_courses_offered
|
||||
)
|
||||
|
||||
MAX_WEEKS_SINCE_LAST_LOGIN: int = 4
|
||||
|
||||
|
||||
def generate_report() -> None:
|
||||
""" Generate a report with relevant data."""
|
||||
courses_offered = get_unique_courses_offered()
|
||||
learners = get_recently_active_users(weeks=MAX_WEEKS_SINCE_LAST_LOGIN)
|
||||
registered_learners = get_registered_learners()
|
||||
certificates = get_generated_certificates()
|
||||
enrollments = get_course_enrollments()
|
||||
extra_data = settings.SURVEY_REPORT_EXTRA_DATA
|
||||
|
||||
survey_report = SurveyReport(
|
||||
courses_offered=courses_offered,
|
||||
learners=learners,
|
||||
registered_learners=registered_learners,
|
||||
generated_certificates=certificates,
|
||||
enrollments=enrollments,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
survey_report.save()
|
||||
12
openedx/features/survey_report/apps.py
Normal file
12
openedx/features/survey_report/apps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Survey Report App Configuration.
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SurveyReportConfig(AppConfig):
|
||||
"""
|
||||
Configuration for the survey report Django app.
|
||||
"""
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'openedx.features.survey_report'
|
||||
@@ -0,0 +1,29 @@
|
||||
Addition of the Survey Report App to edx-platform
|
||||
=================================================
|
||||
|
||||
Status
|
||||
------
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
The transition to a more modular architecture for edx-platorm has been
|
||||
strengthened by the acceptance of the `No new Django apps ADR`_.
|
||||
|
||||
.. _No new Django apps ADR: https://github.com/openedx/edx-platform/tree/master/docs/decisions/0014-no-new-apps.rst
|
||||
|
||||
|
||||
Rationale
|
||||
---------
|
||||
|
||||
This feature was considered for inclusion into the edx-platform code because it
|
||||
imports several models from the inner workings of the core functionality in
|
||||
order to query them. This goes in accordance with the section further guidance
|
||||
of the ADR.
|
||||
|
||||
|
||||
Decision
|
||||
--------
|
||||
|
||||
Locate the Survey Report Application in the edx-platform repository under
|
||||
`openedx/features`.
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
CLI command to generate survey report.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from openedx.features.survey_report.api import generate_report
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to generate a new survey report with
|
||||
non-sensitive data.
|
||||
"""
|
||||
|
||||
help = """
|
||||
This command will create a new survey report using some
|
||||
models to get:
|
||||
- Total number of courses offered
|
||||
- Currently active learners
|
||||
...
|
||||
learners ever registered, and generated certificates.
|
||||
"""
|
||||
|
||||
def handle(self, *_args, **_options):
|
||||
try:
|
||||
generate_report()
|
||||
except Exception as error:
|
||||
raise CommandError(f'An error has occurred while survey report was generating. {error}') from error
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Survey report has been generated successfully.'))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Test for generate_report command.
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from openedx.features.survey_report.models import SurveyReport
|
||||
|
||||
|
||||
class GenerateReportTest(TestCase):
|
||||
"""
|
||||
Test for generate_report command.
|
||||
"""
|
||||
@override_settings(SURVEY_REPORT_EXTRA_DATA={'extra_data': 'extra_data'})
|
||||
@mock.patch('openedx.features.survey_report.queries.get_course_enrollments')
|
||||
@mock.patch('openedx.features.survey_report.queries.get_generated_certificates')
|
||||
@mock.patch('openedx.features.survey_report.queries.get_registered_learners')
|
||||
@mock.patch('openedx.features.survey_report.queries.get_recently_active_users')
|
||||
@mock.patch('openedx.features.survey_report.queries.get_unique_courses_offered')
|
||||
def test_generate_report(self, mock_get_unique_courses_offered, mock_get_recently_active_users,
|
||||
mock_get_registered_learners, mock_get_generated_certificates,
|
||||
mock_get_course_enrollments):
|
||||
"""
|
||||
Test that generate_report command creates a survey report.
|
||||
"""
|
||||
mock_get_unique_courses_offered.return_value = 1
|
||||
mock_get_recently_active_users.return_value = 2
|
||||
mock_get_registered_learners.return_value = 3
|
||||
mock_get_generated_certificates.return_value = 4
|
||||
mock_get_course_enrollments.return_value = 5
|
||||
out = StringIO()
|
||||
call_command('generate_report', stdout=out)
|
||||
|
||||
survey_report = SurveyReport.objects.last()
|
||||
assert survey_report.courses_offered == 1
|
||||
assert survey_report.learners == 2
|
||||
assert survey_report.registered_learners == 3
|
||||
assert survey_report.generated_certificates == 4
|
||||
assert survey_report.enrollments == 5
|
||||
assert survey_report.extra_data == {'extra_data': 'extra_data'}
|
||||
32
openedx/features/survey_report/migrations/0001_initial.py
Normal file
32
openedx/features/survey_report/migrations/0001_initial.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-03 20:07
|
||||
|
||||
from django.db import migrations, models
|
||||
import jsonfield.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SurveyReport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('courses_offered', models.BigIntegerField()),
|
||||
('learners', models.BigIntegerField()),
|
||||
('registered_learners', models.BigIntegerField()),
|
||||
('enrollments', models.BigIntegerField()),
|
||||
('generated_certificates', models.BigIntegerField()),
|
||||
('extra_data', jsonfield.fields.JSONField(blank=True, default=dict, help_text='Extra information for instance data')),
|
||||
('created_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'get_latest_by': 'created_at',
|
||||
},
|
||||
),
|
||||
]
|
||||
37
openedx/features/survey_report/models.py
Normal file
37
openedx/features/survey_report/models.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Survey Report models.
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from jsonfield import JSONField
|
||||
|
||||
|
||||
class SurveyReport(models.Model):
|
||||
"""
|
||||
This model stores information to automate the way of gathering impact data from the openedx project.
|
||||
|
||||
.. no_pii:
|
||||
|
||||
fields:
|
||||
- courses_offered: Total number of active unique courses.
|
||||
- learner: Recently active users with login in some weeks.
|
||||
- registered_learners: Total number of users ever registered in the platform.
|
||||
- enrollments: Total number of active enrollments in the platform.
|
||||
- generated_certificates: Total number of generated certificates.
|
||||
- extra_data: Extra information that will be saved in the report, E.g: site_name, openedx-release.
|
||||
"""
|
||||
courses_offered = models.BigIntegerField()
|
||||
learners = models.BigIntegerField()
|
||||
registered_learners = models.BigIntegerField()
|
||||
enrollments = models.BigIntegerField()
|
||||
generated_certificates = models.BigIntegerField()
|
||||
extra_data = JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Extra information for instance data",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
get_latest_by = 'created_at'
|
||||
92
openedx/features/survey_report/queries.py
Normal file
92
openedx/features/survey_report/queries.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Queries to get data from database.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.db.models import Count, OuterRef, Q, Subquery
|
||||
|
||||
from common.djangoapps.util.query import read_replica_or_default
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from lms.djangoapps.grades.models import PersistentCourseGrade
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MIN_ENROLLS_ACTIVE_COURSE: int = 5
|
||||
|
||||
|
||||
def get_unique_courses_offered() -> int:
|
||||
"""
|
||||
Get total number of unique course that started before today and have an open date,
|
||||
or have not finished yet, whose number of enrollments is greater than MIN_ENROLLS_ACTIVE_COURSE.
|
||||
"""
|
||||
log.info("Getting the total number of unique courses offered...")
|
||||
total = CourseOverview.objects.annotate(
|
||||
count=Subquery(
|
||||
CourseEnrollment.objects
|
||||
.filter(course_id=OuterRef('id'))
|
||||
.values('course_id')
|
||||
.annotate(count=Count('course_id'))
|
||||
.values('count')
|
||||
))\
|
||||
.filter(count__gt=MIN_ENROLLS_ACTIVE_COURSE)\
|
||||
.filter(start__lt=datetime.now())\
|
||||
.filter(Q(end__isnull=True) | Q(end__gt=datetime.now()))\
|
||||
.using(read_replica_or_default())\
|
||||
.count()
|
||||
log.info("Getting the total number of unique courses offered... DONE")
|
||||
return total
|
||||
|
||||
|
||||
def get_recently_active_users(weeks: int) -> int:
|
||||
"""
|
||||
Get total number of users with last login in the last weeks.
|
||||
|
||||
Args:
|
||||
weeks (int): number of weeks since the last login to considerate as an active learner.
|
||||
"""
|
||||
log.info("Getting the total number of recently active users...")
|
||||
total = User.objects.filter(last_login__gte=datetime.now() - timedelta(weeks=weeks))\
|
||||
.using(read_replica_or_default())\
|
||||
.count()
|
||||
log.info("Getting total number of recently active users... DONE")
|
||||
return total
|
||||
|
||||
|
||||
def get_registered_learners() -> int:
|
||||
"""
|
||||
Get total number of active learners registered.
|
||||
"""
|
||||
log.info("Getting the total number of ever registered learners...")
|
||||
total = User.objects.filter(is_active=True)\
|
||||
.using(read_replica_or_default())\
|
||||
.count()
|
||||
log.info("Getting the total number of ever registered learners... DONE")
|
||||
return total
|
||||
|
||||
|
||||
def get_generated_certificates() -> int:
|
||||
"""
|
||||
Get total number of generated certificates.
|
||||
"""
|
||||
log.info("Getting the total number of generated certificates...")
|
||||
total = PersistentCourseGrade.objects.filter(passed_timestamp__isnull=False)\
|
||||
.using(read_replica_or_default())\
|
||||
.count()
|
||||
log.info("Getting the total number of generated certificates... DONE")
|
||||
return total
|
||||
|
||||
|
||||
def get_course_enrollments() -> int:
|
||||
"""
|
||||
Get total number of enrollments from users that aren't staff. Course staff members will be included.
|
||||
"""
|
||||
log.info("Getting the total number of course enrollments...")
|
||||
total = CourseEnrollment.objects.filter(is_active=True, user__is_superuser=False, user__is_staff=False)\
|
||||
.using(read_replica_or_default())\
|
||||
.count()
|
||||
log.info("Getting the total number of course enrollments... DONE")
|
||||
return total
|
||||
0
openedx/features/survey_report/tests/__init__.py
Normal file
0
openedx/features/survey_report/tests/__init__.py
Normal file
104
openedx/features/survey_report/tests/test_query_methods.py
Normal file
104
openedx/features/survey_report/tests/test_query_methods.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Test for survey report commands.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from lms.djangoapps.grades.models import PersistentCourseGrade
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.features.survey_report.queries import (
|
||||
get_course_enrollments,
|
||||
get_recently_active_users,
|
||||
get_generated_certificates,
|
||||
get_registered_learners,
|
||||
get_unique_courses_offered
|
||||
)
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
|
||||
class TestSurveyReportCommands(ModuleStoreTestCase):
|
||||
"""
|
||||
Test for survey report query methods.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup for users and courses.
|
||||
"""
|
||||
super().setUp()
|
||||
self.store = modulestore() # lint-amnesty, pylint: disable=protected-access
|
||||
self.first_course = CourseFactory.create(
|
||||
org="test", course="course1", display_name="run1"
|
||||
)
|
||||
self.user = UserFactory.create(username='test_user', email='test@example.com', password='password')
|
||||
self.user1 = UserFactory.create(username='test_user1', email='test1@example.com', password='password')
|
||||
self.user2 = UserFactory.create(username='test_user2', email='test2@example.com', password='password')
|
||||
self.user3 = UserFactory.create(username='test_user3', email='test3@example.com', password='password')
|
||||
self.user4 = UserFactory.create(username='test_user4', email='test4@example.com', password='password')
|
||||
self.user5 = UserFactory.create(username='test_user5', email='test5@example.com', password='password')
|
||||
|
||||
def test_get_unique_courses_offered(self):
|
||||
"""
|
||||
Test that get_unique_courses_offered returns the correct number of courses.
|
||||
"""
|
||||
course_overview = CourseOverviewFactory.create(id=self.first_course.id, start="2019-01-01", end="2024-01-01")
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user1, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user2, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user3, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user4, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user5, course_id=course_overview.id)
|
||||
assert get_unique_courses_offered() == 1
|
||||
|
||||
def test_get_recently_active_users(self):
|
||||
"""
|
||||
Test that get_currently_learners returns the correct number of learners.
|
||||
"""
|
||||
self.user.last_login = datetime.now() - timedelta(days=1)
|
||||
self.user.save()
|
||||
self.user1.last_login = datetime.now() - timedelta(weeks=2)
|
||||
self.user1.save()
|
||||
self.user2.last_login = datetime.now() - timedelta(weeks=4)
|
||||
self.user2.save()
|
||||
assert get_recently_active_users(weeks=3) == 2
|
||||
|
||||
def test_get_learners_registered(self):
|
||||
"""
|
||||
Test that get_learners_registered returns the correct number of learners.
|
||||
"""
|
||||
assert get_registered_learners() == 7
|
||||
|
||||
def test_get_generated_certificates(self):
|
||||
"""
|
||||
Test that get_generated_certificates returns the correct number of certificates.
|
||||
"""
|
||||
course_grade_params = {
|
||||
"user_id": self.user.id,
|
||||
"course_id": self.first_course.id,
|
||||
"percent_grade": 77.7,
|
||||
"letter_grade": "pass",
|
||||
"passed": True,
|
||||
"passed_timestamp": datetime.now(),
|
||||
}
|
||||
PersistentCourseGrade.update_or_create(**course_grade_params)
|
||||
assert get_generated_certificates() == 1
|
||||
|
||||
def test_get_course_enrollments(self):
|
||||
"""
|
||||
Test that get_course_enrollments returns the correct number of enrollments.
|
||||
"""
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
self.user1.is_staff = True
|
||||
self.user1.save()
|
||||
course_overview = CourseOverviewFactory.create(id=self.first_course.id, start="2019-01-01", end="2024-01-01")
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user1, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user2, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user3, course_id=course_overview.id)
|
||||
CourseEnrollmentFactory.create(user=self.user4, course_id=course_overview.id)
|
||||
assert get_course_enrollments() == 3
|
||||
Reference in New Issue
Block a user