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 csv
import dateutil
from datetime import datetime from datetime import datetime
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
...@@ -9,11 +12,14 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -9,11 +12,14 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course from courseware.courses import get_course
from student.models import CourseEnrollment from student.models import CourseEnrollment
from lms.lib.comment_client.user import User from lms.lib.comment_client.user import User
import django_comment_client.utils as utils import django_comment_client.utils as utils
class _Fields: class _Fields:
""" Container class for field names """
USER_ID = u"id"
USERNAME = u"username" USERNAME = u"username"
EMAIL = u"email" EMAIL = u"email"
FIRST_NAME = u"first_name" FIRST_NAME = u"first_name"
...@@ -27,6 +33,7 @@ class _Fields: ...@@ -27,6 +33,7 @@ class _Fields:
def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0, comments_generated=0): def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0, comments_generated=0):
""" Builds social stats with values specified """
return { return {
_Fields.THREADS: threads, _Fields.THREADS: threads,
_Fields.COMMENTS: comments, _Fields.COMMENTS: comments,
...@@ -38,28 +45,85 @@ def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0, ...@@ -38,28 +45,85 @@ def _make_social_stats(threads=0, comments=0, replies=0, upvotes=0, followers=0,
class Command(BaseCommand): 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>" 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 = [ 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.THREADS, _Fields.COMMENTS, _Fields.REPLIES,
_Fields.UPVOTES, _Fields.FOLOWERS, _Fields.COMMENTS_GENERATED _Fields.UPVOTES, _Fields.FOLOWERS, _Fields.COMMENTS_GENERATED
] ]
def _get_users(self, course_key): def _get_users(self, course_key):
""" Returns users enrolled to a course as dictionary user_id => user """
users = CourseEnrollment.users_enrolled_in(course_key) users = CourseEnrollment.users_enrolled_in(course_key)
return {user.id: user for user in users} 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 { return {
int(user_id): data int(user_id): data for user_id, data
for user_id, data in User.all_social_stats(str(course_key)).iteritems() 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): def _merge_user_data_and_social_stats(self, userdata, social_stats):
""" Merges user data (email, username, etc.) and discussion stats """
result = [] result = []
for user_id, user in userdata.iteritems(): for user_id, user in userdata.iteritems():
user_record = { user_record = {
_Fields.USER_ID: user.id,
_Fields.USERNAME: user.username, _Fields.USERNAME: user.username,
_Fields.EMAIL: user.email, _Fields.EMAIL: user.email,
_Fields.FIRST_NAME: user.first_name, _Fields.FIRST_NAME: user.first_name,
...@@ -70,22 +134,34 @@ class Command(BaseCommand): ...@@ -70,22 +134,34 @@ class Command(BaseCommand):
return result return result
def _output(self, data, output_stream): 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 = csv.DictWriter(output_stream, self.row_order)
csv_writer.writeheader() csv_writer.writeheader()
for row in sorted(data, key=lambda item: item['username']): 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} to_write = {key: value for key, value in row.items() if key in self.row_order}
csv_writer.writerow(to_write) 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): def get_default_file_location(self, course_key):
""" Builds default destination file name """
return utils.format_filename( return utils.format_filename(
"social_stats_{course}_{date:%Y_%m_%d_%H_%M_%S}.csv".format(course=course_key, date=datetime.utcnow()) "social_stats_{course}_{date:%Y_%m_%d_%H_%M_%S}.csv".format(course=course_key, date=datetime.utcnow())
) )
def handle(self, *args, **options): def handle(self, *args, **options):
""" Executes command """
if not args: if not args:
raise CommandError("Course id not specified") raise CommandError("Course id not specified")
if len(args) > 2: 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] raw_course_key = args[0]
if len(args) == 1: if len(args) == 1:
...@@ -103,10 +179,18 @@ class Command(BaseCommand): ...@@ -103,10 +179,18 @@ class Command(BaseCommand):
raise CommandError("Invalid course id: {}".format(course_key)) raise CommandError("Invalid course id: {}".format(course_key))
users = self._get_users(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) 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: with open(output_file_location, 'wb') as output_stream:
self._output(merged_data, output_stream) self._output(merged_data, output_stream)
......
...@@ -649,6 +649,7 @@ class RenderMustacheTests(TestCase): ...@@ -649,6 +649,7 @@ class RenderMustacheTests(TestCase):
@ddt.ddt @ddt.ddt
class FormatFilenameTests(TestCase): class FormatFilenameTests(TestCase):
""" Tests format filename utility function """
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
("normal.txt", "normal.txt"), ("normal.txt", "normal.txt"),
...@@ -659,4 +660,5 @@ class FormatFilenameTests(TestCase): ...@@ -659,4 +660,5 @@ class FormatFilenameTests(TestCase):
("contains spaces.org", "contains_spaces.org"), ("contains spaces.org", "contains_spaces.org"),
) )
def test_format_filename(self, raw_filename, expected_output): def test_format_filename(self, raw_filename, expected_output):
self.assertEqual(utils.format_filename(raw_filename), expected_output) """ Tests that format_filename produces expected output for certain inputs """
\ No newline at end of file self.assertEqual(utils.format_filename(raw_filename), expected_output)
...@@ -118,8 +118,8 @@ class User(models.Model): ...@@ -118,8 +118,8 @@ class User(models.Model):
return get_user_social_stats(self.id, self.course_id, end_date=end_date) return get_user_social_stats(self.id, self.course_id, end_date=end_date)
@classmethod @classmethod
def all_social_stats(cls, course_id, end_date=None): def all_social_stats(cls, course_id, end_date=None, thread_type=None):
return get_user_social_stats('*', course_id, end_date=end_date) return get_user_social_stats('*', course_id, end_date=end_date, thread_type=thread_type)
def _retrieve(self, *args, **kwargs): def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes) url = self.url(action='get', params=self.attributes)
...@@ -151,7 +151,8 @@ class User(models.Model): ...@@ -151,7 +151,8 @@ class User(models.Model):
raise raise
self._update_from_response(response) 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: if not course_id:
raise CommentClientRequestError("Must provide course_id when retrieving social stats for the user") 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): ...@@ -159,6 +160,8 @@ def get_user_social_stats(user_id, course_id, end_date=None):
params = {'course_id': course_id} params = {'course_id': course_id}
if end_date: if end_date:
params.update({'end_date': end_date.isoformat()}) params.update({'end_date': end_date.isoformat()})
if thread_type:
params.update({'thread_type': thread_type})
response = perform_request( response = perform_request(
'get', '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