Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c90861
feat: add survey_report djangoapp
Henrrypg Oct 18, 2022
e143333
Merge branch 'master' into survey_report
Alec4r Oct 22, 2022
1a85171
feat: add survey report cli command and query methods
Alec4r Oct 26, 2022
c06ad16
fix: add init file
Henrrypg Oct 26, 2022
484ee03
Merge branch 'master' into survey_report
Alec4r Oct 27, 2022
fc85c63
Merge branch 'master' into survey_report
Alec4r Nov 4, 2022
2a11601
refactor: change model fields
Alec4r Oct 31, 2022
4ff6352
refactor: rename application file and rename methods
Alec4r Oct 31, 2022
5387bce
refactor: add is_active to get course enrollments
Alec4r Oct 31, 2022
2930cbe
refactor: rename method to get active users
Alec4r Nov 3, 2022
9fc6cac
refactor: remove fields useless
Alec4r Nov 3, 2022
ececb95
test: rename mocks in command tests
Alec4r Nov 3, 2022
e57b10d
test: update test name
Alec4r Nov 4, 2022
614bf1c
docs: add README file
Alec4r Nov 8, 2022
c633c43
docs: add selection criteria to get unique courses
Alec4r Nov 8, 2022
d7d6934
docs: update README
Alec4r Nov 9, 2022
5db5ef9
test: remove useless mocks and use default modulestore
Alec4r Nov 9, 2022
4a2117d
docs: change command error message
Alec4r Nov 9, 2022
f1a611a
docs: add docs decisions
Alec4r Nov 10, 2022
2c7de0c
docs: Update openedx/features/survey_report/management/commands/gener…
Alec4r Nov 10, 2022
de046f3
docs: add fields descriptions
Alec4r Nov 10, 2022
eacaeba
docs: add logs for each query
Alec4r Nov 11, 2022
a603a83
style: add blank lines
Alec4r Nov 11, 2022
8383568
refactor: rename variables and add a constant for weeks
Alec4r Nov 11, 2022
a26f453
refactor: add constant MIN_ENROLLS_ACTIVE_COURSE
Alec4r Nov 12, 2022
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
3 changes: 3 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# Agreements
'openedx.core.djangoapps.agreements',

# Survey reports
'openedx.features.survey_report',

# User and group management via edx-django-utils
'edx_django_utils.user',

Expand Down
3 changes: 3 additions & 0 deletions lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,3 +1080,6 @@ def get_env_setting(setting):
"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', {})
3 changes: 3 additions & 0 deletions lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,3 +671,6 @@
"LOGO_URL": "https://courses.example.com/yourmfe-logo.png",
},
}

############## Settings for survey report ##############
SURVEY_REPORT_EXTRA_DATA = {}
11 changes: 11 additions & 0 deletions openedx/features/survey_report/README.rst
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
felipemontoya marked this conversation as resolved.
Empty file.
37 changes: 37 additions & 0 deletions openedx/features/survey_report/api.py
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions openedx/features/survey_report/apps.py
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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`.
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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.'))
Empty file.
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions openedx/features/survey_report/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
Empty file.
37 changes: 37 additions & 0 deletions openedx/features/survey_report/models.py
Original file line number Diff line number Diff line change
@@ -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.

Comment thread
felipemontoya marked this conversation as resolved.
.. 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()
Comment thread
felipemontoya marked this conversation as resolved.
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)
Comment thread
felipemontoya marked this conversation as resolved.

class Meta:
ordering = ["-created_at"]
get_latest_by = 'created_at'
92 changes: 92 additions & 0 deletions openedx/features/survey_report/queries.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Comment thread
ormsbee marked this conversation as resolved.
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')
Comment thread
ormsbee marked this conversation as resolved.
))\
.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()
Comment thread
felipemontoya marked this conversation as resolved.
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
Empty file.
Loading