Skip to content
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
4 changes: 3 additions & 1 deletion lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,8 @@ def get_env_setting(setting):

############## Settings for survey report ##############
SURVEY_REPORT_EXTRA_DATA = ENV_TOKENS.get('SURVEY_REPORT_EXTRA_DATA', {})

SURVEY_REPORT_ENDPOINT = ENV_TOKENS.get('SURVEY_REPORT_ENDPOINT',
'https://hooks.zapier.com/hooks/catch/11595998/3ouwv7m/')
ANONYMOUS_SURVEY_REPORT = False

AVAILABLE_DISCUSSION_TOURS = ENV_TOKENS.get('AVAILABLE_DISCUSSION_TOURS', [])
2 changes: 2 additions & 0 deletions lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,3 +674,5 @@

############## Settings for survey report ##############
SURVEY_REPORT_EXTRA_DATA = {}
SURVEY_REPORT_ENDPOINT = "https://example.com/survey_report"
ANONYMOUS_SURVEY_REPORT = False
62 changes: 60 additions & 2 deletions openedx/features/survey_report/api.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
"""
Contains the logic to manage survey report model.
"""
import requests

from django.conf import settings
from django.forms.models import model_to_dict

from openedx.features.survey_report.models import SurveyReport
from openedx.features.survey_report.models import (
SurveyReport,
SurveyReportUpload,
SurveyReportAnonymousSiteID,
SURVEY_REPORT_ERROR,
SURVEY_REPORT_GENERATED
)
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 .models import SURVEY_REPORT_ERROR, SURVEY_REPORT_GENERATED

MAX_WEEKS_SINCE_LAST_LOGIN: int = 4

Expand Down Expand Up @@ -49,6 +56,57 @@ def generate_report() -> None:
except (Exception, ) as update_report_error:
update_report(survey_report.id, {"state": SURVEY_REPORT_ERROR})
raise Exception(update_report_error) from update_report_error
return survey_report.id


def get_id() -> str:
""" Generate id for the survey report."""
if not settings.ANONYMOUS_SURVEY_REPORT:
return settings.LMS_BASE
return str(SurveyReportAnonymousSiteID.objects.get_or_create()[0].id)


def send_report_to_external_api(report_id: int) -> None:
"""
Send a report to Openedx endpoint and save the response in the SurveyReportUpload model.

endpoint: The value of the setting SURVEY_REPORT_ENDPOINT

content_type: JSON

payload:
- 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.
- created_at: Date when the report was generated, this date will send with format '%m-%d-%Y %H:%M:%S'
"""
report = SurveyReport.objects.get(id=report_id)

fields = [
"courses_offered",
"learners",
"registered_learners",
"generated_certificates",
"enrollments",
]

data = model_to_dict(report, fields=fields)
data["id"] = get_id()
data["extra_data"] = report.extra_data
data["created_at"] = report.created_at.strftime("%m-%d-%Y %H:%M:%S")

