From c410da91ded5f3b48d42a6a0fdf16740e6ff408f Mon Sep 17 00:00:00 2001 From: Kevin Chugh Date: Thu, 25 Apr 2013 07:02:57 -0400 Subject: [PATCH 01/36] fix coffeescript error --- lms/djangoapps/django_comment_client/base/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 69609dcf01..296eb65b66 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -27,6 +27,7 @@ from django_comment_client.utils import JsonResponse, JsonError, extract, get_co from django_comment_client.permissions import check_permissions_by_view, cached_has_permission from django_comment_client.models import Role +from courseware.access import has_access log = logging.getLogger(__name__) From ab08fa94e4ea4d9c7a80f476ecad7de4c33ba72a Mon Sep 17 00:00:00 2001 From: Kevin Chugh Date: Thu, 25 Apr 2013 11:36:12 -0400 Subject: [PATCH 02/36] coffeescript fixes --- common/static/coffee/src/discussion/utils.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 41f52f1711..0da84a3709 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -18,6 +18,9 @@ class @DiscussionUtil @loadRoles: (roles)-> @roleIds = roles + @loadFlagModerator: (what)-> + @isFlagModerator = (what=="True") + @loadRolesFromContainer: -> @loadRoles($("#discussion-container").data("roles")) @@ -50,7 +53,7 @@ class @DiscussionUtil delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" - pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" + pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin" undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote" follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow" From 8568ce7ad08cf4c77d2a1d71905cfce7388b9735 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Apr 2013 15:00:26 -0400 Subject: [PATCH 03/36] server django version generating 1 instead of True so test for both --- common/static/coffee/src/discussion/utils.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 0da84a3709..304af48031 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -19,7 +19,7 @@ class @DiscussionUtil @roleIds = roles @loadFlagModerator: (what)-> - @isFlagModerator = (what=="True") + @isFlagModerator = ((what=="True") or (what == "1")) @loadRolesFromContainer: -> @loadRoles($("#discussion-container").data("roles")) From 4b806768ec8c627fc083390562f495a6ebeb9b5a Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Apr 2013 15:28:01 -0400 Subject: [PATCH 04/36] more server trial and error --- common/static/coffee/src/discussion/utils.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 304af48031..5955d07d20 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -19,7 +19,7 @@ class @DiscussionUtil @roleIds = roles @loadFlagModerator: (what)-> - @isFlagModerator = ((what=="True") or (what == "1")) + @isFlagModerator = ((what=="True") or (what == 1)) @loadRolesFromContainer: -> @loadRoles($("#discussion-container").data("roles")) From ed5ad46192c62761762e35a608ab79c6923a6851 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Mon, 29 Apr 2013 13:17:16 -0400 Subject: [PATCH 05/36] Revert "Revert "fix merge conflict"" This reverts commit 2df3fe93440cb8b58bf86fbcc796eaad09be4ce8. Conflicts: common/static/coffee/src/discussion/utils.coffee --- .../coffee/src/discussion/content.coffee | 34 ++++++--- .../coffee/src/discussion/discussion.coffee | 3 + .../static/coffee/src/discussion/utils.coffee | 7 +- .../views/discussion_content_view.coffee | 46 ++++++++++++ .../views/discussion_thread_list_view.coffee | 11 +++ .../views/discussion_thread_show_view.coffee | 17 ++++- .../views/discussion_thread_view.coffee | 2 +- .../views/response_comment_show_view.coffee | 22 ++++++ .../views/thread_response_show_view.coffee | 17 +++++ .../views/thread_response_view.coffee | 2 +- .../django_comment_client/base/urls.py | 5 +- .../django_comment_client/base/views.py | 65 +++++++++++++---- .../django_comment_client/forum/views.py | 19 ++--- .../management/commands/reload_forum_users.py | 8 +-- .../django_comment_client/permissions.py | 5 +- .../tests/test_mustache_helpers.py | 1 - lms/djangoapps/django_comment_client/utils.py | 34 ++++----- lms/lib/comment_client/comment.py | 38 +++++++++- lms/lib/comment_client/comment_client.py | 1 - lms/lib/comment_client/thread.py | 67 +++++++++++++----- lms/static/images/flagged.png | Bin 0 -> 40840 bytes lms/static/images/notflagged.png | Bin 0 -> 39000 bytes lms/static/images/resolvedflag.png | Bin 0 -> 362 bytes lms/static/sass/_discussion.scss | 44 +++++++++++- .../discussion/_filter_dropdown.html | 8 +++ .../discussion/_underscore_templates.html | 15 +++- lms/templates/discussion/index.html | 2 +- lms/templates/discussion/single_thread.html | 2 +- 28 files changed, 388 insertions(+), 87 deletions(-) create mode 100644 lms/static/images/flagged.png create mode 100644 lms/static/images/notflagged.png create mode 100644 lms/static/images/resolvedflag.png diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/coffee/src/discussion/content.coffee index 00c34df686..6361a4b76e 100644 --- a/common/static/coffee/src/discussion/content.coffee +++ b/common/static/coffee/src/discussion/content.coffee @@ -88,20 +88,32 @@ if Backbone? pinned = @get("pinned") @set("pinned",pinned) @trigger "change", @ + + flagAbuse: -> + temp_array = @get("abuse_flaggers") + temp_array.push(window.user.get('id')) + @set("abuse_flaggers",temp_array) + @trigger "change", @ + unflagAbuse: -> + @get("abuse_flaggers").pop(window.user.get('id')) + @trigger "change", @ + class @Thread extends @Content urlMappers: - 'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @discussion.id, @id) - 'reply' : -> DiscussionUtil.urlFor('create_comment', @id) - 'unvote' : -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id) - 'upvote' : -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id) - 'downvote' : -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id) - 'close' : -> DiscussionUtil.urlFor('openclose_thread', @id) - 'update' : -> DiscussionUtil.urlFor('update_thread', @id) - 'delete' : -> DiscussionUtil.urlFor('delete_thread', @id) - 'follow' : -> DiscussionUtil.urlFor('follow_thread', @id) - 'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id) + 'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @discussion.id, @id) + 'reply' : -> DiscussionUtil.urlFor('create_comment', @id) + 'unvote' : -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id) + 'upvote' : -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id) + 'downvote' : -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id) + 'close' : -> DiscussionUtil.urlFor('openclose_thread', @id) + 'update' : -> DiscussionUtil.urlFor('update_thread', @id) + 'delete' : -> DiscussionUtil.urlFor('delete_thread', @id) + 'follow' : -> DiscussionUtil.urlFor('follow_thread', @id) + 'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id) + 'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id) + 'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id) 'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id) 'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id) @@ -157,6 +169,8 @@ if Backbone? 'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id) 'update': -> DiscussionUtil.urlFor('update_comment', @id) 'delete': -> DiscussionUtil.urlFor('delete_comment', @id) + 'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id) + 'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id) getCommentsCount: -> count = 0 diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/coffee/src/discussion/discussion.coffee index 83e25e1da7..5a52cd4de0 100644 --- a/common/static/coffee/src/discussion/discussion.coffee +++ b/common/static/coffee/src/discussion/discussion.coffee @@ -37,6 +37,9 @@ if Backbone? data['commentable_ids'] = options.commentable_ids when 'all' url = DiscussionUtil.urlFor 'threads' + when 'flagged' + data['flagged'] = true + url = DiscussionUtil.urlFor 'search' when 'followed' url = DiscussionUtil.urlFor 'followed_threads', options.user_id if options['group_id'] diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 5955d07d20..b7b7cb2550 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -23,6 +23,7 @@ class @DiscussionUtil @loadRolesFromContainer: -> @loadRoles($("#discussion-container").data("roles")) + @loadFlagModerator($("#discussion-container").data("flag-moderator")) @isStaff: (user_id) -> staff = _.union(@roleIds['Staff'], @roleIds['Moderator'], @roleIds['Administrator']) @@ -51,6 +52,10 @@ class @DiscussionUtil update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update" create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply" delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete" + flagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/flagAbuse" + unFlagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unFlagAbuse" + flagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/flagAbuse" + unFlagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unFlagAbuse" upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote" downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote" pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin" @@ -75,7 +80,7 @@ class @DiscussionUtil permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}" permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}" user_profile : "/courses/#{$$course_id}/discussion/forum/users/#{param}" - followed_threads : "/courses/#{$$course_id}/discussion/forum/users/#{param}/followed" + followed_threads : "/courses/#{$$course_id}/discussion/forum/users/#{param}/followed" threads : "/courses/#{$$course_id}/discussion/forum" }[name] diff --git a/common/static/coffee/src/discussion/views/discussion_content_view.coffee b/common/static/coffee/src/discussion/views/discussion_content_view.coffee index 9399d95398..9b2de1b198 100644 --- a/common/static/coffee/src/discussion/views/discussion_content_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_content_view.coffee @@ -1,6 +1,11 @@ if Backbone? class @DiscussionContentView extends Backbone.View + + events: + "click .discussion-flag-abuse": "toggleFlagAbuse" + + attrRenderer: endorsed: (endorsed) -> if endorsed @@ -94,7 +99,48 @@ if Backbone? setWmdContent: (cls_identifier, text) => DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text + initialize: -> @initLocal() @model.bind('change', @renderPartialAttrs, @) + + + + toggleFlagAbuse: (event) -> + event.preventDefault() + if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) + @unFlagAbuse() + else + @flagAbuse() + + flagAbuse: -> + url = @model.urlFor("flagAbuse") + DiscussionUtil.safeAjax + $elem: @$(".discussion-flag-abuse") + url: url + type: "POST" + success: (response, textStatus) => + if textStatus == 'success' + ### + note, we have to clone the array in order to trigger a change event + ### + temp_array = _.clone(@model.get('abuse_flaggers')); + temp_array.push(window.user.id) + @model.set('abuse_flaggers', temp_array) + + unFlagAbuse: -> + url = @model.urlFor("unFlagAbuse") + DiscussionUtil.safeAjax + $elem: @$(".discussion-flag-abuse") + url: url + type: "POST" + success: (response, textStatus) => + if textStatus == 'success' + temp_array = _.clone(@model.get('abuse_flaggers')); + temp_array.pop(window.user.id) + # if you're an admin, clear this + if DiscussionUtil.isFlagModerator + temp_array = [] + + @model.set('abuse_flaggers', temp_array) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 8364963218..9aa4ba869d 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -276,6 +276,11 @@ if Backbone? @$(".post-search-field").val("") @$('.cohort').show() @retrieveAllThreads() + else if discussionId == "#flagged" + @discussionIds = "" + @$(".post-search-field").val("") + @$('.cohort').hide() + @retrieveFlaggedThreads() else if discussionId == "#following" @retrieveFollowed(event) @$('.cohort').hide() @@ -321,6 +326,12 @@ if Backbone? @collection.reset() @loadMorePages(event) + retrieveFlaggedThreads: (event)-> + @collection.current_page = 0 + @collection.reset() + @mode = 'flagged' + @loadMorePages(event) + sortThreads: (event) -> @$(".sort-bar a").removeClass("active") $(event.target).addClass("active") diff --git a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee index 56525af347..49936c46e8 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_show_view.coffee @@ -3,6 +3,7 @@ if Backbone? events: "click .discussion-vote": "toggleVote" + "click .discussion-flag-abuse": "toggleFlagAbuse" "click .admin-pin": "togglePin" "click .action-follow": "toggleFollowing" "click .action-edit": "edit" @@ -25,6 +26,7 @@ if Backbone? @delegateEvents() @renderDogear() @renderVoted() + @renderFlagged() @renderPinned() @renderAttrs() @$("span.timeago").timeago() @@ -42,6 +44,16 @@ if Backbone? @$("[data-role=discussion-vote]").addClass("is-cast") else @$("[data-role=discussion-vote]").removeClass("is-cast") + + renderFlagged: => + if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) + @$("[data-role=thread-flag]").addClass("flagged") + @$("[data-role=thread-flag]").removeClass("notflagged") + @$(".discussion-flag-abuse .flag-label").html("Misuse Reported") + else + @$("[data-role=thread-flag]").removeClass("flagged") + @$("[data-role=thread-flag]").addClass("notflagged") + @$(".discussion-flag-abuse .flag-label").html("Report Misuse") renderPinned: => if @model.get("pinned") @@ -56,6 +68,7 @@ if Backbone? updateModelDetails: => @renderVoted() + @renderFlagged() @renderPinned() @$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"]) @@ -96,6 +109,7 @@ if Backbone? if textStatus == 'success' @model.set(response, {silent: true}) + unvote: -> window.user.unvote(@model) url = @model.urlFor("unvote") @@ -107,6 +121,7 @@ if Backbone? if textStatus == 'success' @model.set(response, {silent: true}) + edit: (event) -> @trigger "thread:edit", event @@ -182,4 +197,4 @@ if Backbone? params = $.extend(params, user:{username: @model.username, user_url: @model.user_url}) Mustache.render(@template, params) - \ No newline at end of file + diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee index cb549f1088..c3a793b478 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee @@ -91,7 +91,7 @@ if Backbone? body = @getWmdContent("reply-body") return if not body.trim().length @setWmdContent("reply-body", "") - comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, endorsed: false, user_id: window.user.get("id")) + comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id")) comment.set('thread', @model.get('thread')) @renderResponse(comment) @model.addComment() diff --git a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee index 84e7357e1f..18d405fdb4 100644 --- a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee +++ b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee @@ -1,7 +1,14 @@ if Backbone? class @ResponseCommentShowView extends DiscussionContentView + + events: + "click .discussion-flag-abuse": "toggleFlagAbuse" tagName: "li" + + initialize: -> + super() + @model.on "change", @updateModelDetails render: -> @template = _.template($("#response-comment-show-template").html()) @@ -11,6 +18,7 @@ if Backbone? @initLocal() @delegateEvents() @renderAttrs() + @renderFlagged() @markAsStaff() @$el.find(".timeago").timeago() @convertMath() @@ -34,3 +42,17 @@ if Backbone? @$el.find("a.profile-link").after('staff') else if DiscussionUtil.isTA(@model.get("user_id")) @$el.find("a.profile-link").after('Community  TA') + + + renderFlagged: => + if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) + @$("[data-role=thread-flag]").addClass("flagged") + @$("[data-role=thread-flag]").removeClass("notflagged") + else + @$("[data-role=thread-flag]").removeClass("flagged") + @$("[data-role=thread-flag]").addClass("notflagged") + + updateModelDetails: => + @renderFlagged() + + diff --git a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee index 1f305ddf34..0e42b79b9a 100644 --- a/common/static/coffee/src/discussion/views/thread_response_show_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_show_view.coffee @@ -5,6 +5,7 @@ if Backbone? "click .action-endorse": "toggleEndorse" "click .action-delete": "delete" "click .action-edit": "edit" + "click .discussion-flag-abuse": "toggleFlagAbuse" $: (selector) -> @$el.find(selector) @@ -23,6 +24,7 @@ if Backbone? if window.user.voted(@model) @$(".vote-btn").addClass("is-cast") @renderAttrs() + @renderFlagged() @$el.find(".posted-details").timeago() @convertMath() @markAsStaff() @@ -70,6 +72,7 @@ if Backbone? success: (response, textStatus) => if textStatus == 'success' @model.set(response) + edit: (event) -> @trigger "response:edit", event @@ -92,3 +95,17 @@ if Backbone? url: url data: data type: "POST" + + + renderFlagged: => + if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) + @$("[data-role=thread-flag]").addClass("flagged") + @$("[data-role=thread-flag]").removeClass("notflagged") + @$(".discussion-flag-abuse .flag-label").html("Misuse Reported") + else + @$("[data-role=thread-flag]").removeClass("flagged") + @$("[data-role=thread-flag]").addClass("notflagged") + @$(".discussion-flag-abuse .flag-label").html("Report Misuse") + + updateModelDetails: => + @renderFlagged() diff --git a/common/static/coffee/src/discussion/views/thread_response_view.coffee b/common/static/coffee/src/discussion/views/thread_response_view.coffee index 9b6800cdde..46a96a55ec 100644 --- a/common/static/coffee/src/discussion/views/thread_response_view.coffee +++ b/common/static/coffee/src/discussion/views/thread_response_view.coffee @@ -77,7 +77,7 @@ if Backbone? body = @getWmdContent("comment-body") return if not body.trim().length @setWmdContent("comment-body", "") - comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), user_id: window.user.get("id"), id:"unsaved") + comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), abuse_flaggers:[], user_id: window.user.get("id"), id:"unsaved") view = @renderComment(comment) @hideEditorChrome() @trigger "comment:add", comment diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py index 5a43030565..41bf568012 100644 --- a/lms/djangoapps/django_comment_client/base/urls.py +++ b/lms/djangoapps/django_comment_client/base/urls.py @@ -9,6 +9,8 @@ urlpatterns = patterns('django_comment_client.base.views', # nopep8 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\-]+)/flagAbuse$', 'flag_abuse_for_thread', name='flag_abuse_for_thread'), + url(r'threads/(?P[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_thread', name='un_flag_abuse_for_thread'), url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'), url(r'threads/(?P[\w\-]+)/pin$', 'pin_thread', name='pin_thread'), url(r'threads/(?P[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'), @@ -23,7 +25,8 @@ urlpatterns = patterns('django_comment_client.base.views', # nopep8 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'comments/(?P[\w\-]+)/flagAbuse$', 'flag_abuse_for_comment', name='flag_abuse_for_comment'), + url(r'comments/(?P[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_comment', name='un_flag_abuse_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'), diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 296eb65b66..8f7ea2f06b 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -20,7 +20,7 @@ from django.utils.translation import ugettext as _ 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.courses import get_course_with_access, get_course_by_id from course_groups.cohorts import get_cohort_id, is_commentable_cohorted from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context @@ -120,7 +120,7 @@ def create_thread(request, course_id, commentable_id): #patch for backward compatibility to comments service if not 'pinned' in thread.attributes: thread['pinned'] = False - + if post.get('auto_subscribe', 'false').lower() == 'true': user = cc.User.from_django_user(request.user) user.follow(thread) @@ -285,6 +285,50 @@ def vote_for_thread(request, course_id, thread_id, value): return JsonResponse(utils.safe_content(thread.to_dict())) +@require_POST +@login_required +@permitted +def flag_abuse_for_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + thread.flagAbuse(user, thread) + return JsonResponse(utils.safe_content(thread.to_dict())) + + +@require_POST +@login_required +@permitted +def un_flag_abuse_for_thread(request, course_id, thread_id): + user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_id) + thread = cc.Thread.find(thread_id) + removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff') + thread.unFlagAbuse(user, thread, removeAll) + return JsonResponse(utils.safe_content(thread.to_dict())) + + +@require_POST +@login_required +@permitted +def flag_abuse_for_comment(request, course_id, comment_id): + user = cc.User.from_django_user(request.user) + comment = cc.Comment.find(comment_id) + comment.flagAbuse(user, comment) + return JsonResponse(utils.safe_content(comment.to_dict())) + + +@require_POST +@login_required +@permitted +def un_flag_abuse_for_comment(request, course_id, comment_id): + user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_id) + removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff') + comment = cc.Comment.find(comment_id) + comment.unFlagAbuse(user, comment, removeAll) + return JsonResponse(utils.safe_content(comment.to_dict())) + + @require_POST @login_required @permitted @@ -294,19 +338,21 @@ def undo_vote_for_thread(request, course_id, thread_id): user.unvote(thread) return JsonResponse(utils.safe_content(thread.to_dict())) + @require_POST @login_required @permitted def pin_thread(request, course_id, thread_id): user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.pin(user,thread_id) + thread.pin(user, thread_id) return JsonResponse(utils.safe_content(thread.to_dict())) + def un_pin_thread(request, course_id, thread_id): user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.un_pin(user,thread_id) + thread.un_pin(user, thread_id) return JsonResponse(utils.safe_content(thread.to_dict())) @@ -453,16 +499,11 @@ def upload(request, course_id): # ajax upload file to a question or answer if not file_extension in cc_settings.ALLOWED_UPLOAD_FILE_TYPES: file_types = "', '".join(cc_settings.ALLOWED_UPLOAD_FILE_TYPES) msg = _("allowed file types are '%(file_types)s'") % \ - {'file_types': file_types} + {'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 + 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 @@ -473,7 +514,7 @@ def upload(request, course_id): # ajax upload file to a question or answer if size > cc_settings.MAX_UPLOAD_FILE_SIZE: file_storage.delete(new_file_name) msg = _("maximum upload file size is %(file_size)sK") % \ - {'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE} + {'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE} raise exceptions.PermissionDenied(msg) except exceptions.PermissionDenied, e: diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 6498ea8370..a94b9a07ad 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -9,9 +9,10 @@ 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 course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, +from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, get_cohorted_commentables, get_course_cohorts, get_cohort_by_id) from courseware.access import has_access +from django_comment_client.models import Role from django_comment_client.permissions import cached_has_permission from django_comment_client.utils import (merge_dict, extract, strip_none, get_courseware_context) @@ -79,7 +80,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', - 'tags', 'commentable_ids']))) + 'tags', 'commentable_ids', 'flagged']))) threads, page, num_pages = cc.Thread.search(query_params) @@ -92,7 +93,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG else: thread['group_name'] = "" thread['group_string'] = "This post visible to everyone." - + #patch for backward compatibility to comments service if not 'pinned' in thread: thread['pinned'] = False @@ -108,7 +109,6 @@ def inline_discussion(request, course_id, discussion_id): """ Renders JSON for DiscussionModules """ - course = get_course_with_access(request.user, course_id, 'load') try: @@ -219,6 +219,7 @@ def forum_form_discussion(request, course_id): 'threads': saxutils.escape(json.dumps(threads), escapedict), 'thread_pages': query_params['num_pages'], 'user_info': saxutils.escape(json.dumps(user_info), escapedict), + 'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'), 'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict), 'course_id': course.id, 'category_map': category_map, @@ -241,19 +242,12 @@ def single_thread(request, course_id, discussion_id, thread_id): try: thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) - - #patch for backward compatibility with comments service - if not 'pinned' in thread.attributes: - thread['pinned'] = False - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: log.error("Error loading single thread.") raise Http404 if request.is_ajax(): - courseware_context = get_courseware_context(thread, course) - annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) context = {'thread': thread.to_dict(), 'course_id': course_id} # TODO: Remove completely or switch back to server side rendering @@ -325,6 +319,7 @@ def single_thread(request, course_id, discussion_id, thread_id): 'thread_pages': query_params['num_pages'], 'is_course_cohorted': is_course_cohorted(course_id), 'is_moderator': cached_has_permission(request.user, "see_all_cohorts", course_id), + 'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'), 'cohorts': cohorts, 'user_cohort': get_cohort_id(request.user, course_id), 'cohorted_commentables': cohorted_commentables @@ -412,7 +407,7 @@ def followed_threads(request, course_id, user_id): 'user_info': saxutils.escape(json.dumps(user_info), escapedict), 'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict), # 'content': content, - } + } return render_to_response('discussion/user_profile.html', context) except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): diff --git a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py index 5e7e268270..e84771d615 100644 --- a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py +++ b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py @@ -6,10 +6,11 @@ from django.core.management.base import BaseCommand, CommandError from django.contrib.auth.models import User import comment_client as cc + class Command(BaseCommand): help = 'Reload forum (comment client) users from existing users' - def adduser(self,user): + def adduser(self, user): print user try: cc_user = cc.User.from_django_user(user) @@ -22,8 +23,7 @@ class Command(BaseCommand): uset = [User.objects.get(username=x) for x in args] else: uset = User.objects.all() - + for user in uset: self.adduser(user) - - \ No newline at end of file + diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py index 7d21cc9783..cc3ead53e7 100644 --- a/lms/djangoapps/django_comment_client/permissions.py +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -73,7 +73,6 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs): return True in results elif operator == "and": return not False in results - return test(user, permissions, operator="or") @@ -89,6 +88,10 @@ VIEW_PERMISSIONS = { 'vote_for_comment' : [['vote', 'is_open']], 'undo_vote_for_comment': [['unvote', 'is_open']], 'vote_for_thread' : [['vote', 'is_open']], + 'flag_abuse_for_thread': [['vote', 'is_open']], + 'un_flag_abuse_for_thread': [['vote', 'is_open']], + 'flag_abuse_for_comment': [['vote', 'is_open']], + 'un_flag_abuse_for_comment': [['vote', 'is_open']], 'undo_vote_for_thread': [['unvote', 'is_open']], 'pin_thread': ['create_comment'], 'un_pin_thread': ['create_comment'], diff --git a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py index 7db3ba6e86..d5a403ecb8 100644 --- a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py +++ b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py @@ -39,4 +39,3 @@ class CloseThreadTextTest(TestCase): self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') ######################################################################################### - diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 9bfb9a9d0d..c79cc4cb89 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -1,3 +1,4 @@ +import time from collections import defaultdict import logging import time @@ -104,12 +105,12 @@ def filter_unstarted_categories(category_map): result_map = {} unfiltered_queue = [category_map] - filtered_queue = [result_map] + filtered_queue = [result_map] while len(unfiltered_queue) > 0: unfiltered_map = unfiltered_queue.pop() - filtered_map = filtered_queue.pop() + filtered_map = filtered_queue.pop() filtered_map["children"] = [] filtered_map["entries"] = {} @@ -174,8 +175,7 @@ def initialize_discussion_info(course): category = " / ".join([x.strip() for x in category.split("/")]) last_category = category.split("/")[-1] discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title} - unexpanded_category_map[category].append({"title": title, "id": id, - "sort_key": sort_key, "start_date": module.lms.start}) + unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": module.lms.start}) category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)} for category_path, entries in unexpanded_category_map.items(): @@ -202,9 +202,9 @@ def initialize_discussion_info(course): level = path[-1] if level not in node: node[level] = {"subcategories": defaultdict(dict), - "entries": defaultdict(dict), - "sort_key": level, - "start_date": category_start_date} + "entries": defaultdict(dict), + "sort_key": level, + "start_date": category_start_date} else: if node[level]["start_date"] > category_start_date: node[level]["start_date"] = category_start_date @@ -284,12 +284,12 @@ class QueryCountDebugMiddleware(object): def get_ability(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"), + '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"), } #TODO: RENAME @@ -318,6 +318,7 @@ def get_annotated_content_infos(course_id, thread, user, user_info): Get metadata for a thread and its children """ infos = {} + def annotate(content): infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info) for child in content.get('children', []): @@ -382,8 +383,8 @@ def get_courseware_context(content, course): location = id_map[id]["location"].url() title = id_map[id]["title"] - url = reverse('jump_to', kwargs={"course_id":course.location.course_id, - "location": location}) + url = reverse('jump_to', kwargs={"course_id": course.location.course_id, + "location": location}) content_info = {"courseware_url": url, "courseware_title": title} return content_info @@ -396,7 +397,8 @@ def safe_content(content): 'updated_at', 'depth', 'type', 'commentable_id', 'comments_count', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'courseware_title', 'courseware_url', 'tags', 'unread_comments_count', - 'read', 'group_id', 'group_name', 'group_string', 'pinned' + 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers' + ] if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False): diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py index 2f93aff6b3..324de7923f 100644 --- a/lms/lib/comment_client/comment.py +++ b/lms/lib/comment_client/comment.py @@ -11,12 +11,12 @@ class Comment(models.Model): 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', - 'type', 'commentable_id', + 'type', 'commentable_id', 'abuse_flaggers' ] updatable_fields = [ 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', - 'user_id', 'endorsed', + 'user_id', 'endorsed' ] initializable_fields = updatable_fields @@ -42,6 +42,32 @@ class Comment(models.Model): else: return super(Comment, cls).url(action, params) + def flagAbuse(self, user, voteable): + if voteable.type == 'thread': + url = _url_for_flag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_flag_abuse_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag threads or comments") + params = {'user_id': user.id} + request = perform_request('put', url, params) + voteable.update_attributes(request) + + def unFlagAbuse(self, user, voteable, removeAll): + if voteable.type == 'thread': + url = _url_for_unflag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_unflag_abuse_comment(voteable.id) + else: + raise CommentClientError("Can flag/unflag for threads or comments") + params = {'user_id': user.id} + + if removeAll: + params['all'] = True + + request = perform_request('put', url, params) + voteable.update_attributes(request) + def _url_for_thread_comments(thread_id): return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id) @@ -49,3 +75,11 @@ def _url_for_thread_comments(thread_id): def _url_for_comment(comment_id): return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id) + + +def _url_for_flag_abuse_comment(comment_id): + return "{prefix}/comments/{comment_id}/abuse_flags".format(prefix=settings.PREFIX, comment_id=comment_id) + + +def _url_for_unflag_abuse_comment(comment_id): + return "{prefix}/comments/{comment_id}/abuse_unflags".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 index 862483a75b..9b1a0baee2 100644 --- a/lms/lib/comment_client/comment_client.py +++ b/lms/lib/comment_client/comment_client.py @@ -29,7 +29,6 @@ def search_trending_tags(course_id, query_params={}, *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) diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index 8911d5a2c6..60a68dc3ae 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -1,5 +1,4 @@ from .utils import * - import models import settings @@ -11,7 +10,7 @@ class Thread(models.Model): 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'at_position_list', 'children', 'type', 'highlighted_title', - 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned' + 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers' ] updatable_fields = [ @@ -27,11 +26,13 @@ class Thread(models.Model): @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') or query_params.get('commentable_ids'): url = cls.url(action='search') else: @@ -54,6 +55,7 @@ class Thread(models.Model): @classmethod def url(cls, action, params={}): + if action in ['get_all', 'post']: return cls.url_for_threads(params) elif action == 'search': @@ -66,12 +68,11 @@ class Thread(models.Model): # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): url = self.url(action='get', params=self.attributes) - request_params = { - 'recursive': kwargs.get('recursive'), - 'user_id': kwargs.get('user_id'), - 'mark_as_read': kwargs.get('mark_as_read', True), - } + 'recursive': kwargs.get('recursive'), + 'user_id': kwargs.get('user_id'), + 'mark_as_read': kwargs.get('mark_as_read', True), + } # user_id may be none, in which case it shouldn't be part of the # request. @@ -79,23 +80,57 @@ class Thread(models.Model): response = perform_request('get', url, request_params) self.update_attributes(**response) - + + def flagAbuse(self, user, voteable): + if voteable.type == 'thread': + url = _url_for_flag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_flag_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag threads or comments") + params = {'user_id': user.id} + request = perform_request('put', url, params) + voteable.update_attributes(request) + + def unFlagAbuse(self, user, voteable, removeAll): + if voteable.type == 'thread': + url = _url_for_unflag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_unflag_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag for threads or comments") + params = {'user_id': user.id} + #if you're an admin, when you unflag, remove ALL flags + if removeAll: + params['all'] = True + + request = perform_request('put', url, params) + voteable.update_attributes(request) + def pin(self, user, thread_id): url = _url_for_pin_thread(thread_id) params = {'user_id': user.id} request = perform_request('put', url, params) - self.update_attributes(request) + self.update_attributes(request) def un_pin(self, user, thread_id): url = _url_for_un_pin_thread(thread_id) params = {'user_id': user.id} request = perform_request('put', url, params) - self.update_attributes(request) - - + self.update_attributes(request) + + +def _url_for_flag_abuse_thread(thread_id): + return "{prefix}/threads/{thread_id}/abuse_flags".format(prefix=settings.PREFIX, thread_id=thread_id) + + +def _url_for_unflag_abuse_thread(thread_id): + return "{prefix}/threads/{thread_id}/abuse_unflags".format(prefix=settings.PREFIX, thread_id=thread_id) + + def _url_for_pin_thread(thread_id): - return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) - + return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) + + def _url_for_un_pin_thread(thread_id): - return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) - \ No newline at end of file + return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) diff --git a/lms/static/images/flagged.png b/lms/static/images/flagged.png new file mode 100644 index 0000000000000000000000000000000000000000..c3de8577330a8719d91375a7b23b85c6e29fdc00 GIT binary patch literal 40840 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 XY(MU5$SVKkB6Gp;%Guo0H}3x*@%T+T literal 0 HcmV?d00001 diff --git a/lms/static/images/notflagged.png b/lms/static/images/notflagged.png new file mode 100644 index 0000000000000000000000000000000000000000..42bc0b3aff36ecf2dc79801e5c670037d09ad198 GIT binary patch literal 39000 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 z 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 ez0O;+yOX}m11*~rrbnLMGA<}^}zy2S2oP1FL literal 0 HcmV?d00001 diff --git a/lms/static/images/resolvedflag.png b/lms/static/images/resolvedflag.png new file mode 100644 index 0000000000000000000000000000000000000000..8e318f786ce8bcdefc4b7a1b686ec2afd42bd6c0 GIT binary patch literal 362 zcmeAS@N?(olHy`uVBq!ia0vp^AT}Qd6Of#FQ0fnmVlH;_4B_D5xc$)o0g%gC;1OBO zz`!j8!i<;h)`8T>l(C#5R5WfrBD=NDxcD>w(6z1Xv<2dGXI zq|Ui0HL)Z!KTjbfGdGpN&`94z-_U5*^#7ND3XXWXIEF|}P5$x!|Nq72{(X0?etx`t z{J%tlVPV>{ce8)byfO1%-S5+fPc2qnoVhYnTRdI=zxBuIowINH-05iToH>z^O=70x%*se0Qj8Ic3sYm;(_oY-#xZs6^fLzz z6uf$R^Y#1pH9x-X(TD^-^6R9$$9r~cnz y-Y2P#O@+-DwO!`$Ft+*k<;|1s%`+x4GBR{uPcxDDXOIE(4}+(xpUXO@geCxqpqFs~ literal 0 HcmV?d00001 diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index 9583a8d30f..c03785859e 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -95,6 +95,7 @@ body.discussion { + .new-post-form-errors { display: none; background: $error-red; @@ -1280,8 +1281,8 @@ body.discussion { .discussion-article { position: relative; padding: 40px; - min-height: 468px; - + min-height: 468px; + a { word-wrap: break-word; } @@ -1334,6 +1335,9 @@ body.discussion { background-position: 0 0; } } + + + } .discussion-post { @@ -2436,7 +2440,6 @@ body.discussion { @extend .discussion-module } - .group-visibility-label { font-size: 12px; color:#000; @@ -2491,4 +2494,39 @@ body.discussion { .pinned-false { display:none; +} + +.discussion-flag-abuse { + font-size: 12px; + float:right; + padding-right: 5px; + font-style: italic; + } + +.notflagged .icon +{ + display: inline-block; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/notflagged.png') no-repeat 0 0; +} + +.flagged .icon +{ + display: inline-block; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/flagged.png') no-repeat 0 0; +} + +.flagged span { + color: #B82066; + font-style: italic; +} + +.notflagged span { + color: #888; + font-style: italic; } \ No newline at end of file diff --git a/lms/templates/discussion/_filter_dropdown.html b/lms/templates/discussion/_filter_dropdown.html index fef4abb11f..dd5b94f910 100644 --- a/lms/templates/discussion/_filter_dropdown.html +++ b/lms/templates/discussion/_filter_dropdown.html @@ -33,6 +33,14 @@ Show All Discussions + %if flag_moderator: +
  • + + Show Flagged Discussions + +
  • + + %endif
  • Following diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 24e3b467be..110e6ffc19 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -3,6 +3,7 @@ + diff --git a/rakefiles/jasmine.rake b/rakefiles/jasmine.rake index d9b3bee427..36e4e57a27 100644 --- a/rakefiles/jasmine.rake +++ b/rakefiles/jasmine.rake @@ -35,11 +35,19 @@ def django_for_jasmine(system, django_reload) end def template_jasmine_runner(lib) - coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] + case lib + when /common\/lib\/.+/ + coffee_files = Dir["#{lib}/**/js/**/*.coffee", "common/static/coffee/src/**/*.coffee"] + when /common\/static\/coffee/ + coffee_files = Dir["#{lib}/**/*.coffee"] + else + puts('I do not know how to run jasmine tests for #{lib}') + exit + end if !coffee_files.empty? sh("node_modules/.bin/coffee -c #{coffee_files.join(' ')}") end - phantom_jasmine_path = File.expand_path("node_modules/phantom-jasmine") + phantom_jasmine_path = File.expand_path("common/test/phantom-jasmine") common_js_root = File.expand_path("common/static/js") common_coffee_root = File.expand_path("common/static/coffee/src") @@ -50,7 +58,7 @@ def template_jasmine_runner(lib) js_specs = Dir[spec_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} js_source = Dir[src_glob].sort_by {|p| [p.split('/').length, p]} .map {|f| File.expand_path(f)} - template = ERB.new(File.read("#{lib}/jasmine_test_runner.html.erb")) + template = ERB.new(File.read("common/templates/jasmine/jasmine_test_runner.html.erb")) template_output = "#{lib}/jasmine_test_runner.html" File.open(template_output, 'w') do |f| f.write(template.result(binding)) @@ -95,3 +103,20 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| end end end + +desc "Open jasmine tests for discussion in your default browser" +task "browse_jasmine_discussion" do + template_jasmine_runner("common/static/coffee") do |f| + sh("python -m webbrowser -t 'file://#{f}'") + puts "Press ENTER to terminate".red + $stdin.gets + end +end + +desc "Use phantomjs to run jasmine tests for discussion from the console" +task "phantomjs_jasmine_discussion" do + phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' + template_jasmine_runner("common/static/coffee") do |f| + sh("#{phantomjs} node_modules/phantom-jasmine/lib/run_jasmine_test.coffee #{f}") + end +end From cbdf93473b15744e4c7130bc72159319c90bf194 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 7 May 2013 16:21:37 -0400 Subject: [PATCH 17/36] start to get the jasmine tests working for comment service --- .../spec/discussion/content_spec.coffee | 43 +++++++++++++++++++ .../response_comment_show_view_spec.coffee | 31 +++---------- common/templates/jasmine/base.html | 5 --- .../jasmine/jasmine_test_runner.html.erb | 11 +++-- 4 files changed, 57 insertions(+), 33 deletions(-) create mode 100644 common/static/coffee/spec/discussion/content_spec.coffee diff --git a/common/static/coffee/spec/discussion/content_spec.coffee b/common/static/coffee/spec/discussion/content_spec.coffee new file mode 100644 index 0000000000..6b6188ad19 --- /dev/null +++ b/common/static/coffee/spec/discussion/content_spec.coffee @@ -0,0 +1,43 @@ +describe 'Content', -> + beforeEach -> + # TODO: figure out a better way of handling this + # It is set up in main.coffee DiscussionApp.start + window.$$course_id = 'mitX/999/test' + window.user = new DiscussionUser {id: '567'} + + @content = new Content { + id: '01234567', + user_id: '567', + course_id: 'mitX/999/test', + body: 'this is some content', + abuse_flaggers: ['123'] + } + + it 'should exist', -> + expect(Content).toBeDefined() + + it 'is initialized correctly', -> + @content.initialize + expect(Content.contents['01234567']).toEqual @content + expect(@content.get 'id').toEqual '01234567' + expect(@content.get 'user_url').toEqual '/courses/mitX/999/test/discussion/forum/users/567' + expect(@content.get 'children').toEqual [] + expect(@content.get 'comments').toEqual(jasmine.any(Comments)) + + it 'can update info', -> + @content.updateInfo { + ability: 'can_endorse', + voted: true, + subscribed: true + } + expect(@content.get 'ability').toEqual 'can_endorse' + expect(@content.get 'voted').toEqual true + expect(@content.get 'subscribed').toEqual true + + describe 'can be flagged and unflagged', -> + beforeEach -> + spyOn @content, 'trigger' + + it 'can be flagged for abuse', -> + @content.flagAbuse + expect(@content.get 'abuse_flaggers').toEqual ['123', '567'] diff --git a/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee index a4168edb3c..814a428c70 100644 --- a/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/response_comment_show_view_spec.coffee @@ -1,26 +1,7 @@ -describe "ResponseCommentShowView", -> - beforeEach -> - setFixtures """ -
  • - -
  • - """ - # spyOn($.fn, 'load').andReturn(@moduleData) +xdescribe "ResponseCommentShowView", -> - @showView = new ResponseCommentShowView( - el: $("li") - ) - - describe "class definition", -> - it "sets the correct tagName", -> - expect(@showView.tagName).toEqual("li") + it "defines the class", -> + spyOn myComment, 'initialize' + myComment = new Comment() + myView = new ResponseCommentShowView(myComment) + expect(myView.tagName).toBeDefined() diff --git a/common/templates/jasmine/base.html b/common/templates/jasmine/base.html index c9a32f4005..3b8013a282 100644 --- a/common/templates/jasmine/base.html +++ b/common/templates/jasmine/base.html @@ -37,22 +37,17 @@ - + + + + + + + + - - + """ - it "defines the class", -> - spyOn myComment, 'initialize' - myComment = new Comment() - myView = new ResponseCommentShowView(myComment) - expect(myView.tagName).toBeDefined() + # set up a model for a new Comment + @response = new Comment { + id: '01234567', + user_id: '567', + course_id: 'mitX/999/test', + body: 'this is a response', + created_at: '2013-04-03T20:08:39Z', + abuse_flaggers: ['123'] + roles: [] + } + @view = new ResponseCommentShowView({ model: @response }) + + # spyOn(DiscussionUtil, 'loadRoles').andReturn [] + + it 'defines the tag', -> + expect($('#jasmine-fixtures')).toExist + expect(@view.tagName).toBeDefined + expect(@view.el.tagName.toLowerCase()).toBe 'li' + + it 'is tied to the model', -> + expect(@view.model).toBeDefined(); + + describe 'rendering', -> + + beforeEach -> + spyOn(@view, 'renderAttrs') + spyOn(@view, 'markAsStaff') + spyOn(@view, 'convertMath') + + it 'produces the correct HTML', -> + @view.render() + expect(@view.el.innerHTML).toContainHtml """ +
    this is a response
    +
    +
    + """ diff --git a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee index 18d405fdb4..6023964c75 100644 --- a/common/static/coffee/src/discussion/views/response_comment_show_view.coffee +++ b/common/static/coffee/src/discussion/views/response_comment_show_view.coffee @@ -1,11 +1,11 @@ if Backbone? class @ResponseCommentShowView extends DiscussionContentView - + events: - "click .discussion-flag-abuse": "toggleFlagAbuse" + "click .discussion-flag-abuse": "toggleFlagAbuse" tagName: "li" - + initialize: -> super() @model.on "change", @updateModelDetails @@ -42,17 +42,17 @@ if Backbone? @$el.find("a.profile-link").after('staff') else if DiscussionUtil.isTA(@model.get("user_id")) @$el.find("a.profile-link").after('Community  TA') - - + + renderFlagged: => if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) - @$("[data-role=thread-flag]").addClass("flagged") + @$("[data-role=thread-flag]").addClass("flagged") @$("[data-role=thread-flag]").removeClass("notflagged") else - @$("[data-role=thread-flag]").removeClass("flagged") - @$("[data-role=thread-flag]").addClass("notflagged") - + @$("[data-role=thread-flag]").removeClass("flagged") + @$("[data-role=thread-flag]").addClass("notflagged") + updateModelDetails: => @renderFlagged() - - + + diff --git a/common/static/js/vendor/flot/jquery.timeago.js b/common/static/js/vendor/flot/jquery.timeago.js new file mode 100644 index 0000000000..2e8d29f536 --- /dev/null +++ b/common/static/js/vendor/flot/jquery.timeago.js @@ -0,0 +1,152 @@ +/** + * Timeago is a jQuery plugin that makes it easy to support automatically + * updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago"). + * + * @name timeago + * @version 0.11.4 + * @requires jQuery v1.2.3+ + * @author Ryan McGeary + * @license MIT License - http://www.opensource.org/licenses/mit-license.php + * + * For usage and examples, visit: + * http://timeago.yarp.com/ + * + * Copyright (c) 2008-2012, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org) + */ +(function($) { + $.timeago = function(timestamp) { + if (timestamp instanceof Date) { + return inWords(timestamp); + } else if (typeof timestamp === "string") { + return inWords($.timeago.parse(timestamp)); + } else if (typeof timestamp === "number") { + return inWords(new Date(timestamp)); + } else { + return inWords($.timeago.datetime(timestamp)); + } + }; + var $t = $.timeago; + + $.extend($.timeago, { + settings: { + refreshMillis: 60000, + allowFuture: false, + strings: { + prefixAgo: null, + prefixFromNow: null, + suffixAgo: "ago", + suffixFromNow: "from now", + seconds: "less than a minute", + minute: "about a minute", + minutes: "%d minutes", + hour: "about an hour", + hours: "about %d hours", + day: "a day", + days: "%d days", + month: "about a month", + months: "%d months", + year: "about a year", + years: "%d years", + wordSeparator: " ", + numbers: [] + } + }, + inWords: function(distanceMillis) { + var $l = this.settings.strings; + var prefix = $l.prefixAgo; + var suffix = $l.suffixAgo; + if (this.settings.allowFuture) { + if (distanceMillis < 0) { + prefix = $l.prefixFromNow; + suffix = $l.suffixFromNow; + } + } + + var seconds = Math.abs(distanceMillis) / 1000; + var minutes = seconds / 60; + var hours = minutes / 60; + var days = hours / 24; + var years = days / 365; + + function substitute(stringOrFunction, number) { + var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction; + var value = ($l.numbers && $l.numbers[number]) || number; + return string.replace(/%d/i, value); + } + + var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) || + seconds < 90 && substitute($l.minute, 1) || + minutes < 45 && substitute($l.minutes, Math.round(minutes)) || + minutes < 90 && substitute($l.hour, 1) || + hours < 24 && substitute($l.hours, Math.round(hours)) || + hours < 42 && substitute($l.day, 1) || + days < 30 && substitute($l.days, Math.round(days)) || + days < 45 && substitute($l.month, 1) || + days < 365 && substitute($l.months, Math.round(days / 30)) || + years < 1.5 && substitute($l.year, 1) || + substitute($l.years, Math.round(years)); + + var separator = $l.wordSeparator === undefined ? " " : $l.wordSeparator; + return $.trim([prefix, words, suffix].join(separator)); + }, + parse: function(iso8601) { + var s = $.trim(iso8601); + s = s.replace(/\.\d+/,""); // remove milliseconds + s = s.replace(/-/,"/").replace(/-/,"/"); + s = s.replace(/T/," ").replace(/Z/," UTC"); + s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400 + return new Date(s); + }, + datetime: function(elem) { + var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title"); + return $t.parse(iso8601); + }, + isTime: function(elem) { + // jQuery's `is()` doesn't play well with HTML5 in IE + return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time"); + } + }); + + $.fn.timeago = function() { + var self = this; + self.each(refresh); + + var $s = $t.settings; + if ($s.refreshMillis > 0) { + setInterval(function() { self.each(refresh); }, $s.refreshMillis); + } + return self; + }; + + function refresh() { + var data = prepareData(this); + if (!isNaN(data.datetime)) { + $(this).text(inWords(data.datetime)); + } + return this; + } + + function prepareData(element) { + element = $(element); + if (!element.data("timeago")) { + element.data("timeago", { datetime: $t.datetime(element) }); + var text = $.trim(element.text()); + if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) { + element.attr("title", text); + } + } + return element.data("timeago"); + } + + function inWords(date) { + return $t.inWords(distance(date)); + } + + function distance(date) { + return (new Date().getTime() - date.getTime()); + } + + // fix for IE6 suckage + document.createElement("abbr"); + document.createElement("time"); +}(jQuery)); diff --git a/common/static/js/vendor/jquery.timeago.js b/common/static/js/vendor/jquery.timeago.js new file mode 100644 index 0000000000..2e8d29f536 --- /dev/null +++ b/common/static/js/vendor/jquery.timeago.js @@ -0,0 +1,152 @@ +/** + * Timeago is a jQuery plugin that makes it easy to support automatically + * updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago"). + * + * @name timeago + * @version 0.11.4 + * @requires jQuery v1.2.3+ + * @author Ryan McGeary + * @license MIT License - http://www.opensource.org/licenses/mit-license.php + * + * For usage and examples, visit: + * http://timeago.yarp.com/ + * + * Copyright (c) 2008-2012, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org) + */ +(function($) { + $.timeago = function(timestamp) { + if (timestamp instanceof Date) { + return inWords(timestamp); + } else if (typeof timestamp === "string") { + return inWords($.timeago.parse(timestamp)); + } else if (typeof timestamp === "number") { + return inWords(new Date(timestamp)); + } else { + return inWords($.timeago.datetime(timestamp)); + } + }; + var $t = $.timeago; + + $.extend($.timeago, { + settings: { + refreshMillis: 60000, + allowFuture: false, + strings: { + prefixAgo: null, + prefixFromNow: null, + suffixAgo: "ago", + suffixFromNow: "from now", + seconds: "less than a minute", + minute: "about a minute", + minutes: "%d minutes", + hour: "about an hour", + hours: "about %d hours", + day: "a day", + days: "%d days", + month: "about a month", + months: "%d months", + year: "about a year", + years: "%d years", + wordSeparator: " ", + numbers: [] + } + }, + inWords: function(distanceMillis) { + var $l = this.settings.strings; + var prefix = $l.prefixAgo; + var suffix = $l.suffixAgo; + if (this.settings.allowFuture) { + if (distanceMillis < 0) { + prefix = $l.prefixFromNow; + suffix = $l.suffixFromNow; + } + } + + var seconds = Math.abs(distanceMillis) / 1000; + var minutes = seconds / 60; + var hours = minutes / 60; + var days = hours / 24; + var years = days / 365; + + function substitute(stringOrFunction, number) { + var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction; + var value = ($l.numbers && $l.numbers[number]) || number; + return string.replace(/%d/i, value); + } + + var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) || + seconds < 90 && substitute($l.minute, 1) || + minutes < 45 && substitute($l.minutes, Math.round(minutes)) || + minutes < 90 && substitute($l.hour, 1) || + hours < 24 && substitute($l.hours, Math.round(hours)) || + hours < 42 && substitute($l.day, 1) || + days < 30 && substitute($l.days, Math.round(days)) || + days < 45 && substitute($l.month, 1) || + days < 365 && substitute($l.months, Math.round(days / 30)) || + years < 1.5 && substitute($l.year, 1) || + substitute($l.years, Math.round(years)); + + var separator = $l.wordSeparator === undefined ? " " : $l.wordSeparator; + return $.trim([prefix, words, suffix].join(separator)); + }, + parse: function(iso8601) { + var s = $.trim(iso8601); + s = s.replace(/\.\d+/,""); // remove milliseconds + s = s.replace(/-/,"/").replace(/-/,"/"); + s = s.replace(/T/," ").replace(/Z/," UTC"); + s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400 + return new Date(s); + }, + datetime: function(elem) { + var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title"); + return $t.parse(iso8601); + }, + isTime: function(elem) { + // jQuery's `is()` doesn't play well with HTML5 in IE + return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time"); + } + }); + + $.fn.timeago = function() { + var self = this; + self.each(refresh); + + var $s = $t.settings; + if ($s.refreshMillis > 0) { + setInterval(function() { self.each(refresh); }, $s.refreshMillis); + } + return self; + }; + + function refresh() { + var data = prepareData(this); + if (!isNaN(data.datetime)) { + $(this).text(inWords(data.datetime)); + } + return this; + } + + function prepareData(element) { + element = $(element); + if (!element.data("timeago")) { + element.data("timeago", { datetime: $t.datetime(element) }); + var text = $.trim(element.text()); + if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) { + element.attr("title", text); + } + } + return element.data("timeago"); + } + + function inWords(date) { + return $t.inWords(distance(date)); + } + + function distance(date) { + return (new Date().getTime() - date.getTime()); + } + + // fix for IE6 suckage + document.createElement("abbr"); + document.createElement("time"); +}(jQuery)); diff --git a/common/templates/jasmine/base.html b/common/templates/jasmine/base.html index 3b8013a282..9a1b3bed92 100644 --- a/common/templates/jasmine/base.html +++ b/common/templates/jasmine/base.html @@ -37,18 +37,18 @@ +