From bfd212b6d8d91e4cd03936a39fe4e362bd024e6c Mon Sep 17 00:00:00 2001 From: Alejandro Cardenas Date: Tue, 15 Nov 2022 17:47:21 -0500 Subject: [PATCH] [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 Co-authored-by: David Ormsbee Co-authored-by: Maria Grimaldi --- lms/envs/common.py | 3 + lms/envs/production.py | 3 + lms/envs/test.py | 3 + openedx/features/survey_report/README.rst | 11 ++ openedx/features/survey_report/__init__.py | 0 openedx/features/survey_report/api.py | 37 +++++++ openedx/features/survey_report/apps.py | 12 ++ .../decisions/0001-addition-to-core-repo.rst | 29 +++++ .../survey_report/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/generate_report.py | 31 ++++++ .../management/commands/tests/__init__.py | 0 .../commands/tests/test_generate_report.py | 44 ++++++++ .../survey_report/migrations/0001_initial.py | 32 ++++++ .../survey_report/migrations/__init__.py | 0 openedx/features/survey_report/models.py | 37 +++++++ openedx/features/survey_report/queries.py | 92 ++++++++++++++++ .../features/survey_report/tests/__init__.py | 0 .../survey_report/tests/test_query_methods.py | 104 ++++++++++++++++++ 19 files changed, 438 insertions(+) create mode 100644 openedx/features/survey_report/README.rst create mode 100644 openedx/features/survey_report/__init__.py create mode 100644 openedx/features/survey_report/api.py create mode 100644 openedx/features/survey_report/apps.py create mode 100644 openedx/features/survey_report/docs/decisions/0001-addition-to-core-repo.rst create mode 100644 openedx/features/survey_report/management/__init__.py create mode 100644 openedx/features/survey_report/management/commands/__init__.py create mode 100644 openedx/features/survey_report/management/commands/generate_report.py create mode 100644 openedx/features/survey_report/management/commands/tests/__init__.py create mode 100644 openedx/features/survey_report/management/commands/tests/test_generate_report.py create mode 100644 openedx/features/survey_report/migrations/0001_initial.py create mode 100644 openedx/features/survey_report/migrations/__init__.py create mode 100644 openedx/features/survey_report/models.py create mode 100644 openedx/features/survey_report/queries.py create mode 100644 openedx/features/survey_report/tests/__init__.py create mode 100644 openedx/features/survey_report/tests/test_query_methods.py diff --git a/lms/envs/common.py b/lms/envs/common.py index 075a993393..c7d0780afb 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/lms/envs/production.py b/lms/envs/production.py index d1c5a08aa2..8455f76948 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -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', {}) diff --git a/lms/envs/test.py b/lms/envs/test.py index 94bbb61709..634c2bb627 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -671,3 +671,6 @@ MFE_CONFIG_OVERRIDES = { "LOGO_URL": "https://courses.example.com/yourmfe-logo.png", }, } + +############## Settings for survey report ############## +SURVEY_REPORT_EXTRA_DATA = {} diff --git a/openedx/features/survey_report/README.rst b/openedx/features/survey_report/README.rst new file mode 100644 index 0000000000..06c120ba9c --- /dev/null +++ b/openedx/features/survey_report/README.rst @@ -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. diff --git a/openedx/features/survey_report/__init__.py b/openedx/features/survey_report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/api.py b/openedx/features/survey_report/api.py new file mode 100644 index 0000000000..dc1f621a6a --- /dev/null +++ b/openedx/features/survey_report/api.py @@ -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() diff --git a/openedx/features/survey_report/apps.py b/openedx/features/survey_report/apps.py new file mode 100644 index 0000000000..34786a4877 --- /dev/null +++ b/openedx/features/survey_report/apps.py @@ -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' diff --git a/openedx/features/survey_report/docs/decisions/0001-addition-to-core-repo.rst b/openedx/features/survey_report/docs/decisions/0001-addition-to-core-repo.rst new file mode 100644 index 0000000000..ec76842686 --- /dev/null +++ b/openedx/features/survey_report/docs/decisions/0001-addition-to-core-repo.rst @@ -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`. diff --git a/openedx/features/survey_report/management/__init__.py b/openedx/features/survey_report/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/management/commands/__init__.py b/openedx/features/survey_report/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/management/commands/generate_report.py b/openedx/features/survey_report/management/commands/generate_report.py new file mode 100644 index 0000000000..cb94f9ad2e --- /dev/null +++ b/openedx/features/survey_report/management/commands/generate_report.py @@ -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.')) diff --git a/openedx/features/survey_report/management/commands/tests/__init__.py b/openedx/features/survey_report/management/commands/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/management/commands/tests/test_generate_report.py b/openedx/features/survey_report/management/commands/tests/test_generate_report.py new file mode 100644 index 0000000000..84e7a38325 --- /dev/null +++ b/openedx/features/survey_report/management/commands/tests/test_generate_report.py @@ -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'} diff --git a/openedx/features/survey_report/migrations/0001_initial.py b/openedx/features/survey_report/migrations/0001_initial.py new file mode 100644 index 0000000000..46d816a363 --- /dev/null +++ b/openedx/features/survey_report/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/openedx/features/survey_report/migrations/__init__.py b/openedx/features/survey_report/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/models.py b/openedx/features/survey_report/models.py new file mode 100644 index 0000000000..cb3353ac2f --- /dev/null +++ b/openedx/features/survey_report/models.py @@ -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' diff --git a/openedx/features/survey_report/queries.py b/openedx/features/survey_report/queries.py new file mode 100644 index 0000000000..011a0d5b47 --- /dev/null +++ b/openedx/features/survey_report/queries.py @@ -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 diff --git a/openedx/features/survey_report/tests/__init__.py b/openedx/features/survey_report/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/survey_report/tests/test_query_methods.py b/openedx/features/survey_report/tests/test_query_methods.py new file mode 100644 index 0000000000..fd1a41f504 --- /dev/null +++ b/openedx/features/survey_report/tests/test_query_methods.py @@ -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