request = requests.post(settings.SURVEY_REPORT_ENDPOINT, json=data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ormsbee do you have something different in mind for sending this report?

We have explored things like having a zappier that formats and sends to google docs, but we are open to anything.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should have a real URL enabled by default (if one exists yet), and a timeout on this request so it doesn't block for too long. But other than that, this seems fine to me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there an endpoint to send to at this point?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@ormsbee No, we don't have an endpoint for it, @felipemontoya some approach for this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I can produce a Zappier webhook and we leave that as the default for now.
https://help.zapier.com/hc/en-us/articles/8496326446989

Webhooks are available only in paid plans. I can put it in the edunext account.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@felipemontoya @jmakowski1123 @e0d I added the site name in a new field and I added a setting to make the site name anonymous.

new survey report file: https://docs.google.com/spreadsheets/d/1PWhWZ0XN6tEo8xhDh3kTSuGMrEYAE6tDBQu2kdF8tZA/edit?usp=sharing

setting: ANONYMOUS_SURVEY_REPORT is False by default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How is the hash computed? Ideally it would be a consistent value -- it wouldn't change if the secret key was updated for example.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@e0d I'm using a sha256 from the hashlib so the hash will be always the same.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

At the risk of bikeshedding on this one, if we really want to have a fixed ID that each site carries for anonymization purposes, please make it so that the value is randomly generated and stored in the database. A simple hash of the base URL is too easy to figure out because there are relatively few Open edX sites out there, and we can run SHA256 against all of them. Salting doesn't help because either the salt is randomly generated per-site (in which case it might as well be the anonymous value), or it's linked against some secret that might change.

We've walked up and down this path a couple of times with anonymous user IDs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@ormsbee I tough 2 difference options for this:

Setting: We can define a new setting to save the ID, the problem with this is that the ID could be changed by the user whenever he wants, so maybe is not the best idea.

Model: We can create a new model just to save the ID, this will help us to persist the ID without problems and the ID will never change, we can use a get_or_create in the send method.

What do you think?


request.raise_for_status()

SurveyReportUpload.objects.create(
report=report,
status_code=request.status_code,
request_details=request.content
)


def update_report(survey_report_id: int, data: dict) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from django.core.management.base import BaseCommand, CommandError

from openedx.features.survey_report.api import generate_report
from openedx.features.survey_report.api import generate_report, send_report_to_external_api


class Command(BaseCommand):
Expand All @@ -22,12 +22,25 @@ class Command(BaseCommand):
learners ever registered, and generated certificates.
"""

def handle(self, *_args, **_options):
def add_arguments(self, parser):
parser.add_argument(
'--no-send',
action='store_true',
help='Do not send the report after generated.'
)

def handle(self, *_args, **options):
try:
generate_report()
report = generate_report()
self.stdout.write(self.style.SUCCESS('Survey report has been generated successfully.'))
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.')
)
if not options['no_send']:
try:
send_report_to_external_api(report_id=report)
self.stdout.write(self.style.SUCCESS('Survey report has been sent successfully.'))
except Exception as send_error:
raise CommandError(
f'An error has occurred while survey report was sending. {send_error}'
) from send_error
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def test_generate_report(self, mock_get_report_data):
}
mock_get_report_data.return_value = report_test_data
out = StringIO()
call_command('generate_report', stdout=out)
call_command('generate_report', no_send=True, stdout=out)
Comment thread
Alec4r marked this conversation as resolved.
Outdated

survey_report = SurveyReport.objects.last()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.16 on 2023-02-01 15:16

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('survey_report', '0003_add_state_field_and_add_default_values_to_fields'),
]

operations = [
migrations.CreateModel(
name='SurveyReportUpload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sent_at', models.DateTimeField(auto_now=True, help_text='Date when the report was sent to external api.')),
('status_code', models.IntegerField(help_text='Request status code.')),
('request_details', models.CharField(blank=True, help_text='Information about the send request.', max_length=255, null=True)),
('report', models.ForeignKey(help_text='The report that was sent.', on_delete=django.db.models.deletion.CASCADE, to='survey_report.surveyreport')),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.16 on 2023-02-10 15:45

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

dependencies = [
('survey_report', '0004_surveyreportupload'),
]

operations = [
migrations.CreateModel(
name='SurveyReportAnonymousSiteID',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
],
),
]
38 changes: 38 additions & 0 deletions openedx/features/survey_report/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Survey Report models.
"""

import uuid

from django.db import models
from jsonfield import JSONField

Expand Down Expand Up @@ -58,3 +60,39 @@ class SurveyReport(models.Model):
class Meta:
ordering = ["-created_at"]
get_latest_by = 'created_at'


class SurveyReportUpload(models.Model):
"""
This model stores the result of the POST request made to an external service after generating a survey report.

.. no_pii:

fields:
- sent_at: Date when the report was sent.
- report: The report that was sent.
- status: Request status code.
- request_details: Information about the send request.
"""
sent_at = models.DateTimeField(auto_now=True, help_text="Date when the report was sent to external api.")
report = models.ForeignKey(SurveyReport, on_delete=models.CASCADE, help_text="The report that was sent.")
status_code = models.IntegerField(help_text="Request status code.")
request_details = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Information about the send request."
)

def is_uploaded(self) -> bool:
return 200 <= self.status_code < 300


class SurveyReportAnonymousSiteID(models.Model):
"""
This model is just to save the identification which will be send to the external API when
the settings ANONYMOUS_SURVEY_REPORT is defined.

.. no_pii:
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)