Commit 7938634e by E. Kolpakov

Added support for filtering by thread type and creation date.

parent 870f2625
""" Management command to export discussion participation statistics per course to csv """
import csv
import dateutil
from datetime import datetime
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
......@@ -9,11 +12,14 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course
from student.models import CourseEnrollment
from lms.lib.comment_client.user import User
import django_comment_client.utils as utils
class _Fields:
""" Container class for field names """
USER_ID = u"id"
USERNAME = u"username"
EMAIL = u"email"
FIRST_NAME = u"first_name"
......@@ -27,6 +33,7 @@ class _Fields:
def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0, comments_generated=0):
""" Builds social stats with values specified """
return {
_Fields.THREADS: threads,
_Fields.COMMENTS: comments,
......@@ -38,28 +45,85 @@ def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0,
class Command(BaseCommand):
"""
Exports discussion participation per course
Usage:
./manage.py lms export_discussion_participation course_key [dest_file] [OPTIONS]
* course_key - target course key (e.g. edX/DemoX/T1)
* dest_file - location of destination file (created if missing, overwritten if exists)
OPTIONS:
* thread-type - one of {discussion, question}. Filters discussion participation stats by discussion thread type.
* end-date - date time in iso8601 format (YYYY-MM-DD hh:mm:ss). Filters discussion participation stats
by creation date: no threads/comments/replies created *after* this date is included in calculation
Examples:
* `./manage.py lms export_discussion_participation <course_key>` - exports entire discussion participation stats for
a course; output is written to default location (same folder, auto-generated file name)
* `./manage.py lms export_discussion_participation <course_key> <file_name>` - exports entire discussion
participation stats for a course; output is written chosen file (created if missing, overwritten if exists)
* `./manage.py lms export_discussion_participation <course_key> --thread-type=[discussion|question]` - exports
discussion participation stats for a course for chosen thread type only.
* `./manage.py lms export_discussion_participation <course_key> --end-date=<iso8601 datetime>` - exports discussion
participation stats for a course for threads/comments/replies created before specified date.
* `./manage.py lms export_discussion_participation <course_key> --end-date=<iso8601 datetime>
--thread-type=[discussion|question]` - exports discussion participation stats for a course for
threads/comments/replies created before specified date, including only threads of specified type
"""
THREAD_TYPE_PARAMETER = 'thread_type'
END_DATE_PARAMETER = 'end_date'
args = "<course_id> <output_file_location>"
option_list = BaseCommand.option_list + (
make_option(
'--thread-type',
action='store',
type='choice',
dest=THREAD_TYPE_PARAMETER,
choices=('discussion', 'question'),
default=None,
help='Filter threads, comments and replies by thread type'
),
make_option(
'--end-date',
action='store',
type='string',
dest=END_DATE_PARAMETER,
default=None,
help='Include threads, comments and replies created before the supplied date (iso8601 format)'
),
)
row_order = [
_Fields.USERNAME, _Fields.EMAIL, _Fields.FIRST_NAME, _Fields.LAST_NAME,
_Fields.USERNAME, _Fields.EMAIL, _Fields.FIRST_NAME, _Fields.LAST_NAME, _Fields.USER_ID,
_Fields.THREADS, _Fields.COMMENTS, _Fields.REPLIES,
_Fields.UPVOTES, _Fields.FOLOWERS, _Fields.COMMENTS_GENERATED
]
def _get_users(self, course_key):
""" Returns users enrolled to a course as dictionary user_id => user """
users = CourseEnrollment.users_enrolled_in(course_key)
return {user.id: user for user in users}
def _get_social_stats(self, course_key):
def _get_social_stats(self, course_key, end_date=None, thread_type=None):
""" Gets social stats for course with specified filter parameters """
date = dateutil.parser.parse(end_date) if end_date else None
return {
int(user_id): data
for user_id, data in User.all_social_stats(str(course_key)).iteritems()
int(user_id): data for user_id, data
in User.all_social_stats(str(course_key), end_date=date, thread_type=thread_type).iteritems()
}
def _merge_user_data_and_social_stats(self, userdata, social_stats):
""" Merges user data (email, username, etc.) and discussion stats """
result = []
for user_id, user in userdata.iteritems():
user_record = {
_Fields.USER_ID: user.id,
_Fields.USERNAME: user.username,
_Fields.EMAIL: user.email,
_Fields.FIRST_NAME: user.first_name,
......@@ -70,22 +134,34 @@ class Command(BaseCommand):
return result
def _output(self, data, output_stream):
""" Exports data in csv format to specified output stream """
csv_writer = csv.DictWriter(output_stream, self.row_order)
csv_writer.writeheader()
for row in sorted(data, key=lambda item: item['username']):
to_write = {key: value for key, value in row.items() if key in self.row_order}
csv_writer.writerow(to_write)
def _get_filter_string_representation(self, options):
""" Builds human-readable filter parameters representation """
filter_strs = []
if options.get(self.THREAD_TYPE_PARAMETER, None):
filter_strs.append("Thread type:{}".format(options[self.THREAD_TYPE_PARAMETER]))
if options.get(self.END_DATE_PARAMETER, None):
filter_strs.append("Created before:{}".format(options[self.END_DATE_PARAMETER]))
return ", ".join(filter_strs) if filter_strs else "all"
def get_default_file_location(self, course_key):
""" Builds default destination file name """
return utils.format_filename(
"social_stats_{course}_{date:%Y_%m_%d_%H_%M_%S}.csv".format(course=course_key, date=datetime.utcnow())
)
def handle(self, *args, **options):
""" Executes command """
if not args:
raise CommandError("Course id not specified")
if len(args) > 2:
raise CommandError("Only one course id may be specifiied")
raise CommandError("Only one course id may be specified")
raw_course_key = args[0]
if len(args) == 1:
......@@ -103,10 +179,18 @@ class Command(BaseCommand):
raise CommandError("Invalid course id: {}".format(course_key))
users = self._get_users(course_key)
social_stats = self._get_social_stats(course_key)
social_stats = self._get_social_stats(
course_key,
end_date=options.get(self.END_DATE_PARAMETER, None),
thread_type=options.get(self.THREAD_TYPE_PARAMETER, None)
)
merged_data = self._merge_user_data_and_social_stats(users, social_stats)
self.stdout.write("Writing social stats to {}\n".format(output_file_location))
filter_str = self._get_filter_string_representation(options)
self.stdout.write("Writing social stats ({filters}) to {file}\n".format(
filters=filter_str, file=output_file_location
))
with open(output_file_location, 'wb') as output_stream:
self._output(merged_data, output_stream)
......
......@@ -649,6 +649,7 @@ class RenderMustacheTests(TestCase):
@ddt.ddt
class FormatFilenameTests(TestCase):
""" Tests format filename utility function """
@ddt.unpack
@ddt.data(
("normal.txt", "normal.txt"),
......@@ -659,4 +660,5 @@ class FormatFilenameTests(TestCase):
("contains spaces.org", "contains_spaces.org"),
)
def test_format_filename(self, raw_filename, expected_output):
self.assertEqual(utils.format_filename(raw_filename), expected_output)
\ No newline at end of file
""" Tests that format_filename produces expected output for certain inputs """
self.assertEqual(utils.format_filename(raw_filename), expected_output)
......@@ -118,8 +118,8 @@ class User(models.Model):
return get_user_social_stats(self.id, self.course_id, end_date=end_date)
@classmethod
def all_social_stats(cls, course_id, end_date=None):
return get_user_social_stats('*', course_id, end_date=end_date)
def all_social_stats(cls, course_id, end_date=None, thread_type=None):
return get_user_social_stats('*', course_id, end_date=end_date, thread_type=thread_type)
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
......@@ -151,7 +151,8 @@ class User(models.Model):
raise
self._update_from_response(response)
def get_user_social_stats(user_id, course_id, end_date=None):
def get_user_social_stats(user_id, course_id, end_date=None, thread_type=None):
if not course_id:
raise CommentClientRequestError("Must provide course_id when retrieving social stats for the user")
......@@ -159,6 +160,8 @@ def get_user_social_stats(user_id, course_id, end_date=None):
params = {'course_id': course_id}
if end_date:
params.update({'end_date': end_date.isoformat()})
if thread_type:
params.update({'thread_type': thread_type})
response = perform_request(
'get',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment