diff --git a/.gitignore b/.gitignore index 28b78aedbc..3653c832fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ Gemfile.lock .env/ lms/static/sass/*.css cms/static/sass/*.css +lms/lib/comment_client/python diff --git a/common/djangoapps/student/management/commands/sync_user_info.py b/common/djangoapps/student/management/commands/sync_user_info.py new file mode 100644 index 0000000000..04257e2a5d --- /dev/null +++ b/common/djangoapps/student/management/commands/sync_user_info.py @@ -0,0 +1,19 @@ +## +## One-off script to sync all user information to the discussion service (later info will be synced automatically) + + +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +import comment_client as cc + + +class Command(BaseCommand): + help = \ +''' +Sync all user ids, usernames, and emails to the discussion +service''' + + def handle(self, *args, **options): + for user in User.objects.all().iterator(): + cc_user = cc.User.from_django_user(user) + cc_user.save() diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 4a8de5f5ed..b5243f9ab7 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -45,6 +45,15 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django_countries import CountryField +from django.db.models.signals import post_save +from django.dispatch import receiver + +from functools import partial + +import comment_client as cc + +import logging + from xmodule.modulestore.django import modulestore #from cache_toolbox import cache_model, cache_relation @@ -254,8 +263,18 @@ def add_user_to_default_group(user, group): utg.users.add(User.objects.get(username=user)) utg.save() +# @receiver(post_save, sender=User) +def update_user_information(sender, instance, created, **kwargs): + try: + cc_user = cc.User.from_django_user(instance) + cc_user.save() + except Exception as e: + log = logging.getLogger("mitx.discussion") + log.error(unicode(e)) + log.error("update user info to discussion failed for user with id: " + str(instance.id)) + ########################## REPLICATION SIGNALS ################################# -@receiver(post_save, sender=User) +# @receiver(post_save, sender=User) def replicate_user_save(sender, **kwargs): user_obj = kwargs['instance'] if not should_replicate(user_obj): @@ -263,7 +282,7 @@ def replicate_user_save(sender, **kwargs): for course_db_name in db_names_to_replicate_to(user_obj.id): replicate_user(user_obj, course_db_name) -@receiver(post_save, sender=CourseEnrollment) +# @receiver(post_save, sender=CourseEnrollment) def replicate_enrollment_save(sender, **kwargs): """This is called when a Student enrolls in a course. It has to do the following: @@ -289,12 +308,12 @@ def replicate_enrollment_save(sender, **kwargs): user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id) replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id) -@receiver(post_delete, sender=CourseEnrollment) +# @receiver(post_delete, sender=CourseEnrollment) def replicate_enrollment_delete(sender, **kwargs): enrollment_obj = kwargs['instance'] return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) -@receiver(post_save, sender=UserProfile) +# @receiver(post_save, sender=UserProfile) def replicate_userprofile_save(sender, **kwargs): """We just updated the UserProfile (say an update to the name), so push that change to all Course DBs that we're enrolled in.""" @@ -404,4 +423,3 @@ def should_replicate(instance): .format(instance)) return False return True - diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index b33678fbac..cde95153fd 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -8,6 +8,7 @@ import logging from datetime import datetime from django.test import TestCase +from nose.plugins.skip import SkipTest from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY @@ -22,6 +23,7 @@ class ReplicationTest(TestCase): def test_user_replication(self): """Test basic user replication.""" + raise SkipTest() portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass') portal_user.first_name='Rusty' portal_user.last_name='Skids' @@ -80,6 +82,7 @@ class ReplicationTest(TestCase): def test_enrollment_for_existing_user_info(self): """Test the effect of Enrolling in a class if you've already got user data to be copied over.""" + raise SkipTest() # Create our User portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass') portal_user.first_name = "Jack" @@ -143,6 +146,8 @@ class ReplicationTest(TestCase): def test_enrollment_for_user_info_after_enrollment(self): """Test the effect of modifying User data after you've enrolled.""" + raise SkipTest() + # Create our User portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass') portal_user.first_name = "Patty" diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 8c513e7aec..7b89510a12 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -29,6 +29,7 @@ Each input type takes the xml tree as 'element', the previous answer as 'value', import logging import re import shlex # for splitting quoted strings +import json from lxml import etree import xml.sax.saxutils as saxutils @@ -336,6 +337,11 @@ def filesubmission(element, value, status, render_template, msg=''): Upload a single file (e.g. for programming assignments) ''' eid = element.get('id') + escapedict = {'"': '"'} + allowed_files = json.dumps(element.get('allowed_files', '').split()) + allowed_files = saxutils.escape(allowed_files, escapedict) + required_files = json.dumps(element.get('required_files', '').split()) + required_files = saxutils.escape(required_files, escapedict) # Check if problem has been queued queue_len = 0 @@ -345,7 +351,8 @@ def filesubmission(element, value, status, render_template, msg=''): msg = 'Submitted to grader. (Queue length: %s)' % queue_len context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, - 'queue_len': queue_len + 'queue_len': queue_len, 'allowed_files': allowed_files, + 'required_files': required_files } html = render_template("filesubmission.html", context) return etree.XML(html) diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index fccf469015..a859dc8458 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -1,5 +1,5 @@
-
+
% if state == 'unsubmitted': % elif state == 'correct': diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index cc5dfd183a..c123756655 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -34,6 +34,7 @@ setup( "video = xmodule.video_module:VideoDescriptor", "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", "videosequence = xmodule.seq_module:SequenceDescriptor", + "discussion = xmodule.discussion_module:DiscussionDescriptor", ] } ) diff --git a/common/lib/xmodule/xmodule/backcompat_module.py b/common/lib/xmodule/xmodule/backcompat_module.py index 1e63bfadf8..40ffd46d1c 100644 --- a/common/lib/xmodule/xmodule/backcompat_module.py +++ b/common/lib/xmodule/xmodule/backcompat_module.py @@ -68,7 +68,7 @@ class SemanticSectionDescriptor(XModuleDescriptor): the child element """ xml_object = etree.fromstring(xml_data) - system.error_tracker("WARNING: the <{0}> tag is deprecated. Please do not use in new content." + system.error_tracker("WARNING: the <{0}> tag is deprecated. Please do not use in new content." .format(xml_object.tag)) if len(xml_object) == 1: diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py new file mode 100644 index 0000000000..c029d95098 --- /dev/null +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -0,0 +1,26 @@ +from lxml import etree + +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor + +import json + +class DiscussionModule(XModule): + def get_html(self): + context = { + 'discussion_id': self.discussion_id, + } + return self.system.render_template('discussion/_discussion_module.html', context) + + def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + + if isinstance(instance_state, str): + instance_state = json.loads(instance_state) + xml_data = etree.fromstring(definition['data']) + self.discussion_id = xml_data.attrib['id'] + self.title = xml_data.attrib['for'] + self.discussion_category = xml_data.attrib['discussion_category'] + +class DiscussionDescriptor(RawDescriptor): + module_class = DiscussionModule diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index a242757357..23fa4d70fe 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -160,24 +160,42 @@ class @Problem max_filesize = 4*1000*1000 # 4 MB file_too_large = false file_not_selected = false + required_files_not_submitted = false + unallowed_file_submitted = false + + errors = [] @inputs.each (index, element) -> if element.type is 'file' + required_files = $(element).data("required_files") + allowed_files = $(element).data("allowed_files") for file in element.files + if allowed_files.length != 0 and file.name not in allowed_files + unallowed_file_submitted = true + errors.push "You submitted #{file.name}; only #{allowed_files} are allowed." + if file.name in required_files + required_files.splice(required_files.indexOf(file.name), 1) if file.size > max_filesize file_too_large = true - alert 'Submission aborted! Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)' + errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)' fd.append(element.id, file) if element.files.length == 0 file_not_selected = true fd.append(element.id, '') # In case we want to allow submissions with no file + if required_files.length != 0 + required_files_not_submitted = true + errors.push "You did not submit the required files: #{required_files}." else fd.append(element.id, element.value) + if file_not_selected - alert 'Submission aborted! You did not select any files to submit' + errors.push 'You did not select any files to submit' - abort_submission = file_too_large or file_not_selected + if errors.length > 0 + alert errors.join("\n") + + abort_submission = file_too_large or file_not_selected or unallowed_file_submitted or required_files_not_submitted settings = type: "POST" diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 832a5ec7eb..38291be2ef 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -3,6 +3,7 @@ class @Sequence @el = $(element).find('.sequence') @contents = @$('.seq_contents') @id = @el.data('id') + @modx_url = @el.data('course_modx_root') @initProgress() @bind() @render parseInt(@el.data('position')) @@ -76,13 +77,14 @@ class @Sequence if @position != new_position if @position != undefined @mark_visited @position - $.postWithPrefix "/modx/#{@id}/goto_position", position: new_position + modx_full_url = @modx_url + '/' + @id + '/goto_position' + $.postWithPrefix modx_full_url, position: new_position @mark_active new_position @$('#seq_content').html @contents.eq(new_position - 1).text() XModule.loadModules('display', @$('#seq_content')) - MathJax.Hub.Queue(["Typeset", MathJax.Hub]) + MathJax.Hub.Queue(["Typeset", MathJax.Hub, "seq_content"]) # NOTE: Actually redundant. Some other MathJax call also being performed @position = new_position @toggleArrows() @hookUpProgressEvent() @@ -91,7 +93,7 @@ class @Sequence event.preventDefault() new_position = $(event.target).data('element') Logger.log "seq_goto", old: @position, new: new_position, id: @id - + # On Sequence chage, destroy any existing polling thread # for queued submissions, see ../capa/display.coffee if window.queuePollerID diff --git a/doc/discussion.md b/doc/discussion.md new file mode 100644 index 0000000000..4f8ab9a01a --- /dev/null +++ b/doc/discussion.md @@ -0,0 +1,159 @@ +# Running the discussion service + +## Instruction for Mac + +## Installing Mongodb + +If you haven't done so already: + + brew install mongodb + +Make sure that you have mongodb running. You can simply open a new terminal tab and type: + + mongod + +## Installing elasticsearch + + brew install elasticsearch + +For debugging, it's often more convenient to have elasticsearch running in a terminal tab instead of in background. To do so, simply open a new terminal tab and then type: + + elasticsearch -f + +## Setting up the discussion service + +First, make sure that you have access to the [github repository](https://github.com/rll/cs_comments_service). If this were not the case, send an email to dementrock@gmail.com. + +First go into the mitx_all directory. Then type + + git clone git@github.com:rll/cs_comments_service.git + cd cs_comments_service/ + +If you see a prompt asking "Do you wish to trust this .rvmrc file?", type "y" + +Now if you see this error "Gemset 'cs_comments_service' does not exist," run the following command to create the gemset and then use the rvm environment manually: + + rvm gemset create 'cs_comments_service' + rvm use 1.9.3@cs_comments_service + +Now use the following command to install required packages: + + bundle install + +The following command creates database indexes: + + bundle exec rake db:init + +Now use the following command to generate seeds (basically some random comments in Latin): + + bundle exec rake db:seed + +It's done! Launch the app now: + + ruby app.rb + +## Running the delayed job worker + +In the discussion service, notifications are handled asynchronously using a third party gem called delayed_job. If you want to test this functionality, run the following command in a separate tab: + + bundle exec rake jobs:work + +## Initialize roles and permissions + +To fully test the discussion forum, you might want to act as a moderator or an administrator. Currently, moderators can manage everything in the forum, and administrator can manage everything plus assigning and revoking moderator status of other users. + +First make sure that the database is up-to-date: + + rake django-admin[syncdb] + rake django-admin[migrate] + +For convenience, add the following environment variables to the terminal (assuming that you're using configuration set lms.envs.dev): + + export DJANGO_SETTINGS_MODULE=lms.envs.dev + export PYTHONPATH=. + +Now initialzie roles and permissions: + + django-admin.py seed_permissions_roles + +To assign yourself as a moderator, use the following command (assuming your username is "test", and the course id is "MITx/6.002x/2012_Fall"): + + django-admin.py assign_role test Moderator "MITx/6.002x/2012_Fall" + +To assign yourself as an administrator, use the following command + + django-admin.py assign_role test Administrator "MITx/6.002x/2012_Fall" + +## Some other useful commands + +### generate seeds for a specific forum +The seed generating command above assumes that you have the following discussion tags somewhere in the course data: + + + + + +For example, you can insert them into overview section as following: + + +
+ + +
+
+ <%include file="sections/introseq.xml"/> +
+
+ + See the Lab Introduction or Interactive Lab Usage Handout for information on how to do the lab + + + +
+
+ + + + +
+
+ +Currently, only the attribute "id" is actually used, which identifies discussion forum. In the code for the data generator, the corresponding lines are: + + generate_comments_for("video_1") + generate_comments_for("lab_1") + generate_comments_for("lab_2") + +We also have a command for generating comments within a forum with the specified id: + + bundle exec rake db:generate_comments[type_the_discussion_id_here] + +For instance, if you want to generate comments for a new discussion tab named "lab_3", then use the following command + + bundle exec rake db:generate_comments[lab_3] + +### Running tests for the service + + bundle exec rspec + +Warning: the development and test environments share the same elasticsearch index. After running tests, search may not work in the development environment. You simply need to reindex: + + bundle exec rake db:reindex_search + +### debugging the service + +You can use the following command to launch a console within the service environment: + + bundle exec rake console + +### show user roles and permissions + +Use the following command to see the roles and permissions of a user in a given course (assuming, again, that the username is "test"): + + django-admin.py show_permissions moderator + +You need to make sure that the environment variables are exported. Otherwise you would need to do + + django-admin.py show_permissions moderator --settings=lms.envs.dev --pythonpath=. diff --git a/install.txt b/install.txt index fa82b11a5c..37a6e50986 100644 --- a/install.txt +++ b/install.txt @@ -74,5 +74,4 @@ There is also a script "create-dev-env.sh" that automates these steps. $ django-admin.py syncdb --settings=envs.dev --pythonpath=. $ django-admin.py migrate --settings=envs.dev --pythonpath=. $ django-admin.py runserver --settings=envs.dev --pythonpath=. - diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py index b1d9f1cf26..cfe802bbd7 100644 --- a/lms/djangoapps/course_wiki/views.py +++ b/lms/djangoapps/course_wiki/views.py @@ -80,8 +80,8 @@ def course_wiki_redirect(request, course_id): urlpath = URLPath.create_article( root, course_slug, - title=course.title, - content="This is the wiki for " + course.title + ".", + title=course.number, + content="{0}\n===\nThis is the wiki for **{1}**'s _{2}_.".format(course.number, course.org, course.title), user_message="Course page automatically created.", user=None, ip_address=None, diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 951c48822e..3d8d8b0ebe 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -155,8 +155,18 @@ def progress_summary(student, course, grader, student_module_cache): chapters = [] # Don't include chapters that aren't displayable (e.g. due to error) for c in course.get_display_items(): + # Skip if the chapter is hidden + hidden = c.metadata.get('hide_from_toc','false') + if hidden.lower() == 'true': + continue + sections = [] for s in c.get_display_items(): + # Skip if the section is hidden + hidden = s.metadata.get('hide_from_toc','false') + if hidden.lower() == 'true': + continue + # Same for sections graded = s.metadata.get('graded', False) scores = [] diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index b45101f664..fbd43b8247 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -167,6 +167,7 @@ def get_module(user, request, location, student_module_cache, course_id, positio shared_module = student_module_cache.lookup(descriptor.category, shared_state_key) + instance_state = instance_module.state if instance_module is not None else None shared_state = shared_module.state if shared_module is not None else None diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 2ab6fa0223..583628d1f2 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -3,6 +3,10 @@ import logging import urllib import itertools +from functools import partial + +from functools import partial + from django.conf import settings from django.core.context_processors import csrf from django.core.urlresolvers import reverse @@ -21,6 +25,11 @@ from courseware.courses import (get_course_with_access, get_courses_by_universit from models import StudentModuleCache from module_render import toc_for_course, get_module, get_section from student.models import UserProfile + +from multicourse import multicourse_settings + +from django_comment_client.utils import get_discussion_title + from student.models import UserTestGroup, CourseEnrollment from util.cache import cache, cache_if_anonymous from xmodule.course_module import CourseDescriptor @@ -29,6 +38,11 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.search import path_to_location +import comment_client + + + + log = logging.getLogger("mitx.courseware") template_imports = {'urllib': urllib} @@ -256,6 +270,26 @@ def university_profile(request, org_id): return render_to_response(template_file, context) +def render_notifications(request, course, notifications): + context = { + 'notifications': notifications, + 'get_discussion_title': partial(get_discussion_title, request=request, course=course), + 'course': course, + } + return render_to_string('notifications.html', context) + +@login_required +def news(request, course_id): + course = get_course_with_access(request.user, course_id, 'load') + + notifications = comment_client.get_notifications(request.user.id) + + context = { + 'course': course, + 'content': render_notifications(request, course, notifications), + } + + return render_to_response('news.html', context) @login_required @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -346,4 +380,3 @@ def instructor_dashboard(request, course_id): context = {'course': course, 'staff_access': True,} return render_to_response('courseware/instructor_dashboard.html', context) - diff --git a/lms/djangoapps/django_comment_client/__init__.py b/lms/djangoapps/django_comment_client/__init__.py new file mode 100644 index 0000000000..61f59d6f9b --- /dev/null +++ b/lms/djangoapps/django_comment_client/__init__.py @@ -0,0 +1,2 @@ +# call some function from permissions so that the post_save hook is imported +from permissions import assign_default_role diff --git a/lms/djangoapps/django_comment_client/base/__init__.py b/lms/djangoapps/django_comment_client/base/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py new file mode 100644 index 0000000000..f2cb4ccb15 --- /dev/null +++ b/lms/djangoapps/django_comment_client/base/urls.py @@ -0,0 +1,32 @@ +from django.conf.urls.defaults import url, patterns +import django_comment_client.base.views + +urlpatterns = patterns('django_comment_client.base.views', + + url(r'upload$', 'upload', name='upload'), + url(r'users/(?P\w+)/update_moderator_status$', 'update_moderator_status', name='update_moderator_status'), + url(r'threads/tags/autocomplete$', 'tags_autocomplete', name='tags_autocomplete'), + url(r'threads/(?P[\w\-]+)/update$', 'update_thread', name='update_thread'), + url(r'threads/(?P[\w\-]+)/reply$', 'create_comment', name='create_comment'), + url(r'threads/(?P[\w\-]+)/delete', 'delete_thread', name='delete_thread'), + url(r'threads/(?P[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'), + url(r'threads/(?P[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'), + url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'), + url(r'threads/(?P[\w\-]+)/follow$', 'follow_thread', name='follow_thread'), + url(r'threads/(?P[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'), + url(r'threads/(?P[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'), + + url(r'comments/(?P[\w\-]+)/update$', 'update_comment', name='update_comment'), + url(r'comments/(?P[\w\-]+)/endorse$', 'endorse_comment', name='endorse_comment'), + url(r'comments/(?P[\w\-]+)/reply$', 'create_sub_comment', name='create_sub_comment'), + url(r'comments/(?P[\w\-]+)/delete$', 'delete_comment', name='delete_comment'), + url(r'comments/(?P[\w\-]+)/upvote$', 'vote_for_comment', {'value': 'up'}, name='upvote_comment'), + url(r'comments/(?P[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'), + url(r'comments/(?P[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'), + + url(r'(?P[\w\-]+)/threads/create$', 'create_thread', name='create_thread'), + # TODO should we search within the board? + url(r'(?P[\w\-]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'), + url(r'(?P[\w\-]+)/follow$', 'follow_commentable', name='follow_commentable'), + url(r'(?P[\w\-]+)/unfollow$', 'unfollow_commentable', name='unfollow_commentable'), +) diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py new file mode 100644 index 0000000000..e619140993 --- /dev/null +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -0,0 +1,390 @@ +import time +import random +import os +import os.path +import logging +import urlparse +import functools + +import comment_client as cc +import django_comment_client.utils as utils + +from django.core import exceptions +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_POST, require_GET +from django.views.decorators import csrf +from django.core.files.storage import get_storage_class +from django.utils.translation import ugettext as _ +from django.conf import settings +from django.contrib.auth.models import User + +from mitxmako.shortcuts import render_to_response, render_to_string +from courseware.courses import get_course_with_access + + +from django_comment_client.utils import JsonResponse, JsonError, extract + +from django_comment_client.permissions import check_permissions_by_view +from django_comment_client.models import Role + +def permitted(fn): + @functools.wraps(fn) + def wrapper(request, *args, **kwargs): + def fetch_content(): + if "thread_id" in kwargs: + content = cc.Thread.find(kwargs["thread_id"]).to_dict() + elif "comment_id" in kwargs: + content = cc.Comment.find(kwargs["comment_id"]).to_dict() + else: + content = None + return content + + if check_permissions_by_view(request.user, kwargs['course_id'], fetch_content(), request.view_name): + return fn(request, *args, **kwargs) + else: + return JsonError("unauthorized") + return wrapper + +def ajax_content_response(request, course_id, content, template_name): + context = { + 'course_id': course_id, + 'content': content, + } + html = render_to_string(template_name, context) + annotated_content_info = utils.get_annotated_content_info(course_id, content, request.user) + return JsonResponse({ + 'html': html, + 'content': content, + 'annotated_content_info': annotated_content_info, + }) + +@require_POST +@login_required +@permitted +def create_thread(request, course_id, commentable_id): + post = request.POST + thread = cc.Thread(**extract(post, ['body', 'title', 'tags'])) + thread.update_attributes(**{ + 'anonymous' : post.get('anonymous', 'false').lower() == 'true', + 'commentable_id' : commentable_id, + 'course_id' : course_id, + 'user_id' : request.user.id, + }) + thread.save() + if post.get('auto_subscribe', 'false').lower() == 'true': + user = cc.User.from_django_user(request.user) + user.follow(thread) + if request.is_ajax(): + return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_create_thread.html') + else: + return JsonResponse(thread.to_dict()) + +@require_POST +@login_required +@permitted +def update_thread(request, course_id, thread_id): + thread = cc.Thread.find(thread_id) + thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags'])) + thread.save() + if request.is_ajax(): + return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_update_thread.html') + else: + return JsonResponse(thread.to_dict()) + +def _create_comment(request, course_id, thread_id=None, parent_id=None): + post = request.POST + comment = cc.Comment(**extract(post, ['body'])) + comment.update_attributes(**{ + 'anonymous' : post.get('anonymous', 'false').lower() == 'true', + 'user_id' : request.user.id, + 'course_id' : course_id, + 'thread_id' : thread_id, + 'parent_id' : parent_id, + }) + comment.save() + if post.get('auto_subscribe', 'false').lower() == 'true': + user = cc.User.from_django_user(request.user) + user.follow(comment.thread) + if request.is_ajax(): + return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_create_comment.html') + else: + return JsonResponse(comment.to_dict()) + +@require_POST +@login_required +@permitted +def create_comment(request, course_id, thread_id): + return _create_comment(request, course_id, thread_id=thread_id) + +@require_POST +@login_required +@permitted +def delete_thread(request, course_id, thread_id): + thread = cc.Thread.find(thread_id) + thread.delete() + return JsonResponse(thread.to_dict()) + +@require_POST +@login_required +@permitted +def update_comment(request, course_id, comment_id): + comment = cc.Comment.find(comment_id) + comment.update_attributes(**extract(request.POST, ['body'])) + comment.save() + if request.is_ajax(): + return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_update_comment.html') + else: + return JsonResponse(comment.to_dict()), + +@require_POST +@login_required +@permitted +def endorse_comment(request, course_id, comment_id): + comment = cc.Comment.find(comment_id) + comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true' + comment.save() + return JsonResponse(comment.to_dict()) + +@require_POST +@login_required +@permitted +def openclose_thread(request, course_id, thread_id): + thread = cc.Thread.find(thread_id) + thread.closed = request.POST.get('closed', 'false').lower() == 'true' + thread.save() + return JsonResponse(thread.to_dict()) + +@require_POST +@login_required +@permitted +def create_sub_comment(request, course_id, comment_id): + return _create_comment(request, course_id, parent_id=comment_id) + +@require_POST +@login_required +@permitted +def delete_comment(request, course_id, comment_id): + comment = cc.Comment.find(comment_id) + comment.delete() + return JsonResponse(comment.to_dict()) + +@require_POST +@login_required +@permitted +def vote_for_comment(request, course_id, comment_id, value): + user = cc.User.from_django_user(request.user) + comment = cc.Comment.find(comment_id) + user.vote(comment, value) + return JsonResponse(comment.to_dict()) + +@require_POST +@login_required +@permitted +def undo_vote_for_comment(request, course_id, comment_id): + user = cc.User.from_django_user(request.user) + comment = cc.Comment.find(comment_id) + user.unvote(comment) + return JsonResponse(comment.to_dict()) + +@require_POST +@login_required +@permitted +def vote_for_thread(request, course_id, thread_id, value): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + user.vote(thread, value) + return JsonResponse(thread.to_dict()) + +@require_POST +@login_required +@permitted +def undo_vote_for_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + user.unvote(thread) + return JsonResponse(thread.to_dict()) + + +@require_POST +@login_required +@permitted +def follow_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + user.follow(thread) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def follow_commentable(request, course_id, commentable_id): + user = cc.User.from_django_user(request.user) + commentable = cc.Commentable.find(commentable_id) + user.follow(commentable) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def follow_user(request, course_id, followed_user_id): + user = cc.User.from_django_user(request.user) + followed_user = cc.User.find(followed_user_id) + user.follow(followed_user) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def unfollow_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + user.unfollow(thread) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def unfollow_commentable(request, course_id, commentable_id): + user = cc.User.from_django_user(request.user) + commentable = cc.Commentable.find(commentable_id) + user.unfollow(commentable) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def unfollow_user(request, course_id, followed_user_id): + user = cc.User.from_django_user(request.user) + followed_user = cc.User.find(followed_user_id) + user.unfollow(followed_user) + return JsonResponse({}) + +@require_POST +@login_required +@permitted +def update_moderator_status(request, course_id, user_id): + is_moderator = request.POST.get('is_moderator', '').lower() + if is_moderator not in ["true", "false"]: + return JsonError("Must provide is_moderator as boolean value") + is_moderator = is_moderator == "true" + user = User.objects.get(id=user_id) + role = Role.objects.get(course_id=course_id, name="Moderator") + if is_moderator: + user.roles.add(role) + else: + user.roles.remove(role) + if request.is_ajax(): + course = get_course_with_access(request.user, course_id, 'load') + discussion_user = cc.User(id=user_id, course_id=course_id) + context = { + 'course': course, + 'course_id': course_id, + 'user': request.user, + 'django_user': user, + 'discussion_user': discussion_user.to_dict(), + } + return JsonResponse({ + 'html': render_to_string('discussion/ajax_user_profile.html', context) + }) + else: + return JsonResponse({}) + +@require_GET +def search_similar_threads(request, course_id, commentable_id): + text = request.GET.get('text', None) + if text: + query_params = { + 'text': text, + 'commentable_id': commentable_id, + } + result = cc.search_similar_threads(course_id, recursive=False, query_params=query_params) + return JsonResponse(result) + else: + return JsonResponse([]) + +@require_GET +def tags_autocomplete(request, course_id): + value = request.GET.get('q', None) + results = [] + if value: + results = cc.tags_autocomplete(value) + return JsonResponse(results) + +@require_POST +@login_required +@csrf.csrf_exempt +def upload(request, course_id):#ajax upload file to a question or answer + """view that handles file upload via Ajax + """ + + # check upload permission + result = '' + error = '' + new_file_name = '' + try: + # TODO authorization + #may raise exceptions.PermissionDenied + #if request.user.is_anonymous(): + # msg = _('Sorry, anonymous users cannot upload files') + # raise exceptions.PermissionDenied(msg) + + #request.user.assert_can_upload_file() + + # check file type + f = request.FILES['file-upload'] + file_extension = os.path.splitext(f.name)[1].lower() + if not file_extension in settings.DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES: + file_types = "', '".join(settings.DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES) + msg = _("allowed file types are '%(file_types)s'") % \ + {'file_types': file_types} + raise exceptions.PermissionDenied(msg) + + # generate new file name + new_file_name = str( + time.time() + ).replace( + '.', + str(random.randint(0,100000)) + ) + file_extension + + file_storage = get_storage_class()() + # use default storage to store file + file_storage.save(new_file_name, f) + # check file size + # byte + size = file_storage.size(new_file_name) + if size > settings.ASKBOT_MAX_UPLOAD_FILE_SIZE: + file_storage.delete(new_file_name) + msg = _("maximum upload file size is %(file_size)sK") % \ + {'file_size': settings.ASKBOT_MAX_UPLOAD_FILE_SIZE} + raise exceptions.PermissionDenied(msg) + + except exceptions.PermissionDenied, e: + error = unicode(e) + except Exception, e: + logging.critical(unicode(e)) + error = _('Error uploading file. Please contact the site administrator. Thank you.') + + if error == '': + result = 'Good' + file_url = file_storage.url(new_file_name) + parsed_url = urlparse.urlparse(file_url) + file_url = urlparse.urlunparse( + urlparse.ParseResult( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + '', '', '' + ) + ) + else: + result = '' + file_url = '' + + return JsonResponse({ + 'result': { + 'msg': result, + 'error': error, + 'file_url': file_url, + } + }) diff --git a/lms/djangoapps/django_comment_client/forum/__init__.py b/lms/djangoapps/django_comment_client/forum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/forum/urls.py b/lms/djangoapps/django_comment_client/forum/urls.py new file mode 100644 index 0000000000..76957a82d8 --- /dev/null +++ b/lms/djangoapps/django_comment_client/forum/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls.defaults import url, patterns +import django_comment_client.forum.views + +urlpatterns = patterns('django_comment_client.forum.views', + url(r'users/(?P\w+)$', 'user_profile', name='user_profile'), + url(r'(?P\w+)/threads/(?P\w+)$', 'single_thread', name='single_thread'), + url(r'(?P\w+)/inline$', 'inline_discussion', name='inline_discussion'), + url(r'', 'forum_form_discussion', name='forum_form_discussion'), +) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py new file mode 100644 index 0000000000..c384980c9c --- /dev/null +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -0,0 +1,238 @@ +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_POST +from django.http import HttpResponse +from django.utils import simplejson +from django.core.context_processors import csrf +from django.core.urlresolvers import reverse +from django.contrib.auth.models import User + +from mitxmako.shortcuts import render_to_response, render_to_string +from courseware.courses import get_course_with_access +from courseware.access import has_access + +from urllib import urlencode +from django_comment_client.permissions import check_permissions_by_view +from django_comment_client.utils import merge_dict, extract, strip_none + +import json +import dateutil +import django_comment_client.utils as utils +import comment_client as cc + + +THREADS_PER_PAGE = 5 +PAGES_NEARBY_DELTA = 2 + + +def _general_discussion_id(course_id): + return course_id.replace('/', '_').replace('.', '_') + +def _should_perform_search(request): + return bool(request.GET.get('text', False) or \ + request.GET.get('tags', False)) + + +def render_accordion(request, course, discussion_id): + + discussion_info = utils.get_categorized_discussion_info(request, course) + + context = { + 'course': course, + 'discussion_info': discussion_info, + 'active': discussion_id, + 'csrf': csrf(request)['csrf_token'], + } + + return render_to_string('discussion/_accordion.html', context) + +def render_discussion(request, course_id, threads, *args, **kwargs): + + discussion_id = kwargs.get('discussion_id') + user_id = kwargs.get('user_id') + discussion_type = kwargs.get('discussion_type', 'inline') + query_params = kwargs.get('query_params', {}) + + template = { + 'inline': 'discussion/_inline.html', + 'forum': 'discussion/_forum.html', + 'user': 'discussion/_user_active_threads.html', + }[discussion_type] + + base_url = { + 'inline': (lambda: reverse('django_comment_client.forum.views.inline_discussion', args=[course_id, discussion_id])), + 'forum': (lambda: reverse('django_comment_client.forum.views.forum_form_discussion', args=[course_id])), + 'user': (lambda: reverse('django_comment_client.forum.views.user_profile', args=[course_id, user_id])), + }[discussion_type]() + + annotated_content_infos = map(lambda x: utils.get_annotated_content_infos(course_id, x, request.user), threads) + annotated_content_info = reduce(merge_dict, annotated_content_infos, {}) + + context = { + 'threads': threads, + 'discussion_id': discussion_id, + 'user_id': user_id, + 'user_info': json.dumps(cc.User.from_django_user(request.user).to_dict()), + 'course_id': course_id, + 'request': request, + 'performed_search': _should_perform_search(request), + 'pages_nearby_delta': PAGES_NEARBY_DELTA, + 'discussion_type': discussion_type, + 'base_url': base_url, + 'query_params': strip_none(extract(query_params, ['page', 'sort_key', 'sort_order', 'tags', 'text'])), + 'annotated_content_info': json.dumps(annotated_content_info), + } + context = dict(context.items() + query_params.items()) + return render_to_string(template, context) + +def render_inline_discussion(*args, **kwargs): + return render_discussion(discussion_type='inline', *args, **kwargs) + +def render_forum_discussion(*args, **kwargs): + return render_discussion(discussion_type='forum', *args, **kwargs) + +def render_user_discussion(*args, **kwargs): + return render_discussion(discussion_type='user', *args, **kwargs) + +def get_threads(request, course_id, discussion_id=None): + + default_query_params = { + 'page': 1, + 'per_page': THREADS_PER_PAGE, + 'sort_key': 'activity', + 'sort_order': 'desc', + 'text': '', + 'tags': '', + 'commentable_id': discussion_id, + 'course_id': course_id, + } + + query_params = merge_dict(default_query_params, + strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags']))) + + threads, page, num_pages = cc.Thread.search(query_params) + + query_params['page'] = page + query_params['num_pages'] = num_pages + + return threads, query_params + +# discussion per page is fixed for now +def inline_discussion(request, course_id, discussion_id): + threads, query_params = get_threads(request, course_id, discussion_id) + html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, \ + query_params=query_params) + return utils.HtmlResponse(html) + +def render_search_bar(request, course_id, discussion_id=None, text=''): + if not discussion_id: + return '' + context = { + 'discussion_id': discussion_id, + 'text': text, + 'course_id': course_id, + } + return render_to_string('discussion/_search_bar.html', context) + +def forum_form_discussion(request, course_id): + course = get_course_with_access(request.user, course_id, 'load') + threads, query_params = get_threads(request, course_id) + content = render_forum_discussion(request, course_id, threads, discussion_id=_general_discussion_id(course_id), query_params=query_params) + + recent_active_threads = cc.search_recent_active_threads( + course_id, + recursive=False, + query_params={'follower_id': request.user.id}, + ) + + trending_tags = cc.search_trending_tags( + course_id, + ) + + if request.is_ajax(): + return utils.HtmlResponse(content) + else: + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + 'content': content, + 'recent_active_threads': recent_active_threads, + 'trending_tags': trending_tags, + 'staff_access' : has_access(request.user, course, 'staff'), + } + # print "start rendering.." + return render_to_response('discussion/index.html', context) + +def render_single_thread(request, discussion_id, course_id, thread_id): + + thread = cc.Thread.find(thread_id).retrieve(recursive=True) + + annotated_content_info = utils.get_annotated_content_infos(course_id, thread=thread.to_dict(), user=request.user) + + context = { + 'discussion_id': discussion_id, + 'thread': thread, + 'user_info': json.dumps(cc.User.from_django_user(request.user).to_dict()), + 'annotated_content_info': json.dumps(annotated_content_info), + 'course_id': course_id, + 'request': request, + } + return render_to_string('discussion/_single_thread.html', context) + +def single_thread(request, course_id, discussion_id, thread_id): + + if request.is_ajax(): + + thread = cc.Thread.find(thread_id).retrieve(recursive=True) + annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user) + context = {'thread': thread.to_dict(), 'course_id': course_id} + html = render_to_string('discussion/_ajax_single_thread.html', context) + + return utils.JsonResponse({ + 'html': html, + 'annotated_content_info': annotated_content_info, + }) + + else: + course = get_course_with_access(request.user, course_id, 'load') + + context = { + 'discussion_id': discussion_id, + 'csrf': csrf(request)['csrf_token'], + 'init': '', + 'content': render_single_thread(request, discussion_id, course_id, thread_id), + 'accordion': render_accordion(request, course, discussion_id), + 'course': course, + 'course_id': course.id, + } + + return render_to_response('discussion/index.html', context) + +def user_profile(request, course_id, user_id): + + course = get_course_with_access(request.user, course_id, 'load') + profiled_user = cc.User(id=user_id, course_id=course_id) + + query_params = { + 'page': request.GET.get('page', 1), + 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities + } + + threads, page, num_pages = profiled_user.active_threads(query_params) + + query_params['page'] = page + query_params['num_pages'] = num_pages + + content = render_user_discussion(request, course_id, threads, user_id=user_id, query_params=query_params) + + if request.is_ajax(): + return utils.HtmlResponse(content) + else: + context = { + 'course': course, + 'user': request.user, + 'django_user': User.objects.get(id=user_id), + 'profiled_user': profiled_user.to_dict(), + 'content': content, + } + + return render_to_response('discussion/user_profile.html', context) diff --git a/lms/djangoapps/django_comment_client/helpers.py b/lms/djangoapps/django_comment_client/helpers.py new file mode 100644 index 0000000000..a168f53021 --- /dev/null +++ b/lms/djangoapps/django_comment_client/helpers.py @@ -0,0 +1,34 @@ +from django.core.urlresolvers import reverse +from mitxmako.shortcuts import render_to_string +from utils import * +from mustache_helpers import mustache_helpers +from functools import partial + +import pystache_custom as pystache +import urllib + +def pluralize(singular_term, count): + if int(count) >= 2: + return singular_term + 's' + return singular_term + +def show_if(text, condition): + if condition: + return text + else: + return '' + +def render_content(content, additional_context={}): + content_info = { + 'displayed_title': content.get('highlighted_title') or content.get('title', ''), + 'displayed_body': content.get('highlighted_body') or content.get('body', ''), + 'raw_tags': ','.join(content.get('tags', [])), + } + context = { + 'content': merge_dict(content, content_info), + content['type']: True, + } + context = merge_dict(context, additional_context) + partial_mustache_helpers = {k: partial(v, content) for k, v in mustache_helpers.items()} + context = merge_dict(context, partial_mustache_helpers) + return render_mustache('discussion/_content.mustache', context) diff --git a/lms/djangoapps/django_comment_client/management/__init__.py b/lms/djangoapps/django_comment_client/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/management/commands/__init__.py b/lms/djangoapps/django_comment_client/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_role.py b/lms/djangoapps/django_comment_client/management/commands/assign_role.py new file mode 100644 index 0000000000..82daa34622 --- /dev/null +++ b/lms/djangoapps/django_comment_client/management/commands/assign_role.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand, CommandError +from django_comment_client.models import Permission, Role +from django.contrib.auth.models import User + + +class Command(BaseCommand): + args = 'user role course_id' + help = 'Assign a role to a user' + + def handle(self, *args, **options): + role = Role.objects.get(name=args[1], course_id=args[2]) + + if '@' in args[0]: + user = User.objects.get(email=args[0]) + else: + user = User.objects.get(username=args[0]) + + user.roles.add(role) \ No newline at end of file diff --git a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py new file mode 100644 index 0000000000..148c204acf --- /dev/null +++ b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py @@ -0,0 +1,26 @@ +""" +This must be run only after seed_permissions_roles.py! + +Creates default roles for all users currently in the database. Just runs through +Enrollments. +""" +from django.core.management.base import BaseCommand, CommandError + +from student.models import CourseEnrollment +from django_comment_client.permissions import assign_default_role + + +class Command(BaseCommand): + args = 'course_id' + help = 'Seed default permisssions and roles' + + def handle(self, *args, **options): + if len(args) != 0: + raise CommandError("This Command takes no arguments") + + print "Updated roles for ", + for i, enrollment in enumerate(CourseEnrollment.objects.all(), start=1): + assign_default_role(None, enrollment) + if i % 1000 == 0: + print "{0}...".format(i), + print \ No newline at end of file diff --git a/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py new file mode 100644 index 0000000000..5987d5c677 --- /dev/null +++ b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py @@ -0,0 +1,31 @@ +from django.core.management.base import BaseCommand, CommandError +from django_comment_client.models import Permission, Role + + +class Command(BaseCommand): + args = 'course_id' + help = 'Seed default permisssions and roles' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("The number of arguments does not match. ") + course_id = args[0] + administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0] + moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0] + student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0] + + for per in ["vote", "update_thread", "follow_thread", "unfollow_thread", + "update_comment", "create_sub_comment", "unvote" , "create_thread", + "follow_commentable", "unfollow_commentable", "create_comment", ]: + student_role.add_permission(per) + + for per in ["edit_content", "delete_thread", "openclose_thread", + "endorse_comment", "delete_comment"]: + moderator_role.add_permission(per) + + for per in ["manage_moderator"]: + administrator_role.add_permission(per) + + moderator_role.inherit_permissions(student_role) + + administrator_role.inherit_permissions(moderator_role) diff --git a/lms/djangoapps/django_comment_client/management/commands/show_permissions.py b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py new file mode 100644 index 0000000000..ec3167aa0c --- /dev/null +++ b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py @@ -0,0 +1,32 @@ +from django.core.management.base import BaseCommand, CommandError +from django_comment_client.models import Permission, Role +from django.contrib.auth.models import User + + +class Command(BaseCommand): + args = 'user' + help = "Show a user's roles and permissions" + + def handle(self, *args, **options): + print args + if len(args) != 1: + raise CommandError("The number of arguments does not match. ") + try: + if '@' in args[0]: + user = User.objects.get(email=args[0]) + else: + user = User.objects.get(username=args[0]) + except User.DoesNotExist: + print "User %s does not exist. " % args[0] + print "Available users: " + print User.objects.all() + return + + roles = user.roles.all() + print "%s has %d roles:" % (user, len(roles)) + for role in roles: + print "\t%s" % role + + for role in roles: + print "%s has permissions: " % role + print role.permissions.all() diff --git a/lms/djangoapps/django_comment_client/middleware.py b/lms/djangoapps/django_comment_client/middleware.py new file mode 100644 index 0000000000..08e20b0296 --- /dev/null +++ b/lms/djangoapps/django_comment_client/middleware.py @@ -0,0 +1,9 @@ +from comment_client import CommentClientError +from django_comment_client.utils import JsonError +import json + +class AjaxExceptionMiddleware(object): + def process_exception(self, request, exception): + if isinstance(exception, CommentClientError) and request.is_ajax(): + return JsonError(json.loads(exception.message)) + return None diff --git a/lms/djangoapps/django_comment_client/migrations/0001_initial.py b/lms/djangoapps/django_comment_client/migrations/0001_initial.py new file mode 100644 index 0000000000..4993984d74 --- /dev/null +++ b/lms/djangoapps/django_comment_client/migrations/0001_initial.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Role' + db.create_table('django_comment_client_role', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=30)), + ('course_id', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)), + )) + db.send_create_signal('django_comment_client', ['Role']) + + # Adding M2M table for field users on 'Role' + db.create_table('django_comment_client_role_users', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('role', models.ForeignKey(orm['django_comment_client.role'], null=False)), + ('user', models.ForeignKey(orm['auth.user'], null=False)) + )) + db.create_unique('django_comment_client_role_users', ['role_id', 'user_id']) + + # Adding model 'Permission' + db.create_table('django_comment_client_permission', ( + ('name', self.gf('django.db.models.fields.CharField')(max_length=30, primary_key=True)), + )) + db.send_create_signal('django_comment_client', ['Permission']) + + # Adding M2M table for field roles on 'Permission' + db.create_table('django_comment_client_permission_roles', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('permission', models.ForeignKey(orm['django_comment_client.permission'], null=False)), + ('role', models.ForeignKey(orm['django_comment_client.role'], null=False)) + )) + db.create_unique('django_comment_client_permission_roles', ['permission_id', 'role_id']) + + + def backwards(self, orm): + # Deleting model 'Role' + db.delete_table('django_comment_client_role') + + # Removing M2M table for field users on 'Role' + db.delete_table('django_comment_client_role_users') + + # Deleting model 'Permission' + db.delete_table('django_comment_client_permission') + + # Removing M2M table for field roles on 'Permission' + db.delete_table('django_comment_client_permission_roles') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'django_comment_client.permission': { + 'Meta': {'object_name': 'Permission'}, + 'name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'primary_key': 'True'}), + 'roles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'permissions'", 'symmetrical': 'False', 'to': "orm['django_comment_client.Role']"}) + }, + 'django_comment_client.role': { + 'Meta': {'object_name': 'Role'}, + 'course_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'roles'", 'symmetrical': 'False', 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['django_comment_client'] \ No newline at end of file diff --git a/lms/djangoapps/django_comment_client/migrations/__init__.py b/lms/djangoapps/django_comment_client/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/models.py b/lms/djangoapps/django_comment_client/models.py new file mode 100644 index 0000000000..605a731517 --- /dev/null +++ b/lms/djangoapps/django_comment_client/models.py @@ -0,0 +1,34 @@ +from django.db import models +from django.contrib.auth.models import User +import logging + + +class Role(models.Model): + name = models.CharField(max_length=30, null=False, blank=False) + users = models.ManyToManyField(User, related_name="roles") + course_id = models.CharField(max_length=255, blank=True, db_index=True) + + def __unicode__(self): + return self.name + " for " + (self.course_id if self.course_id else "all courses") + + def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing, + # since it's one-off and doesn't handle inheritance later + if role.course_id and role.course_id != self.course_id: + logging.warning("%s cannot inheret permissions from %s due to course_id inconsistency" % + (self, role)) + for per in role.permissions.all(): + self.add_permission(per) + + def add_permission(self, permission): + self.permissions.add(Permission.objects.get_or_create(name=permission)[0]) + + def has_permission(self, permission): + return self.permissions.filter(name=permission).exists() + + +class Permission(models.Model): + name = models.CharField(max_length=30, null=False, blank=False, primary_key=True) + roles = models.ManyToManyField(Role, related_name="permissions") + + def __unicode__(self): + return self.name diff --git a/lms/djangoapps/django_comment_client/mustache_helpers.py b/lms/djangoapps/django_comment_client/mustache_helpers.py new file mode 100644 index 0000000000..48a38be29b --- /dev/null +++ b/lms/djangoapps/django_comment_client/mustache_helpers.py @@ -0,0 +1,28 @@ +from django.core.urlresolvers import reverse +import urllib + +def pluralize(content, text): + num, word = text.split(' ') + if int(num or '0') >= 2: + return num + ' ' + word + 's' + else: + return num + ' ' + word + +def url_for_user(content, user_id): + return reverse('django_comment_client.forum.views.user_profile', args=[content['course_id'], user_id]) + +def url_for_tags(content, tags): # assume that tags is in the format u'a, b, c' + return reverse('django_comment_client.forum.views.forum_form_discussion', args=[content['course_id']]) + '?' + urllib.urlencode({'tags': tags}) + +def close_thread_text(content): + if content.get('closed'): + return 'Re-open thread' + else: + return 'Close thread' + +mustache_helpers = { + 'pluralize': pluralize, + 'url_for_tags': url_for_tags, + 'url_for_user': url_for_user, + 'close_thread_text': close_thread_text, +} diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py new file mode 100644 index 0000000000..ffb676d2c2 --- /dev/null +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -0,0 +1,115 @@ +from .models import Role, Permission +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver +from student.models import CourseEnrollment + +import logging +from util.cache import cache + + +@receiver(post_save, sender=CourseEnrollment) +def assign_default_role(sender, instance, **kwargs): + if instance.user.is_staff: + role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] + else: + role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] + + logging.info("assign_default_role: adding %s as %s" % (instance.user, role)) + instance.user.roles.add(role) + +def cached_has_permission(user, permission, course_id=None): + """ + Call has_permission if it's not cached. A change in a user's role or + a role's permissions will only become effective after CACHE_LIFESPAN seconds. + """ + CACHE_LIFESPAN = 60 + key = "permission_%d_%s_%s" % (user.id, str(course_id), permission) + val = cache.get(key, None) + if val not in [True, False]: + val = has_permission(user, permission, course_id=course_id) + cache.set(key, val, CACHE_LIFESPAN) + return val + +def has_permission(user, permission, course_id=None): + for role in user.roles.filter(course_id=course_id): + if role.has_permission(permission): + return True + return False + + +CONDITIONS = ['is_open', 'is_author'] +def check_condition(user, condition, course_id, data): + def check_open(user, condition, course_id, data): + try: + return data and not data['content']['closed'] + except KeyError: + return False + + def check_author(user, condition, course_id, data): + try: + return data and data['content']['user_id'] == str(user.id) + except KeyError: + return False + + handlers = { + 'is_open' : check_open, + 'is_author' : check_author, + } + + return handlers[condition](user, condition, course_id, data) + + +def check_conditions_permissions(user, permissions, course_id, **kwargs): + """ + Accepts a list of permissions and proceed if any of the permission is valid. + Note that ["can_view", "can_edit"] will proceed if the user has either + "can_view" or "can_edit" permission. To use AND operator in between, wrap them in + a list. + """ + + def test(user, per, operator="or"): + if isinstance(per, basestring): + if per in CONDITIONS: + return check_condition(user, per, course_id, kwargs) + return cached_has_permission(user, per, course_id=course_id) + elif isinstance(per, list) and operator in ["and", "or"]: + results = [test(user, x, operator="and") for x in per] + if operator == "or": + return True in results + elif operator == "and": + return not False in results + + return test(user, permissions, operator="or") + + +VIEW_PERMISSIONS = { + 'update_thread' : ['edit_content', ['update_thread', 'is_open', 'is_author']], + 'create_comment' : [["create_comment", "is_open"]], + 'delete_thread' : ['delete_thread'], + 'update_comment' : ['edit_content', ['update_comment', 'is_open', 'is_author']], + 'endorse_comment' : ['endorse_comment'], + 'openclose_thread' : ['openclose_thread'], + 'create_sub_comment': [['create_sub_comment', 'is_open']], + 'delete_comment' : ['delete_comment'], + 'vote_for_comment' : [['vote', 'is_open']], + 'undo_vote_for_comment': [['unvote', 'is_open']], + 'vote_for_thread' : [['vote', 'is_open']], + 'undo_vote_for_thread': [['unvote', 'is_open']], + 'follow_thread' : ['follow_thread'], + 'follow_commentable': ['follow_commentable'], + 'follow_user' : ['follow_user'], + 'unfollow_thread' : ['unfollow_thread'], + 'unfollow_commentable': ['unfollow_commentable'], + 'unfollow_user' : ['unfollow_user'], + 'create_thread' : ['create_thread'], + 'update_moderator_status' : ['manage_moderator'], +} + + +def check_permissions_by_view(user, course_id, content, name): + try: + p = VIEW_PERMISSIONS[name] + except KeyError: + logging.warning("Permission for view named %s does not exist in permissions.py" % name) + return check_conditions_permissions(user, p, course_id, content=content) diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py new file mode 100644 index 0000000000..0e8f786692 --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests.py @@ -0,0 +1,53 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from student.models import CourseEnrollment, \ + replicate_enrollment_save, \ + replicate_enrollment_delete, \ + update_user_information, \ + replicate_user_save + +from django.db.models.signals import m2m_changed, pre_delete, pre_save, post_delete, post_save +from django.dispatch.dispatcher import _make_id +import string +import random +from .permissions import has_permission +from .models import Role, Permission + +class PermissionsTestCase(TestCase): + def random_str(self, length=15, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for x in range(length)) + + def setUp(self): + self.course_id = "MITx/6.002x/2012_Fall" + + self.moderator_role = Role.objects.get_or_create(name="Moderator", course_id=self.course_id)[0] + self.student_role = Role.objects.get_or_create(name="Student", course_id=self.course_id)[0] + + self.student = User.objects.create(username=self.random_str(), + password="123456", email="john@yahoo.com") + self.moderator = User.objects.create(username=self.random_str(), + password="123456", email="staff@edx.org") + self.moderator.is_staff = True + self.moderator.save() + self.student_enrollment = CourseEnrollment.objects.create(user=self.student, course_id=self.course_id) + self.moderator_enrollment = CourseEnrollment.objects.create(user=self.moderator, course_id=self.course_id) + + def tearDown(self): + self.student_enrollment.delete() + self.moderator_enrollment.delete() + +# Do we need to have this? We shouldn't be deleting students, ever +# self.student.delete() +# self.moderator.delete() + + def testDefaultRoles(self): + self.assertTrue(self.student_role in self.student.roles.all()) + self.assertTrue(self.moderator_role in self.moderator.roles.all()) + + def testPermission(self): + name = self.random_str() + self.moderator_role.add_permission(name) + self.assertTrue(has_permission(self.moderator, name, self.course_id)) + + self.student_role.add_permission(name) + self.assertTrue(has_permission(self.student, name, self.course_id)) diff --git a/lms/djangoapps/django_comment_client/urls.py b/lms/djangoapps/django_comment_client/urls.py new file mode 100644 index 0000000000..959b5fa2ca --- /dev/null +++ b/lms/djangoapps/django_comment_client/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import url, patterns, include + +urlpatterns = patterns('', + url(r'forum/', include('django_comment_client.forum.urls')), + url(r'', include('django_comment_client.base.urls')), +) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py new file mode 100644 index 0000000000..619689aa00 --- /dev/null +++ b/lms/djangoapps/django_comment_client/utils.py @@ -0,0 +1,184 @@ +from importlib import import_module +from courseware.models import StudentModuleCache +from courseware.module_render import get_module +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from django.http import HttpResponse +from django.utils import simplejson +from django.db import connection +from django.conf import settings +from django_comment_client.permissions import check_permissions_by_view +from mitxmako import middleware + +import logging +import operator +import itertools +import pystache_custom as pystache + + +_FULLMODULES = None +_DISCUSSIONINFO = None + +def extract(dic, keys): + return {k: dic.get(k) for k in keys} + +def strip_none(dic): + return dict([(k, v) for k, v in dic.iteritems() if v is not None]) + +def strip_blank(dic): + def _is_blank(v): + return isinstance(v, str) and len(v.strip()) == 0 + return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)]) + +def merge_dict(dic1, dic2): + return dict(dic1.items() + dic2.items()) + +def get_full_modules(): + global _FULLMODULES + if not _FULLMODULES: + class_path = settings.MODULESTORE['default']['ENGINE'] + module_path, _, class_name = class_path.rpartition('.') + class_ = getattr(import_module(module_path), class_name) + modulestore = class_(**dict(settings.MODULESTORE['default']['OPTIONS'].items() + [('eager', True)])) + _FULLMODULES = modulestore.modules + return _FULLMODULES + +def get_categorized_discussion_info(request, course): + """ + return a dict of the form {category: modules} + """ + global _DISCUSSIONINFO + if not _DISCUSSIONINFO: + initialize_discussion_info(request, course) + return _DISCUSSIONINFO['categorized'] + +def get_discussion_title(request, course, discussion_id): + global _DISCUSSIONINFO + if not _DISCUSSIONINFO: + initialize_discussion_info(request, course) + title = _DISCUSSIONINFO['by_id'].get(discussion_id, {}).get('title', '(no title)') + return title + +def initialize_discussion_info(request, course): + + global _DISCUSSIONINFO + if _DISCUSSIONINFO: + return + + course_id = course.id + _, course_name, _ = course_id.split('/') + user = request.user + url_course_id = course_id.replace('/', '_').replace('.', '_') + + _is_course_discussion = lambda x: x[0].dict()['category'] == 'discussion' \ + and x[0].dict()['course'] == course_name + + _get_module_descriptor = operator.itemgetter(1) + + def _get_module(module_descriptor): + print module_descriptor + module = get_module(user, request, module_descriptor.location, student_module_cache) + return module + + def _extract_info(module): + return { + 'title': module.title, + 'discussion_id': module.discussion_id, + 'category': module.discussion_category, + } + + def _pack_with_id(info): + return (info['discussion_id'], info) + + discussion_module_descriptors = map(_get_module_descriptor, + filter(_is_course_discussion, + get_full_modules().items())) + + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course) + + discussion_info = map(_extract_info, map(_get_module, discussion_module_descriptors)) + + _DISCUSSIONINFO = {} + + _DISCUSSIONINFO['by_id'] = dict(map(_pack_with_id, discussion_info)) + + _DISCUSSIONINFO['categorized'] = dict((category, list(l)) \ + for category, l in itertools.groupby(discussion_info, operator.itemgetter('category'))) + + _DISCUSSIONINFO['categorized']['General'] = [{ + 'title': 'General discussion', + 'discussion_id': url_course_id, + 'category': 'General', + }] + +class JsonResponse(HttpResponse): + def __init__(self, data=None): + content = simplejson.dumps(data) + super(JsonResponse, self).__init__(content, + mimetype='application/json; charset=utf8') + +class JsonError(HttpResponse): + def __init__(self, error_messages=[]): + if isinstance(error_messages, str): + error_messages = [error_messages] + content = simplejson.dumps({'errors': error_messages}, + indent=2, + ensure_ascii=False) + super(JsonError, self).__init__(content, + mimetype='application/json; charset=utf8', status=400) + +class HtmlResponse(HttpResponse): + def __init__(self, html=''): + super(HtmlResponse, self).__init__(html, content_type='text/plain') + +class ViewNameMiddleware(object): + def process_view(self, request, view_func, view_args, view_kwargs): + request.view_name = view_func.__name__ + +class QueryCountDebugMiddleware(object): + """ + This middleware will log the number of queries run + and the total time taken for each request (with a + status code of 200). It does not currently support + multi-db setups. + """ + def process_response(self, request, response): + if response.status_code == 200: + total_time = 0 + + for query in connection.queries: + query_time = query.get('time') + if query_time is None: + # django-debug-toolbar monkeypatches the connection + # cursor wrapper and adds extra information in each + # item in connection.queries. The query time is stored + # under the key "duration" rather than "time" and is + # in milliseconds, not seconds. + query_time = query.get('duration', 0) / 1000 + total_time += float(query_time) + + logging.info('%s queries run, total %s seconds' % (len(connection.queries), total_time)) + return response + +def get_annotated_content_info(course_id, content, user): + return { + 'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), + 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), + 'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False, + 'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), + 'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, + 'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"), + } + +def get_annotated_content_infos(course_id, thread, user): + infos = {} + def _annotate(content): + infos[str(content['id'])] = get_annotated_content_info(course_id, content, user) + for child in content.get('children', []): + _annotate(child) + _annotate(thread) + return infos + +def render_mustache(template_name, dictionary, *args, **kwargs): + template = middleware.lookup['main'].get_template(template_name).source + return pystache.render(template, dictionary) diff --git a/lms/djangoapps/simplewiki/views.py b/lms/djangoapps/simplewiki/views.py index ac807b13ed..ef0928709f 100644 --- a/lms/djangoapps/simplewiki/views.py +++ b/lms/djangoapps/simplewiki/views.py @@ -39,7 +39,7 @@ def update_template_dictionary(dictionary, request=None, course=None, article=No if course: dictionary['course'] = course if 'namespace' not in dictionary: - dictionary['namespace'] = course.wiki_namespace + dictionary['namespace'] = "edX" else: dictionary['course'] = None @@ -99,7 +99,7 @@ def root_redirect(request, course_id=None): course = get_opt_course_with_access(request.user, course_id, 'load') #TODO: Add a default namespace to settings. - namespace = course.wiki_namespace if course else "edX" + namespace = "edX" try: root = Article.get_root(namespace) @@ -479,7 +479,7 @@ def not_found(request, article_path, course): """Generate a NOT FOUND message for some URL""" d = {'wiki_err_notfound': True, 'article_path': article_path, - 'namespace': course.wiki_namespace} + 'namespace': "edX"} update_template_dictionary(d, request, course) return render_to_response('simplewiki/simplewiki_error.html', d) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d2d71830b0..059254bdff 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -19,6 +19,11 @@ EMAIL_BACKEND = 'django_ses.SESBackend' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' +# Disable askbot, enable Berkeley forums +MITX_FEATURES['ENABLE_DISCUSSION'] = False +MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True + + ########################### NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. with open(ENV_ROOT / "env.json") as env_file: @@ -60,3 +65,5 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE'] if 'COURSE_ID' in ENV_TOKENS: ASKBOT_URL = "courses/{0}/discussions/".format(ENV_TOKENS['COURSE_ID']) +COMMENTS_SERVICE_URL = ENV_TOKENS["COMMENTS_SERVICE_URL"] + diff --git a/lms/envs/common.py b/lms/envs/common.py index 52cb8c7d06..af8a69a8eb 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -30,10 +30,11 @@ import djcelery from path import path from .askbotsettings import * # this is where LIVESETTINGS_OPTIONS comes from +from .discussionsettings import * ################################### FEATURES ################################### COURSEWARE_ENABLED = True -ASKBOT_ENABLED = True +ASKBOT_ENABLED = False GENERATE_RANDOM_USER_CREDENTIALS = False PERFSTATS = False @@ -55,7 +56,8 @@ MITX_FEATURES = { 'SUBDOMAIN_COURSE_LISTINGS' : False, 'ENABLE_TEXTBOOK' : True, - 'ENABLE_DISCUSSION' : True, + 'ENABLE_DISCUSSION' : False, + 'ENABLE_DISCUSSION_SERVICE': True, 'ENABLE_SQL_TRACKING_LOGS': False, 'ENABLE_LMS_MIGRATION': False, @@ -302,6 +304,7 @@ SIMPLE_WIKI_REQUIRE_LOGIN_VIEW = False ################################# WIKI ################################### WIKI_ACCOUNT_HANDLING = False WIKI_EDITOR = 'course_wiki.editors.CodeMirror' +WIKI_SHOW_MAX_CHILDREN = 0 # We don't use the little menu that shows children of an article in the breadcrumb ################################# Jasmine ################################### JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' @@ -327,6 +330,7 @@ TEMPLATE_LOADERS = ( ) MIDDLEWARE_CLASSES = ( + 'django_comment_client.middleware.AjaxExceptionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -349,6 +353,9 @@ MIDDLEWARE_CLASSES = ( 'askbot.middleware.spaceless.SpacelessMiddleware', # 'askbot.middleware.pagesize.QuestionsPageSizeMiddleware', # 'debug_toolbar.middleware.DebugToolbarMiddleware', + + 'django_comment_client.utils.ViewNameMiddleware', + 'django_comment_client.utils.QueryCountDebugMiddleware', ) ############################### Pipeline ####################################### @@ -570,6 +577,9 @@ INSTALLED_APPS = ( # For testing 'django_jasmine', + # Discussion + 'django_comment_client', + # For Askbot 'django.contrib.sitemaps', 'django.contrib.admin', diff --git a/lms/envs/discussionsettings.py b/lms/envs/discussionsettings.py new file mode 100644 index 0000000000..f13680a7fe --- /dev/null +++ b/lms/envs/discussionsettings.py @@ -0,0 +1 @@ +DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES = ('.jpg', '.jpeg', '.gif', '.bmp', '.png', '.tiff') diff --git a/lms/envs/test.py b/lms/envs/test.py index 11534b3f4d..1ab3f550b8 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -84,11 +84,17 @@ DATABASES = { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "course1.db", }, - + 'edx/full/6.002_Spring_2012': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "course2.db", - } + }, + + 'edX/toy/TT_2012_Fall': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course3.db", + }, + } CACHES = { diff --git a/lms/lib/comment_client/__init__.py b/lms/lib/comment_client/__init__.py new file mode 100644 index 0000000000..ecade68b50 --- /dev/null +++ b/lms/lib/comment_client/__init__.py @@ -0,0 +1,2 @@ +from comment_client import * +from utils import CommentClientError, CommentClientUnknownError diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py new file mode 100644 index 0000000000..34cb3d5d06 --- /dev/null +++ b/lms/lib/comment_client/comment.py @@ -0,0 +1,44 @@ +from utils import * + +import models +import settings + +class Comment(models.Model): + + accessible_fields = [ + 'id', 'body', 'anonymous', 'course_id', + 'endorsed', 'parent_id', 'thread_id', + 'username', 'votes', 'user_id', 'closed', + 'created_at', 'updated_at', 'depth', + 'at_position_list', 'type', + ] + + updatable_fields = [ + 'body', 'anonymous', 'course_id', 'closed', + 'user_id', 'endorsed', + ] + + initializable_fields = updatable_fields + + base_url = "{prefix}/comments".format(prefix=settings.PREFIX) + type = 'comment' + + @classmethod + def url_for_comments(cls, params={}): + if params.get('thread_id'): + return _url_for_thread_comments(params['thread_id']) + else: + return _url_for_comment(params['parent_id']) + + @classmethod + def url(cls, action, params={}): + if action in ['post']: + return cls.url_for_comments(params) + else: + return super(Comment, cls).url(action, params) + +def _url_for_thread_comments(thread_id): + return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id) + +def _url_for_comment(comment_id): + return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id) diff --git a/lms/lib/comment_client/comment_client.py b/lms/lib/comment_client/comment_client.py new file mode 100644 index 0000000000..fe485433d6 --- /dev/null +++ b/lms/lib/comment_client/comment_client.py @@ -0,0 +1,38 @@ +from comment import Comment +from thread import Thread +from user import User +from commentable import Commentable + +from utils import * + +import settings + +def search_similar_threads(course_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + return perform_request('get', _url_for_search_similar_threads(), attributes, *args, **kwargs) + +def search_recent_active_threads(course_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + return perform_request('get', _url_for_search_recent_active_threads(), attributes, *args, **kwargs) + +def search_trending_tags(course_id, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id} + attributes = dict(default_params.items() + query_params.items()) + return perform_request('get', _url_for_search_trending_tags(), attributes, *args, **kwargs) + +def tags_autocomplete(value, *args, **kwargs): + return perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs) + +def _url_for_search_similar_threads(): + return "{prefix}/search/threads/more_like_this".format(prefix=settings.PREFIX) + +def _url_for_search_recent_active_threads(): + return "{prefix}/search/threads/recent_active".format(prefix=settings.PREFIX) + +def _url_for_search_trending_tags(): + return "{prefix}/search/tags/trending".format(prefix=settings.PREFIX) + +def _url_for_threads_tags_autocomplete(): + return "{prefix}/threads/tags/autocomplete".format(prefix=settings.PREFIX) diff --git a/lms/lib/comment_client/commentable.py b/lms/lib/comment_client/commentable.py new file mode 100644 index 0000000000..8f91bfc93d --- /dev/null +++ b/lms/lib/comment_client/commentable.py @@ -0,0 +1,9 @@ +from utils import * + +import models +import settings + +class Commentable(models.Model): + + base_url = "{prefix}/commentables".format(prefix=settings.PREFIX) + type = 'commentable' diff --git a/lms/lib/comment_client/legacy.py b/lms/lib/comment_client/legacy.py new file mode 100644 index 0000000000..fc87bcaf4f --- /dev/null +++ b/lms/lib/comment_client/legacy.py @@ -0,0 +1,180 @@ +def delete_threads(commentable_id, *args, **kwargs): + return _perform_request('delete', _url_for_commentable_threads(commentable_id), *args, **kwargs) + +def get_threads(commentable_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'page': 1, 'per_page': 20, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + response = _perform_request('get', _url_for_threads(commentable_id), \ + attributes, *args, **kwargs) + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) + +def search_threads(course_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'page': 1, 'per_page': 20, 'course_id': course_id, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + response = _perform_request('get', _url_for_search_threads(), \ + attributes, *args, **kwargs) + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) + +def search_similar_threads(course_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + return _perform_request('get', _url_for_search_similar_threads(), attributes, *args, **kwargs) + +def search_recent_active_threads(course_id, recursive=False, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id, 'recursive': recursive} + attributes = dict(default_params.items() + query_params.items()) + return _perform_request('get', _url_for_search_recent_active_threads(), attributes, *args, **kwargs) + +def search_trending_tags(course_id, query_params={}, *args, **kwargs): + default_params = {'course_id': course_id} + attributes = dict(default_params.items() + query_params.items()) + return _perform_request('get', _url_for_search_trending_tags(), attributes, *args, **kwargs) + +def create_user(attributes, *args, **kwargs): + return _perform_request('post', _url_for_users(), attributes, *args, **kwargs) + +def update_user(user_id, attributes, *args, **kwargs): + return _perform_request('put', _url_for_user(user_id), attributes, *args, **kwargs) + +def get_threads_tags(*args, **kwargs): + return _perform_request('get', _url_for_threads_tags(), {}, *args, **kwargs) + +def tags_autocomplete(value, *args, **kwargs): + return _perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs) + +def create_thread(commentable_id, attributes, *args, **kwargs): + return _perform_request('post', _url_for_threads(commentable_id), attributes, *args, **kwargs) + +def get_thread(thread_id, recursive=False, *args, **kwargs): + return _perform_request('get', _url_for_thread(thread_id), {'recursive': recursive}, *args, **kwargs) + +def update_thread(thread_id, attributes, *args, **kwargs): + return _perform_request('put', _url_for_thread(thread_id), attributes, *args, **kwargs) + +def create_comment(thread_id, attributes, *args, **kwargs): + return _perform_request('post', _url_for_thread_comments(thread_id), attributes, *args, **kwargs) + +def delete_thread(thread_id, *args, **kwargs): + return _perform_request('delete', _url_for_thread(thread_id), *args, **kwargs) + +def get_comment(comment_id, recursive=False, *args, **kwargs): + return _perform_request('get', _url_for_comment(comment_id), {'recursive': recursive}, *args, **kwargs) + +def update_comment(comment_id, attributes, *args, **kwargs): + return _perform_request('put', _url_for_comment(comment_id), attributes, *args, **kwargs) + +def create_sub_comment(comment_id, attributes, *args, **kwargs): + return _perform_request('post', _url_for_comment(comment_id), attributes, *args, **kwargs) + +def delete_comment(comment_id, *args, **kwargs): + return _perform_request('delete', _url_for_comment(comment_id), *args, **kwargs) + +def vote_for_comment(comment_id, user_id, value, *args, **kwargs): + return _perform_request('put', _url_for_vote_comment(comment_id), {'user_id': user_id, 'value': value}, *args, **kwargs) + +def undo_vote_for_comment(comment_id, user_id, *args, **kwargs): + return _perform_request('delete', _url_for_vote_comment(comment_id), {'user_id': user_id}, *args, **kwargs) + +def vote_for_thread(thread_id, user_id, value, *args, **kwargs): + return _perform_request('put', _url_for_vote_thread(thread_id), {'user_id': user_id, 'value': value}, *args, **kwargs) + +def undo_vote_for_thread(thread_id, user_id, *args, **kwargs): + return _perform_request('delete', _url_for_vote_thread(thread_id), {'user_id': user_id}, *args, **kwargs) + +def get_notifications(user_id, *args, **kwargs): + return _perform_request('get', _url_for_notifications(user_id), *args, **kwargs) + +def get_user_info(user_id, complete=True, *args, **kwargs): + return _perform_request('get', _url_for_user(user_id), {'complete': complete}, *args, **kwargs) + +def subscribe(user_id, subscription_detail, *args, **kwargs): + return _perform_request('post', _url_for_subscription(user_id), subscription_detail, *args, **kwargs) + +def subscribe_user(user_id, followed_user_id, *args, **kwargs): + return subscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id}) + +follow = subscribe_user + +def subscribe_thread(user_id, thread_id, *args, **kwargs): + return subscribe(user_id, {'source_type': 'thread', 'source_id': thread_id}) + +def subscribe_commentable(user_id, commentable_id, *args, **kwargs): + return subscribe(user_id, {'source_type': 'other', 'source_id': commentable_id}) + +def unsubscribe(user_id, subscription_detail, *args, **kwargs): + return _perform_request('delete', _url_for_subscription(user_id), subscription_detail, *args, **kwargs) + +def unsubscribe_user(user_id, followed_user_id, *args, **kwargs): + return unsubscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id}) + +unfollow = unsubscribe_user + +def unsubscribe_thread(user_id, thread_id, *args, **kwargs): + return unsubscribe(user_id, {'source_type': 'thread', 'source_id': thread_id}) + +def unsubscribe_commentable(user_id, commentable_id, *args, **kwargs): + return unsubscribe(user_id, {'source_type': 'other', 'source_id': commentable_id}) + +def _perform_request(method, url, data_or_params=None, *args, **kwargs): + if method in ['post', 'put', 'patch']: + response = requests.request(method, url, data=data_or_params) + else: + response = requests.request(method, url, params=data_or_params) + if 200 < response.status_code < 500: + raise CommentClientError(response.text) + elif response.status_code == 500: + raise CommentClientUnknownError(response.text) + else: + if kwargs.get("raw", False): + return response.text + else: + return json.loads(response.text) + +def _url_for_threads(commentable_id): + return "{prefix}/{commentable_id}/threads".format(prefix=PREFIX, commentable_id=commentable_id) + +def _url_for_thread(thread_id): + return "{prefix}/threads/{thread_id}".format(prefix=PREFIX, thread_id=thread_id) + +def _url_for_thread_comments(thread_id): + return "{prefix}/threads/{thread_id}/comments".format(prefix=PREFIX, thread_id=thread_id) + +def _url_for_comment(comment_id): + return "{prefix}/comments/{comment_id}".format(prefix=PREFIX, comment_id=comment_id) + +def _url_for_vote_comment(comment_id): + return "{prefix}/comments/{comment_id}/votes".format(prefix=PREFIX, comment_id=comment_id) + +def _url_for_vote_thread(thread_id): + return "{prefix}/threads/{thread_id}/votes".format(prefix=PREFIX, thread_id=thread_id) + +def _url_for_notifications(user_id): + return "{prefix}/users/{user_id}/notifications".format(prefix=PREFIX, user_id=user_id) + +def _url_for_subscription(user_id): + return "{prefix}/users/{user_id}/subscriptions".format(prefix=PREFIX, user_id=user_id) + +def _url_for_user(user_id): + return "{prefix}/users/{user_id}".format(prefix=PREFIX, user_id=user_id) + +def _url_for_search_threads(): + return "{prefix}/search/threads".format(prefix=PREFIX) + +def _url_for_search_similar_threads(): + return "{prefix}/search/threads/more_like_this".format(prefix=PREFIX) + +def _url_for_search_recent_active_threads(): + return "{prefix}/search/threads/recent_active".format(prefix=PREFIX) + +def _url_for_search_trending_tags(): + return "{prefix}/search/tags/trending".format(prefix=PREFIX) + +def _url_for_threads_tags(): + return "{prefix}/threads/tags".format(prefix=PREFIX) + +def _url_for_threads_tags_autocomplete(): + return "{prefix}/threads/tags/autocomplete".format(prefix=PREFIX) + +def _url_for_users(): + return "{prefix}/users".format(prefix=PREFIX) + diff --git a/lms/lib/comment_client/models.py b/lms/lib/comment_client/models.py new file mode 100644 index 0000000000..905f244783 --- /dev/null +++ b/lms/lib/comment_client/models.py @@ -0,0 +1,127 @@ +from utils import * + +class Model(object): + + accessible_fields = ['id'] + updatable_fields = ['id'] + initializable_fields = ['id'] + base_url = None + default_retrieve_params = {} + + DEFAULT_ACTIONS_WITH_ID = ['get', 'put', 'delete'] + DEFAULT_ACTIONS_WITHOUT_ID = ['get_all', 'post'] + DEFAULT_ACTIONS = DEFAULT_ACTIONS_WITH_ID + DEFAULT_ACTIONS_WITHOUT_ID + + def __init__(self, *args, **kwargs): + self.attributes = extract(kwargs, self.accessible_fields) + self.retrieved = False + + def __getattr__(self, name): + if name == 'id': + return self.attributes.get('id', None) + try: + return self.attributes[name] + except KeyError: + if self.retrieved or self.id == None: + raise AttributeError("Field {0} does not exist".format(name)) + self.retrieve() + return self.__getattr__(name) + + def __setattr__(self, name, value): + if name == 'attributes' or name not in self.accessible_fields: + super(Model, self).__setattr__(name, value) + else: + self.attributes[name] = value + + def __getitem__(self, key): + if key not in self.accessible_fields: + raise KeyError("Field {0} does not exist".format(key)) + return self.attributes.get(key) + + def __setitem__(self, key, value): + if key not in self.accessible_fields: + raise KeyError("Field {0} does not exist".format(key)) + self.attributes.__setitem__(key, value) + + def get(self, *args, **kwargs): + return self.attributes.get(*args, **kwargs) + + def to_dict(self): + self.retrieve() + return self.attributes + + def retrieve(self, *args, **kwargs): + if not self.retrieved: + self._retrieve(*args, **kwargs) + self.retrieved = True + return self + + def _retrieve(self, *args, **kwargs): + url = self.url(action='get', params=self.attributes) + response = perform_request('get', url, self.default_retrieve_params) + self.update_attributes(**response) + + @classmethod + def find(cls, id): + return cls(id=id) + + def update_attributes(self, *args, **kwargs): + for k, v in kwargs.items(): + if k in self.accessible_fields: + self.__setattr__(k, v) + else: + raise AttributeError("Field {0} does not exist".format(k)) + + def updatable_attributes(self): + return extract(self.attributes, self.updatable_fields) + + def initializable_attributes(self): + return extract(self.attributes, self.initializable_fields) + + @classmethod + def before_save(cls, instance): + pass + + @classmethod + def after_save(cls, instance): + pass + + def save(self): + self.__class__.before_save(self) + if self.id: # if we have id already, treat this as an update + url = self.url(action='put', params=self.attributes) + response = perform_request('put', url, self.updatable_attributes()) + else: # otherwise, treat this as an insert + url = self.url(action='post', params=self.attributes) + response = perform_request('post', url, self.initializable_attributes()) + self.retrieved = True + self.update_attributes(**response) + self.__class__.after_save(self) + + def delete(self): + url = self.url(action='delete', params=self.attributes) + response = perform_request('delete', url) + self.retrieved = True + self.update_attributes(**response) + + @classmethod + def url_with_id(cls, params={}): + return cls.base_url + '/' + str(params['id']) + + @classmethod + def url_without_id(cls, params={}): + return cls.base_url + + @classmethod + def url(cls, action, params={}): + if cls.base_url is None: + raise CommentClientError("Must provide base_url when using default url function") + if action not in cls.DEFAULT_ACTIONS: + raise ValueError("Invalid action {0}. The supported action must be in {1}".format(action, str(cls.DEFAULT_ACTIONS))) + elif action in cls.DEFAULT_ACTIONS_WITH_ID: + try: + return cls.url_with_id(params) + except KeyError: + raise CommentClientError("Cannot perform action {0} without id".format(action)) + else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now + return cls.url_without_id() diff --git a/lms/lib/comment_client/requirements.txt b/lms/lib/comment_client/requirements.txt new file mode 100644 index 0000000000..f2293605cf --- /dev/null +++ b/lms/lib/comment_client/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/lms/lib/comment_client/settings.py b/lms/lib/comment_client/settings.py new file mode 100644 index 0000000000..f64726335f --- /dev/null +++ b/lms/lib/comment_client/settings.py @@ -0,0 +1,8 @@ +from django.conf import settings + +if hasattr(settings, "COMMENTS_SERVICE_URL"): + SERVICE_HOST = settings.COMMENTS_SERVICE_URL +else: + SERVICE_HOST = 'http://localhost:4567' + +PREFIX = SERVICE_HOST + '/api/v1' diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py new file mode 100644 index 0000000000..c203e22d22 --- /dev/null +++ b/lms/lib/comment_client/thread.py @@ -0,0 +1,66 @@ +from utils import * + +import models +import settings + +class Thread(models.Model): + + accessible_fields = [ + 'id', 'title', 'body', 'anonymous', + 'course_id', 'closed', 'tags', 'votes', + 'commentable_id', 'username', 'user_id', + 'created_at', 'updated_at', 'comments_count', + 'at_position_list', 'children', 'type', + ] + + updatable_fields = [ + 'title', 'body', 'anonymous', 'course_id', + 'closed', 'tags', 'user_id', 'commentable_id', + ] + + initializable_fields = updatable_fields + + base_url = "{prefix}/threads".format(prefix=settings.PREFIX) + default_retrieve_params = {'recursive': False} + type = 'thread' + + @classmethod + def search(cls, query_params, *args, **kwargs): + default_params = {'page': 1, + 'per_page': 20, + 'course_id': query_params['course_id'], + 'recursive': False} + params = merge_dict(default_params, strip_blank(strip_none(query_params))) + if query_params.get('text') or query_params.get('tags'): + url = cls.url(action='search') + else: + url = cls.url(action='get_all', params=extract(params, 'commentable_id')) + if params.get('commentable_id'): + del params['commentable_id'] + response = perform_request('get', url, params, *args, **kwargs) + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) + + @classmethod + def url_for_threads(cls, params={}): + if params.get('commentable_id'): + return "{prefix}/{commentable_id}/threads".format(prefix=settings.PREFIX, commentable_id=params['commentable_id']) + else: + return "{prefix}/threads".format(prefix=settings.PREFIX) + + @classmethod + def url_for_search_threads(cls, params={}): + return "{prefix}/search/threads".format(prefix=settings.PREFIX) + + @classmethod + def url(cls, action, params={}): + if action in ['get_all', 'post']: + return cls.url_for_threads(params) + elif action == 'search': + return cls.url_for_search_threads(params) + else: + return super(Thread, cls).url(action, params) + + def _retrieve(self, *args, **kwargs): + url = self.url(action='get', params=self.attributes) + response = perform_request('get', url, {'recursive': kwargs.get('recursive')}) + self.update_attributes(**response) diff --git a/lms/lib/comment_client/user.py b/lms/lib/comment_client/user.py new file mode 100644 index 0000000000..ae4abf91b7 --- /dev/null +++ b/lms/lib/comment_client/user.py @@ -0,0 +1,85 @@ +from utils import * + +import models +import settings + +class User(models.Model): + + accessible_fields = ['username', 'email', 'follower_ids', 'upvoted_ids', 'downvoted_ids', + 'id', 'external_id', 'subscribed_user_ids', 'children', 'course_id', + 'subscribed_thread_ids', 'subscribed_commentable_ids', + 'threads_count', 'comments_count', + ] + + updatable_fields = ['username', 'external_id', 'email'] + initializable_fields = updatable_fields + + base_url = "{prefix}/users".format(prefix=settings.PREFIX) + default_retrieve_params = {'complete': True} + type = 'user' + + @classmethod + def from_django_user(cls, user): + return cls(id=str(user.id), + external_id=str(user.id), + username=user.username, + email=user.email) + + def follow(self, source): + params = {'source_type': source.type, 'source_id': source.id} + response = perform_request('post', _url_for_subscription(self.id), params) + + def unfollow(self, source): + params = {'source_type': source.type, 'source_id': source.id} + response = perform_request('delete', _url_for_subscription(self.id), params) + + def vote(self, voteable, value): + if voteable.type == 'thread': + url = _url_for_vote_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_vote_comment(voteable.id) + else: + raise CommentClientError("Can only vote / unvote for threads or comments") + params = {'user_id': self.id, 'value': value} + request = perform_request('put', url, params) + voteable.update_attributes(request) + + def unvote(self, voteable): + if voteable.type == 'thread': + url = _url_for_vote_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_vote_comment(voteable.id) + else: + raise CommentClientError("Can only vote / unvote for threads or comments") + params = {'user_id': self.id} + request = perform_request('delete', url, params) + voteable.update_attributes(request) + + def active_threads(self, query_params={}): + if not self.course_id: + raise CommentClientError("Must provide course_id when retrieving active threads for the user") + url = _url_for_user_active_threads(self.id) + params = {'course_id': self.course_id} + params = merge_dict(params, query_params) + response = perform_request('get', url, params) + return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) + + def _retrieve(self, *args, **kwargs): + url = self.url(action='get', params=self.attributes) + retrieve_params = self.default_retrieve_params + if self.attributes.get('course_id'): + retrieve_params['course_id'] = self.course_id + response = perform_request('get', url, retrieve_params) + self.update_attributes(**response) + +def _url_for_vote_comment(comment_id): + return "{prefix}/comments/{comment_id}/votes".format(prefix=settings.PREFIX, comment_id=comment_id) + +def _url_for_vote_thread(thread_id): + return "{prefix}/threads/{thread_id}/votes".format(prefix=settings.PREFIX, thread_id=thread_id) + +def _url_for_subscription(user_id): + return "{prefix}/users/{user_id}/subscriptions".format(prefix=settings.PREFIX, user_id=user_id) + +def _url_for_user_active_threads(user_id): + return "{prefix}/users/{user_id}/active_threads".format(prefix=settings.PREFIX, user_id=user_id) diff --git a/lms/lib/comment_client/utils.py b/lms/lib/comment_client/utils.py new file mode 100644 index 0000000000..16b1952303 --- /dev/null +++ b/lms/lib/comment_client/utils.py @@ -0,0 +1,44 @@ +import requests +import json + +def strip_none(dic): + return dict([(k, v) for k, v in dic.iteritems() if v is not None]) + +def strip_blank(dic): + def _is_blank(v): + return isinstance(v, str) and len(v.strip()) == 0 + return dict([(k, v) for k, v in dic.iteritems() if not _is_blank(v)]) + +def extract(dic, keys): + if isinstance(keys, str): + return strip_none({keys: dic.get(keys)}) + else: + return strip_none({k: dic.get(k) for k in keys}) + +def merge_dict(dic1, dic2): + return dict(dic1.items() + dic2.items()) + +def perform_request(method, url, data_or_params=None, *args, **kwargs): + if method in ['post', 'put', 'patch']: + response = requests.request(method, url, data=data_or_params) + else: + response = requests.request(method, url, params=data_or_params) + if 200 < response.status_code < 500: + raise CommentClientError(response.text) + elif response.status_code == 500: + raise CommentClientUnknownError(response.text) + else: + if kwargs.get("raw", False): + return response.text + else: + return json.loads(response.text) + +class CommentClientError(Exception): + def __init__(self, msg): + self.message = msg + + def __str__(self): + return repr(self.message) + +class CommentClientUnknownError(CommentClientError): + pass diff --git a/lms/lib/dogfood/views.py b/lms/lib/dogfood/views.py index 7a0e03436d..6df881df98 100644 --- a/lms/lib/dogfood/views.py +++ b/lms/lib/dogfood/views.py @@ -24,7 +24,6 @@ from mitxmako.shortcuts import render_to_response, render_to_string import track.views from lxml import etree - from courseware.module_render import make_track_function, ModuleSystem, get_module from courseware.models import StudentModule from multicourse import multicourse_settings diff --git a/lms/static/coffee/src/customwmd.coffee b/lms/static/coffee/src/customwmd.coffee new file mode 100644 index 0000000000..74be7ddbfe --- /dev/null +++ b/lms/static/coffee/src/customwmd.coffee @@ -0,0 +1,181 @@ +# Mostly adapted from math.stackexchange.com: http://cdn.sstatic.net/js/mathjax-editing-new.js + +$ -> + + if not MathJax? + return + + HUB = MathJax.Hub + + class MathJaxProcessor + + MATHSPLIT = /// ( + \$\$? # normal inline or display delimiter + | \\(?:begin|end)\{[a-z]*\*?\} # \begin{} \end{} style + | \\[\\{}$] + | [{}] + | (?:\n\s*)+ # only treat as math when there's single new line + | @@\d+@@ # delimiter similar to the one used internally + ) ///i + + CODESPAN = /// + (^|[^\\]) # match beginning or any previous character other than escape delimiter ('/') + (`+) # code span starts + ([^\n]*?[^`\n]) # code content + \2 # code span ends + (?!`) + ///gm + + constructor: (inlineMark, displayMark) -> + @inlineMark = inlineMark || "$" + @displayMark = displayMark || "$$" + @math = null + @blocks = null + + processMath: (start, last, preProcess) -> + block = @blocks.slice(start, last + 1).join("").replace(/&/g, "&") + .replace(//g, ">") + if HUB.Browser.isMSIE + block = block.replace /(%[^\n]*)\n/g, "$1
\n" + @blocks[i] = "" for i in [start+1..last] + @blocks[start] = "@@#{@math.length}@@" + block = preProcess(block) if preProcess + @math.push block + + removeMath: (text) -> + + @math = [] + start = end = last = null + braces = 0 + + hasCodeSpans = /`/.test text + if hasCodeSpans + text = text.replace(/~/g, "~T").replace CODESPAN, ($0) -> # replace dollar sign in code span temporarily + $0.replace /\$/g, "~D" + deTilde = (text) -> + text.replace /~([TD])/g, ($0, $1) -> + {T: "~", D: "$"}[$1] + else + deTilde = (text) -> text + + @blocks = _split(text.replace(/\r\n?/g, "\n"), MATHSPLIT) + + for current in [1...@blocks.length] by 2 + block = @blocks[current] + if block.charAt(0) == "@" + @blocks[current] = "@@#{@math.length}@@" + @math.push block + else if start + if block == end + if braces + last = current + else + @processMath(start, current, deTilde) + start = end = last = null + else if block.match /\n.*\n/ + if last + current = last + @processMath(start, current, deTilde) + start = end = last = null + braces = 0 + else if block == "{" + ++braces + else if block == "}" and braces + --braces + else + if block == @inlineMark or block == @displayMark + start = current + end = block + braces = 0 + else if block.substr(1, 5) == "begin" + start = current + end = "\\end" + block.substr(6) + braces = 0 + + if last + @processMath(start, last, deTilde) + start = end = last = null + + deTilde(@blocks.join("")) + + @removeMathWrapper: (_this) -> + (text) -> _this.removeMath(text) + + replaceMath: (text) -> + text = text.replace /@@(\d+)@@/g, ($0, $1) => @math[$1] + @math = null + text + + @replaceMathWrapper: (_this) -> + (text) -> _this.replaceMath(text) + + if Markdown? + + Markdown.getMathCompatibleConverter = (postProcessor) -> + postProcessor ||= ((text) -> text) + converter = Markdown.getSanitizingConverter() + processor = new MathJaxProcessor() + converter.hooks.chain "preConversion", MathJaxProcessor.removeMathWrapper(processor) + converter.hooks.chain "postConversion", (text) -> + postProcessor(MathJaxProcessor.replaceMathWrapper(processor)(text)) + converter + + Markdown.makeWmdEditor = (elem, appended_id, imageUploadUrl, postProcessor) -> + $elem = $(elem) + + if not $elem.length + console.log "warning: elem for makeWmdEditor doesn't exist" + return + + if not $elem.find(".wmd-panel").length + initialText = $elem.html() + $elem.empty() + _append = appended_id || "" + $wmdPanel = $("
").addClass("wmd-panel") + .append($("
").attr("id", "wmd-button-bar#{_append}")) + .append($("