[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:
Alejandro Cardenas
2022-11-15 17:47:21 -05:00
committed by GitHub
parent f419d6b194
commit bfd212b6d8
19 changed files with 438 additions and 0 deletions

View File

@@ -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',

View File

@@ -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', {})

View File

@@ -671,3 +671,6 @@ MFE_CONFIG_OVERRIDES = {
"LOGO_URL": "https://courses.example.com/yourmfe-logo.png",
},
}
############## Settings for survey report ##############
SURVEY_REPORT_EXTRA_DATA = {}

View 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.

View 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()

View 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'

View File

@@ -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`.

View File

@@ -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.'))

View File

@@ -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'}

View 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',
},
),
]

View 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'

View 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

View 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