Commit 44b09dd8 by Fred Smith

Merge pull request #454 from edx-solutions/rc/2015-06-15

Rc/2015 06 15
parents b651789c 4ae4f7c9
...@@ -418,6 +418,40 @@ class UsersApiTests(ModuleStoreTestCase): ...@@ -418,6 +418,40 @@ class UsersApiTests(ModuleStoreTestCase):
self.assertEqual(response.data['is_active'], False) self.assertEqual(response.data['is_active'], False)
self.assertIsNotNone(response.data['created']) self.assertIsNotNone(response.data['created'])
def test_user_detail_invalid_email(self):
test_uri = '{}/{}'.format(self.users_base_uri, self.user.id)
data = {
'email': 'fail'
}
response = self.do_post(test_uri, data)
self.assertEqual(response.status_code, 400)
self.assertIn('Invalid email address', response.content)
def test_user_detail_duplicate_email(self):
user2 = UserFactory()
test_uri = '{}/{}'.format(self.users_base_uri, self.user.id)
test_uri2 = '{}/{}'.format(self.users_base_uri, user2.id)
data = {
'email': self.test_email
}
response = self.do_post(test_uri, data)
self.assertEqual(response.status_code, 200)
response = self.do_post(test_uri2, data)
self.assertEqual(response.status_code, 400)
self.assertIn('A user with that email address already exists.', response.content)
def test_user_detail_email_updated(self):
test_uri = '{}/{}'.format(self.users_base_uri, self.user.id)
new_email = 'test@example.com'
data = {
'email': new_email
}
self.assertNotEqual(self.user.email, new_email)
response = self.do_post(test_uri, data)
self.assertEqual(response.status_code, 200)
self.user = User.objects.get(id=self.user.id)
self.assertEqual(self.user.email, new_email)
def test_user_detail_post_duplicate_username(self): def test_user_detail_post_duplicate_username(self):
""" """
Create two users, then pass the same first username in request in order to update username of second user. Create two users, then pass the same first username in request in order to update username of second user.
...@@ -1435,6 +1469,8 @@ class UsersApiTests(ModuleStoreTestCase): ...@@ -1435,6 +1469,8 @@ class UsersApiTests(ModuleStoreTestCase):
response.data['level_of_education'], data["level_of_education"]) response.data['level_of_education'], data["level_of_education"])
self.assertEqual( self.assertEqual(
str(response.data['year_of_birth']), data["year_of_birth"]) str(response.data['year_of_birth']), data["year_of_birth"])
# This one's technically on the user model itself, but can be updated.
self.assertEqual(response.data['email'], data['email'])
def test_user_organizations_list(self): def test_user_organizations_list(self):
user_id = self.user.id user_id = self.user.id
......
...@@ -469,6 +469,25 @@ class UsersDetail(SecureAPIView): ...@@ -469,6 +469,25 @@ class UsersDetail(SecureAPIView):
if is_staff is not None: if is_staff is not None:
existing_user.is_staff = is_staff existing_user.is_staff = is_staff
response_data['is_staff'] = existing_user.is_staff response_data['is_staff'] = existing_user.is_staff
email = request.DATA.get('email')
if email is not None:
email_fail = False
try:
validate_email(email)
except ValidationError:
email_fail = True
response_data['message'] = _('Invalid email address {}.').format(repr(email))
if email != existing_user.email:
try:
# Email addresses need to be unique in the LMS, though Django doesn't enforce it directly.
User.objects.get(email=email)
email_fail = True
response_data['message'] = _('A user with that email address already exists.')
except ObjectDoesNotExist:
pass
if email_fail:
return Response(response_data, status=status.HTTP_400_BAD_REQUEST)
existing_user.email = email
existing_user.save() existing_user.save()
username = request.DATA.get('username', None) username = request.DATA.get('username', None)
......
...@@ -5,6 +5,8 @@ from datetime import datetime ...@@ -5,6 +5,8 @@ from datetime import datetime
from optparse import make_option from optparse import make_option
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
import os
from path import path
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -15,6 +17,7 @@ from student.models import CourseEnrollment ...@@ -15,6 +17,7 @@ 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
from xmodule.modulestore.django import modulestore
class DiscussionExportFields(object): class DiscussionExportFields(object):
...@@ -39,9 +42,11 @@ class Command(BaseCommand): ...@@ -39,9 +42,11 @@ class Command(BaseCommand):
Usage: Usage:
./manage.py lms export_discussion_participation course_key [dest_file] [OPTIONS] ./manage.py lms export_discussion_participation course_key [dest_file] [OPTIONS]
./manage.py lms export_discussion_participation [dest_directory] --all [OPTIONS]
* course_key - target course key (e.g. edX/DemoX/T1) * course_key - target course key (e.g. edX/DemoX/T1)
* dest_file - location of destination file (created if missing, overwritten if exists) * dest_file - location of destination file (created if missing, overwritten if exists)
* dest_directory - location to store all dumped files to. Will dump into the current directory otherwise.
OPTIONS: OPTIONS:
...@@ -49,6 +54,10 @@ class Command(BaseCommand): ...@@ -49,6 +54,10 @@ class Command(BaseCommand):
* end-date - date time in iso8601 format (YYYY-MM-DD hh:mm:ss). Filters discussion participation stats * 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 by creation date: no threads/comments/replies created *after* this date is included in calculation
FLAGS:
* cohorted_only - only dump cohorted inline discussion threads
* all - Dump all social stats at once into a particular directory.
Examples: Examples:
* `./manage.py lms export_discussion_participation <course_key>` - exports entire discussion participation stats for * `./manage.py lms export_discussion_participation <course_key>` - exports entire discussion participation stats for
...@@ -62,9 +71,13 @@ class Command(BaseCommand): ...@@ -62,9 +71,13 @@ class Command(BaseCommand):
* `./manage.py lms export_discussion_participation <course_key> --end-date=<iso8601 datetime> * `./manage.py lms export_discussion_participation <course_key> --end-date=<iso8601 datetime>
--thread-type=[discussion|question]` - exports discussion participation stats for a course for --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 threads/comments/replies created before specified date, including only threads of specified type
* `./manage.py lms export_discussion_participation <course_key> --cohorted_only` - exports only cohorted discussion
participation stats for a course; output is written to default location (same folder, auto-generated file name)
""" """
THREAD_TYPE_PARAMETER = 'thread_type' THREAD_TYPE_PARAMETER = 'thread_type'
END_DATE_PARAMETER = 'end_date' END_DATE_PARAMETER = 'end_date'
ALL_PARAMETER = 'all'
COHORTED_ONLY_PARAMETER = 'cohorted_only'
args = "<course_id> <output_file_location>" args = "<course_id> <output_file_location>"
...@@ -86,6 +99,18 @@ class Command(BaseCommand): ...@@ -86,6 +99,18 @@ class Command(BaseCommand):
default=None, default=None,
help='Include threads, comments and replies created before the supplied date (iso8601 format)' help='Include threads, comments and replies created before the supplied date (iso8601 format)'
), ),
make_option(
'--all',
action='store_true',
dest=ALL_PARAMETER,
default=False,
),
make_option(
'--cohorted_only',
action='store_true',
dest=COHORTED_ONLY_PARAMETER,
default=False,
)
) )
def _get_filter_string_representation(self, options): def _get_filter_string_representation(self, options):
...@@ -103,8 +128,34 @@ class Command(BaseCommand): ...@@ -103,8 +128,34 @@ class Command(BaseCommand):
"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): @staticmethod
""" Executes command """ def get_all_courses():
"""
Gets all courses. Made into a separate function because patch isn't cooperating.
"""
return modulestore().get_courses()
def dump_all(self, *args, **options):
if len(args) > 1:
raise CommandError("May not specify course and destination root directory with the --all option.")
args = list(args)
try:
dir_name = path(args.pop())
except IndexError:
dir_name = path('social_stats')
if not os.path.exists(dir_name):
os.makedirs(dir_name)
for course in self.get_all_courses():
raw_course_key = unicode(course.location.course_key)
args = [
raw_course_key,
dir_name / self.get_default_file_location(raw_course_key)
]
self.dump_one(*args, **options)
def dump_one(self, *args, **options):
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:
...@@ -125,12 +176,22 @@ class Command(BaseCommand): ...@@ -125,12 +176,22 @@ class Command(BaseCommand):
if not course: if not course:
raise CommandError("Invalid course id: {}".format(course_key)) raise CommandError("Invalid course id: {}".format(course_key))
target_discussion_ids = None
if options.get(self.COHORTED_ONLY_PARAMETER, False):
cohorted_discussions = course.cohort_config.get('cohorted_inline_discussions', None)
if not cohorted_discussions:
raise CommandError("Only cohorted discussions are marked for export, "
"but no cohorted discussions found for the course")
else:
target_discussion_ids = cohorted_discussions
raw_end_date = options.get(self.END_DATE_PARAMETER, None) raw_end_date = options.get(self.END_DATE_PARAMETER, None)
end_date = dateutil.parser.parse(raw_end_date) if raw_end_date else None end_date = dateutil.parser.parse(raw_end_date) if raw_end_date else None
data = Extractor().extract( data = Extractor().extract(
course_key, course_key,
end_date=end_date, end_date=end_date,
thread_type=(options.get(self.THREAD_TYPE_PARAMETER, None)) thread_type=(options.get(self.THREAD_TYPE_PARAMETER, None)),
thread_ids=target_discussion_ids,
) )
filter_str = self._get_filter_string_representation(options) filter_str = self._get_filter_string_representation(options)
...@@ -139,8 +200,14 @@ class Command(BaseCommand): ...@@ -139,8 +200,14 @@ class Command(BaseCommand):
with open(output_file_location, 'wb') as output_stream: with open(output_file_location, 'wb') as output_stream:
Exporter(output_stream).export(data) Exporter(output_stream).export(data)
self.stdout.write("Success!\n") def handle(self, *args, **options):
""" Executes command """
if options.get(self.ALL_PARAMETER, False):
self.dump_all(*args, **options)
else:
self.dump_one(*args, **options)
self.stdout.write("Success!\n")
class Extractor(object): class Extractor(object):
""" Extracts discussion participation data from db and cs_comments_service """ """ Extracts discussion participation data from db and cs_comments_service """
...@@ -165,11 +232,13 @@ class Extractor(object): ...@@ -165,11 +232,13 @@ class Extractor(object):
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, end_date=None, thread_type=None): def _get_social_stats(self, course_key, end_date=None, thread_type=None, thread_ids=None):
""" Gets social stats for course with specified filter parameters """ """ Gets social stats for course with specified filter parameters """
return { return {
int(user_id): data for user_id, data int(user_id): data for user_id, data
in User.all_social_stats(str(course_key), end_date=end_date, thread_type=thread_type).iteritems() in User.all_social_stats(
str(course_key), end_date=end_date, thread_type=thread_type, thread_ids=thread_ids
).iteritems()
} }
def _merge_user_data_and_social_stats(self, userdata, social_stats): def _merge_user_data_and_social_stats(self, userdata, social_stats):
...@@ -187,13 +256,14 @@ class Extractor(object): ...@@ -187,13 +256,14 @@ class Extractor(object):
result.append(utils.merge_dict(user_record, stats)) result.append(utils.merge_dict(user_record, stats))
return result return result
def extract(self, course_key, end_date=None, thread_type=None): def extract(self, course_key, end_date=None, thread_type=None, thread_ids=None):
""" Extracts and merges data according to course key and filter parameters """ """ Extracts and merges data according to course key and filter parameters """
users = self._get_users(course_key) users = self._get_users(course_key)
social_stats = self._get_social_stats( social_stats = self._get_social_stats(
course_key, course_key,
end_date=end_date, end_date=end_date,
thread_type=thread_type thread_type=thread_type,
thread_ids=thread_ids
) )
return self._merge_user_data_and_social_stats(users, social_stats) return self._merge_user_data_and_social_stats(users, social_stats)
......
...@@ -62,8 +62,8 @@ def render_press_release(request, slug): ...@@ -62,8 +62,8 @@ def render_press_release(request, slug):
def render_404(request): def render_404(request):
return HttpResponseNotFound(render_to_string('static_templates/404.html', {})) return HttpResponseNotFound(render_to_string('static_templates/404-plain.html', {}))
def render_500(request): def render_500(request):
return HttpResponseServerError(render_to_string('static_templates/server-error.html', {})) return HttpResponseServerError(render_to_string('static_templates/server-error-plain.html', {}))
...@@ -119,9 +119,9 @@ class User(models.Model): ...@@ -119,9 +119,9 @@ 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, thread_type=None): def all_social_stats(cls, course_id, end_date=None, thread_type=None, thread_ids=None):
""" Get social stats for all users participating in a course """ """ Get social stats for all users participating in a course """
return get_user_social_stats('*', course_id, end_date=end_date, thread_type=thread_type) return get_user_social_stats('*', course_id, end_date=end_date, thread_type=thread_type, thread_ids=thread_ids)
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)
...@@ -156,7 +156,7 @@ class User(models.Model): ...@@ -156,7 +156,7 @@ class User(models.Model):
self._update_from_response(response) self._update_from_response(response)
def get_user_social_stats(user_id, course_id, end_date=None, thread_type=None): def get_user_social_stats(user_id, course_id, end_date=None, thread_type=None, thread_ids=None):
""" Queries cs_comments_service for social_stats """ """ Queries cs_comments_service for social_stats """
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")
...@@ -167,9 +167,11 @@ def get_user_social_stats(user_id, course_id, end_date=None, thread_type=None): ...@@ -167,9 +167,11 @@ def get_user_social_stats(user_id, course_id, end_date=None, thread_type=None):
params.update({'end_date': end_date.isoformat()}) params.update({'end_date': end_date.isoformat()})
if thread_type: if thread_type:
params.update({'thread_type': thread_type}) params.update({'thread_type': thread_type})
if thread_ids:
params.update({'thread_ids': ",".join(thread_ids)})
response = perform_request( response = perform_request(
'get', 'post',
url, url,
params params
) )
......
<html>
<body>
<h1>Page not found</h1>
</body>
</html>
<html>
<body>
<h1>
There has been a 500 error on the servers
</h1>
</body>
</html>
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
-e git+https://github.com/edx-solutions/xblock-mentoring.git@82a4219b865d12db80ac57bda43fef9e30bec3f1#egg=xblock-mentoring -e git+https://github.com/edx-solutions/xblock-mentoring.git@82a4219b865d12db80ac57bda43fef9e30bec3f1#egg=xblock-mentoring
-e git+https://github.com/edx-solutions/xblock-image-explorer.git@21b9bcc4f2c7917463ab18a596161ac6c58c9c4a#egg=xblock-image-explorer -e git+https://github.com/edx-solutions/xblock-image-explorer.git@21b9bcc4f2c7917463ab18a596161ac6c58c9c4a#egg=xblock-image-explorer
-e git+https://github.com/edx-solutions/xblock-drag-and-drop.git@92ee2055a16899090a073e1df81e35d5293ad767#egg=xblock-drag-and-drop -e git+https://github.com/edx-solutions/xblock-drag-and-drop.git@92ee2055a16899090a073e1df81e35d5293ad767#egg=xblock-drag-and-drop
-e git+https://github.com/edx-solutions/xblock-drag-and-drop-v2.git@ed24dbef753e411f2c9b17339ae21f3a89a8531c#egg=xblock-drag-and-drop-v2 -e git+https://github.com/edx-solutions/xblock-drag-and-drop-v2.git@5736ed8774b92c8b8396b5bd455f8a8fb80295fb#egg=xblock-drag-and-drop-v2
-e git+https://github.com/edx-solutions/xblock-ooyala.git@ac49b30452aff0cc34cace6a34b788e100490f24#egg=xblock-ooyala -e git+https://github.com/edx-solutions/xblock-ooyala.git@ac49b30452aff0cc34cace6a34b788e100490f24#egg=xblock-ooyala
-e git+https://github.com/edx-solutions/xblock-group-project.git@dd8eaf16b3bc7b7be3fb392d588328dadef56c00#egg=xblock-group-project -e git+https://github.com/edx-solutions/xblock-group-project.git@dd8eaf16b3bc7b7be3fb392d588328dadef56c00#egg=xblock-group-project
-e git+https://github.com/edx-solutions/xblock-adventure.git@effa22006bb6528bc6d3788787466eb4e74e1161#egg=xblock-adventure -e git+https://github.com/edx-solutions/xblock-adventure.git@effa22006bb6528bc6d3788787466eb4e74e1161#egg=xblock-adventure
......
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