Commit 1a251a49 by Andy Armstrong

Merge pull request #9105 from edx/andya/team-discussion-component

Show discussion component on Team view
parents 3570d088 2f7fa6fd
...@@ -67,5 +67,6 @@ class @DiscussionSpecHelper ...@@ -67,5 +67,6 @@ class @DiscussionSpecHelper
data-course-name="Fake Course" data-course-name="Fake Course"
data-user-create-comment="true" data-user-create-comment="true"
data-user-create-subcomment="true" data-user-create-subcomment="true"
data-read-only="false"
></div> ></div>
""") """)
...@@ -205,7 +205,7 @@ describe "DiscussionUserProfileView", -> ...@@ -205,7 +205,7 @@ describe "DiscussionUserProfileView", ->
) )
{always: ->} {always: ->}
) )
@view.$(".pagination a").first().click() @view.$(".discussion-pagination a").first().click()
expect(@view.$(".current-page").text()).toEqual("42") expect(@view.$(".current-page").text()).toEqual("42")
expect(@view.$(".last-page").text()).toEqual("99") expect(@view.$(".last-page").text()).toEqual("99")
...@@ -216,5 +216,5 @@ describe "DiscussionUserProfileView", -> ...@@ -216,5 +216,5 @@ describe "DiscussionUserProfileView", ->
params.error() params.error()
{always: ->} {always: ->}
) )
@view.$(".pagination a").first().click() @view.$(".discussion-pagination a").first().click()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalled() expect(DiscussionUtil.discussionAlert).toHaveBeenCalled()
...@@ -10,10 +10,11 @@ if Backbone? ...@@ -10,10 +10,11 @@ if Backbone?
"click .discussion-paginator a": "navigateToPage" "click .discussion-paginator a": "navigateToPage"
page_re: /\?discussion_page=(\d+)/ page_re: /\?discussion_page=(\d+)/
initialize: -> initialize: (options) ->
@toggleDiscussionBtn = @$(".discussion-show") @toggleDiscussionBtn = @$(".discussion-show")
# Set the page if it was set in the URL. This is used to allow deep linking to pages # Set the page if it was set in the URL. This is used to allow deep linking to pages
match = @page_re.exec(window.location.href) match = @page_re.exec(window.location.href)
@context = options.context or "course" # allowed values are "course" or "standalone"
if match if match
@page = parseInt(match[1]) @page = parseInt(match[1])
else else
...@@ -105,6 +106,7 @@ if Backbone? ...@@ -105,6 +106,7 @@ if Backbone?
el: @$("article#thread_#{thread.id}"), el: @$("article#thread_#{thread.id}"),
model: thread, model: thread,
mode: "inline", mode: "inline",
context: @context,
course_settings: @course_settings, course_settings: @course_settings,
topicId: discussionId topicId: discussionId
) )
...@@ -141,6 +143,7 @@ if Backbone? ...@@ -141,6 +143,7 @@ if Backbone?
el: article, el: article,
model: thread, model: thread,
mode: "inline", mode: "inline",
context: @context,
course_settings: @course_settings, course_settings: @course_settings,
topicId: @$el.data("discussion-id") topicId: @$el.data("discussion-id")
) )
...@@ -152,7 +155,7 @@ if Backbone? ...@@ -152,7 +155,7 @@ if Backbone?
"?discussion_page=#{number}" "?discussion_page=#{number}"
params = DiscussionUtil.getPaginationParams(@page, numPages, pageUrl) params = DiscussionUtil.getPaginationParams(@page, numPages, pageUrl)
pagination = _.template($("#pagination-template").html())(params) pagination = _.template($("#pagination-template").html())(params)
@$('section.pagination').html(pagination) @$('section.discussion-pagination').html(pagination)
navigateToPage: (event) => navigateToPage: (event) =>
event.preventDefault() event.preventDefault()
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
this.course_settings = options.course_settings; this.course_settings = options.course_settings;
this.threadType = this.model.get('thread_type'); this.threadType = this.model.get('thread_type');
this.topicId = this.model.get('commentable_id'); this.topicId = this.model.get('commentable_id');
this.context = options.context || 'course';
_.bindAll(this); _.bindAll(this);
return this; return this;
}, },
...@@ -31,11 +32,15 @@ ...@@ -31,11 +32,15 @@
threadTypeTemplate = _.template($("#thread-type-template").html()); threadTypeTemplate = _.template($("#thread-type-template").html());
this.addField(threadTypeTemplate({form_id: formId})); this.addField(threadTypeTemplate({form_id: formId}));
this.$("#" + formId + "-post-type-" + this.threadType).attr('checked', true); this.$("#" + formId + "-post-type-" + this.threadType).attr('checked', true);
this.topicView = new DiscussionTopicMenuView({ // Only allow the topic field for course threads, as standalone threads
topicId: this.topicId, // cannot be moved.
course_settings: this.course_settings if (this.context === 'course') {
}); this.topicView = new DiscussionTopicMenuView({
this.addField(this.topicView.render()); topicId: this.topicId,
course_settings: this.course_settings
});
this.addField(this.topicView.render());
}
DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-post-body'); DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-post-body');
return this; return this;
}, },
...@@ -53,13 +58,14 @@ ...@@ -53,13 +58,14 @@
var title = this.$('.edit-post-title').val(), var title = this.$('.edit-post-title').val(),
threadType = this.$(".post-type-input:checked").val(), threadType = this.$(".post-type-input:checked").val(),
body = this.$('.edit-post-body textarea').val(), body = this.$('.edit-post-body textarea').val(),
commentableId = this.topicView.getCurrentTopicId(),
postData = { postData = {
title: title, title: title,
thread_type: threadType, thread_type: threadType,
body: body, body: body
commentable_id: commentableId
}; };
if (this.topicView) {
postData.commentable_id = this.topicView.getCurrentTopicId();
}
return DiscussionUtil.safeAjax({ return DiscussionUtil.safeAjax({
$elem: this.submitBtn, $elem: this.submitBtn,
...@@ -75,7 +81,9 @@ ...@@ -75,7 +81,9 @@
this.$('.edit-post-title').val('').attr('prev-text', ''); this.$('.edit-post-title').val('').attr('prev-text', '');
this.$('.edit-post-body textarea').val('').attr('prev-text', ''); this.$('.edit-post-body textarea').val('').attr('prev-text', '');
this.$('.wmd-preview p').html(''); this.$('.wmd-preview p').html('');
postData.courseware_title = this.topicView.getFullTopicName(); if (this.topicView) {
postData.courseware_title = this.topicView.getFullTopicName();
}
this.model.set(postData).unset('abbreviatedBody'); this.model.set(postData).unset('abbreviatedBody');
this.trigger('thread:updated'); this.trigger('thread:updated');
if (this.threadType !== threadType) { if (this.threadType !== threadType) {
......
...@@ -13,7 +13,8 @@ if Backbone? ...@@ -13,7 +13,8 @@ if Backbone?
mode: @mode, mode: @mode,
flagged: @model.isFlagged(), flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(), author_display: @getAuthorDisplay(),
cid: @model.cid cid: @model.cid,
readOnly: $('.discussion-module').data('read-only')
}, },
@model.attributes, @model.attributes,
) )
......
...@@ -19,9 +19,12 @@ if Backbone? ...@@ -19,9 +19,12 @@ if Backbone?
initialize: (options) -> initialize: (options) ->
super() super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline" @mode = options.mode or "inline" # allowed values are "tab" or "inline"
@context = options.context or "course" # allowed values are "course" or "standalone"
if @mode not in ["tab", "inline"] if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode) throw new Error("invalid mode: " + @mode)
@readOnly = $(".discussion-module").data('read-only')
# Quick fix to have an actual model when we're receiving new models from # Quick fix to have an actual model when we're receiving new models from
# the server. # the server.
@model.collection.on "reset", (collection) => @model.collection.on "reset", (collection) =>
...@@ -50,12 +53,15 @@ if Backbone? ...@@ -50,12 +53,15 @@ if Backbone?
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-template").html()) @template = _.template($("#thread-template").html())
templateData = @model.toJSON()
container = $("#discussion-container") container = $("#discussion-container")
if !container.length if !container.length
# inline discussion # inline discussion
container = $(".discussion-module") container = $(".discussion-module")
templateData.can_create_comment = container.data("user-create-comment") templateData = _.extend(
@model.toJSON(),
readOnly: @readOnly,
can_create_comment: container.data("user-create-comment")
)
@template(templateData) @template(templateData)
render: -> render: ->
...@@ -300,6 +306,7 @@ if Backbone? ...@@ -300,6 +306,7 @@ if Backbone?
container: @$('.thread-content-wrapper') container: @$('.thread-content-wrapper')
model: @model model: @model
mode: @mode mode: @mode
context: @context
course_settings: @options.course_settings course_settings: @options.course_settings
) )
@editView.bind "thread:updated thread:cancel_edit", @closeEditView @editView.bind "thread:updated thread:cancel_edit", @closeEditView
......
...@@ -18,7 +18,7 @@ if Backbone? ...@@ -18,7 +18,7 @@ if Backbone?
baseUri = URI(window.location).removeSearch("page") baseUri = URI(window.location).removeSearch("page")
pageUrlFunc = (page) -> baseUri.clone().addSearch("page", page) pageUrlFunc = (page) -> baseUri.clone().addSearch("page", page)
paginationParams = DiscussionUtil.getPaginationParams(@page, @numPages, pageUrlFunc) paginationParams = DiscussionUtil.getPaginationParams(@page, @numPages, pageUrlFunc)
@$el.find(".pagination").html(_.template($("#pagination-template").html())(paginationParams)) @$el.find(".discussion-pagination").html(_.template($("#pagination-template").html())(paginationParams))
changePage: (event) -> changePage: (event) ->
event.preventDefault() event.preventDefault()
......
...@@ -87,7 +87,6 @@ if Backbone? ...@@ -87,7 +87,6 @@ if Backbone?
url: url url: url
type: "POST" type: "POST"
dataType: 'json' dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data: data:
thread_type: thread_type thread_type: thread_type
title: title title: title
......
...@@ -9,7 +9,8 @@ if Backbone? ...@@ -9,7 +9,8 @@ if Backbone?
_.extend( _.extend(
{ {
cid: @model.cid, cid: @model.cid,
author_display: @getAuthorDisplay() author_display: @getAuthorDisplay(),
readOnly: $('.discussion-module').data('read-only')
}, },
@model.attributes @model.attributes
) )
......
...@@ -10,7 +10,8 @@ if Backbone? ...@@ -10,7 +10,8 @@ if Backbone?
{ {
cid: @model.cid, cid: @model.cid,
author_display: @getAuthorDisplay(), author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay() endorser_display: @getEndorserDisplay(),
readOnly: $('.discussion-module').data('read-only')
}, },
@model.attributes @model.attributes
) )
......
...@@ -13,17 +13,21 @@ if Backbone? ...@@ -13,17 +13,21 @@ if Backbone?
initialize: (options) -> initialize: (options) ->
@collapseComments = options.collapseComments @collapseComments = options.collapseComments
@createShowView() @createShowView()
@readOnly = $('.discussion-module').data('read-only')
renderTemplate: -> renderTemplate: ->
@template = _.template($("#thread-response-template").html()) @template = _.template($("#thread-response-template").html())
templateData = @model.toJSON()
templateData.wmdId = @model.id ? (new Date()).getTime()
container = $("#discussion-container") container = $("#discussion-container")
if !container.length if !container.length
# inline discussion # inline discussion
container = $(".discussion-module") container = $(".discussion-module")
templateData.create_sub_comment = container.data("user-create-subcomment") templateData = _.extend(
@model.toJSON(),
wmdId: @model.id ? (new Date()).getTime(),
create_sub_comment: container.data("user-create-subcomment"),
readOnly: @readOnly
)
@template(templateData) @template(templateData)
render: -> render: ->
...@@ -88,7 +92,10 @@ if Backbone? ...@@ -88,7 +92,10 @@ if Backbone?
comment.set('thread', @model.get('thread')) comment.set('thread', @model.get('thread'))
view = new ResponseCommentView(model: comment) view = new ResponseCommentView(model: comment)
view.render() view.render()
@$el.find(".comments .new-comment").before(view.el) if @readOnly
@$el.find('.comments').append(view.el)
else
@$el.find(".comments .new-comment").before(view.el)
view.bind "comment:edit", (event) => view.bind "comment:edit", (event) =>
@cancelEdit(event) if @editView? @cancelEdit(event) if @editView?
@cancelCommentEdits() @cancelCommentEdits()
......
<ul class="<%= contentType %>-actions-list"> <% if (!readOnly) { %>
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %> <ul class="<%= contentType %>-actions-list">
<li class="actions-item is-visible"> <% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<div class="more-wrapper"> <li class="actions-item is-visible">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>"> <div class="more-wrapper">
<span class="action-label"><%- gettext("More") %></span> <a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>">
<span class="action-icon"><i class="icon fa fa-ellipsis-h"></i></span> <span class="action-label"><%- gettext("More") %></span>
</a> <span class="action-icon"><i class="icon fa fa-ellipsis-h"></i></span>
<div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false"> </a>
<ul class="actions-dropdown-list"> <div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false">
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %> <ul class="actions-dropdown-list">
</ul> <% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
</ul>
</div>
</div> </div>
</div> </li>
</li> </ul>
</ul> <% } %>
...@@ -8,6 +8,6 @@ ...@@ -8,6 +8,6 @@
<% }); %> <% }); %>
</section> </section>
<section class="pagination"> <section class="discussion-pagination">
</section> </section>
</section> </section>
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
contentId: cid, contentId: cid,
contentType: 'comment', contentType: 'comment',
primaryActions: [], primaryActions: [],
secondaryActions: ['edit', 'delete', 'report'] secondaryActions: ['edit', 'delete', 'report'],
readOnly: readOnly
} }
) )
%> %>
......
...@@ -49,7 +49,8 @@ ...@@ -49,7 +49,8 @@
contentId: cid, contentId: cid,
contentType: 'response', contentType: 'response',
primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'], primaryActions: ['vote', thread.get('thread_type') == 'question' ? 'answer' : 'endorse'],
secondaryActions: ['edit', 'delete', 'report'] secondaryActions: ['edit', 'delete', 'report'],
readOnly: readOnly
} }
) )
%> %>
......
...@@ -12,16 +12,16 @@ ...@@ -12,16 +12,16 @@
</a> </a>
<ol class="comments"> <ol class="comments">
<li class="new-comment"> <li class="new-comment">
<% if (create_sub_comment) { %> <% if (create_sub_comment && !readOnly) { %>
<form class="comment-form" data-id="<%- wmdId %>"> <form class="comment-form" data-id="<%- wmdId %>">
<ul class="discussion-errors"></ul> <ul class="discussion-errors"></ul>
<label class="sr" for="add-new-comment"><%- gettext("Add a comment") %></label> <label class="sr" for="add-new-comment"><%- gettext("Add a comment") %></label>
<div class="comment-body" id="add-new-comment" data-id="<%- wmdId %>" <div class="comment-body" id="add-new-comment" data-id="<%- wmdId %>"
data-placeholder="<%- gettext('Add a comment') %>"></div> data-placeholder="<%- gettext('Add a comment') %>"></div>
<div class="comment-post-control"> <div class="comment-post-control">
<a class="discussion-submit-comment control-button" href="#"><%- gettext("Submit") %></a> <a class="discussion-submit-comment control-button" href="#"><%- gettext("Submit") %></a>
</div> </div>
</form> </form>
<% } %> <% } %>
</li> </li>
</ol> </ol>
...@@ -40,19 +40,22 @@ ...@@ -40,19 +40,22 @@
<span class="post-label-closed"><i class="icon fa fa-lock"></i><%- gettext("Closed") %></span> <span class="post-label-closed"><i class="icon fa fa-lock"></i><%- gettext("Closed") %></span>
</div> </div>
</div> </div>
<div class="post-header-actions post-extended-content"> <% if (!readOnly) { %>
<%= <div class="post-header-actions post-extended-content">
_.template( <%=
$('#forum-actions').html(), _.template(
{ $('#forum-actions').html(),
contentId: cid, {
contentType: 'post', contentId: cid,
primaryActions: ['vote', 'follow'], contentType: 'post',
secondaryActions: ['pin', 'edit', 'delete', 'report', 'close'] primaryActions: ['vote', 'follow'],
} secondaryActions: ['pin', 'edit', 'delete', 'report', 'close'],
) readOnly: readOnly
%> }
</div> )
%>
</div>
<% } %>
</header> </header>
<div class="post-body"><%- body %></div> <div class="post-body"><%- body %></div>
......
...@@ -8,18 +8,20 @@ ...@@ -8,18 +8,20 @@
</div> </div>
<div class="post-extended-content"> <div class="post-extended-content">
<div class="response-count"/> <div class="response-count"/>
<div class="add-response"> <% if (!readOnly) { %>
<button class="button add-response-btn"> <div class="add-response">
<i class="icon fa fa-reply"></i> <button class="button add-response-btn">
<span class="add-response-btn-text"><%- gettext("Add a Response") %></span> <i class="icon fa fa-reply"></i>
</button> <span class="add-response-btn-text"><%- gettext("Add a Response") %></span>
</div> </button>
</div>
<% } %>
<ol class="responses js-response-list"/> <ol class="responses js-response-list"/>
<div class="response-pagination"/> <div class="response-pagination"/>
<div class="post-status-closed bottom-post-status" style="display: none"> <div class="post-status-closed bottom-post-status" style="display: none">
<%- gettext("This thread is closed.") %> <%- gettext("This thread is closed.") %>
</div> </div>
<% if (can_create_comment) { %> <% if (can_create_comment && !readOnly) { %>
<form class="discussion-reply-new" data-id="<%- id %>"> <form class="discussion-reply-new" data-id="<%- id %>">
<h4><%- gettext("Post a response:") %></h4> <h4><%- gettext("Post a response:") %></h4>
<ul class="discussion-errors"></ul> <ul class="discussion-errors"></ul>
......
...@@ -4,4 +4,4 @@ ...@@ -4,4 +4,4 @@
<article class="discussion-thread" id="thread_<%= thread.id %>"/> <article class="discussion-thread" id="thread_<%= thread.id %>"/>
<% }); %> <% }); %>
</section> </section>
<section class="pagination"/> <section class="discussion-pagination"/>
...@@ -389,7 +389,7 @@ class InlineDiscussionPage(PageObject): ...@@ -389,7 +389,7 @@ class InlineDiscussionPage(PageObject):
def __init__(self, browser, discussion_id): def __init__(self, browser, discussion_id):
super(InlineDiscussionPage, self).__init__(browser) super(InlineDiscussionPage, self).__init__(browser)
self._discussion_selector = ( self._discussion_selector = (
"body.courseware .discussion-module[data-discussion-id='{discussion_id}'] ".format( ".discussion-module[data-discussion-id='{discussion_id}'] ".format(
discussion_id=discussion_id discussion_id=discussion_id
) )
) )
...@@ -418,6 +418,10 @@ class InlineDiscussionPage(PageObject): ...@@ -418,6 +418,10 @@ class InlineDiscussionPage(PageObject):
def get_num_displayed_threads(self): def get_num_displayed_threads(self):
return len(self._find_within(".discussion-thread")) return len(self._find_within(".discussion-thread"))
def has_thread(self, thread_id):
"""Returns true if this page is showing the thread with the specified id."""
return self._find_within('.discussion-thread#thread_{}'.format(thread_id)).present
def element_exists(self, selector): def element_exists(self, selector):
return self.q(css=self._discussion_selector + " " + selector).present return self.q(css=self._discussion_selector + " " + selector).present
......
...@@ -4,6 +4,7 @@ Teams pages. ...@@ -4,6 +4,7 @@ Teams pages.
""" """
from .course_page import CoursePage from .course_page import CoursePage
from .discussion import InlineDiscussionPage
from ..common.paging import PaginatedUIMixin from ..common.paging import PaginatedUIMixin
from .fields import FieldsMixin from .fields import FieldsMixin
...@@ -179,3 +180,50 @@ class CreateTeamPage(CoursePage, FieldsMixin): ...@@ -179,3 +180,50 @@ class CreateTeamPage(CoursePage, FieldsMixin):
"""Click on cancel team button""" """Click on cancel team button"""
self.q(css='.create-team .action-cancel').first.click() self.q(css='.create-team .action-cancel').first.click()
self.wait_for_ajax() self.wait_for_ajax()
class TeamPage(CoursePage, PaginatedUIMixin):
"""
The page for a specific Team within the Teams tab
"""
def __init__(self, browser, course_id, team=None):
"""
Set up `self.url_path` on instantiation, since it dynamically
reflects the current team.
"""
super(TeamPage, self).__init__(browser, course_id)
self.team = team
if self.team:
self.url_path = "teams/#teams/{topic_id}/{team_id}".format(
topic_id=self.team['topic_id'], team_id=self.team['id']
)
def is_browser_on_page(self):
"""Check if we're on the teams list page for a particular team."""
if self.team:
if not self.url.endswith(self.url_path):
return False
return self.q(css='.team-profile').present
@property
def discussion_id(self):
"""Get the id of the discussion module on the page"""
return self.q(css='div.discussion-module').attrs('data-discussion-id')[0]
@property
def discussion_page(self):
"""Get the discussion as a bok_choy page object"""
if not hasattr(self, '_discussion_page'):
# pylint: disable=attribute-defined-outside-init
self._discussion_page = InlineDiscussionPage(self.browser, self.discussion_id)
return self._discussion_page
@property
def team_name(self):
"""Get the team's name as displayed in the page header"""
return self.q(css='.page-header .page-title')[0].text
@property
def team_description(self):
"""Get the team's description as displayed in the page header"""
return self.q(css=TEAMS_HEADER_CSS + ' .page-description')[0].text
...@@ -1193,7 +1193,11 @@ class TeamsPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSe ...@@ -1193,7 +1193,11 @@ class TeamsPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSe
thread_author = getattr(self, thread_author) thread_author = getattr(self, thread_author)
self._setup_mock( self._setup_mock(
user, mock_request, # user is the person making the request. user, mock_request, # user is the person making the request.
{"user_id": str(thread_author.id), "closed": False, "commentable_id": commentable_id} {
"user_id": str(thread_author.id),
"closed": False, "commentable_id": commentable_id,
"context": "standalone"
}
) )
response = self.client.post( response = self.client.post(
reverse( reverse(
...@@ -1203,7 +1207,7 @@ class TeamsPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSe ...@@ -1203,7 +1207,7 @@ class TeamsPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSe
"thread_id": "dummy" "thread_id": "dummy"
} }
), ),
data={"body": "foo", "title": "foo"} data={"body": "foo", "title": "foo", "commentable_id": commentable_id}
) )
self.assertEqual(response.status_code, status_code) self.assertEqual(response.status_code, status_code)
......
...@@ -250,11 +250,13 @@ def update_thread(request, course_id, thread_id): ...@@ -250,11 +250,13 @@ def update_thread(request, course_id, thread_id):
if "thread_type" in request.POST: if "thread_type" in request.POST:
thread.thread_type = request.POST["thread_type"] thread.thread_type = request.POST["thread_type"]
if "commentable_id" in request.POST: if "commentable_id" in request.POST:
commentable_id = request.POST["commentable_id"]
course = get_course_with_access(request.user, 'load', course_key) course = get_course_with_access(request.user, 'load', course_key)
if discussion_category_id_access(course, request.user, request.POST.get("commentable_id")): thread_context = getattr(thread, "context", "course")
thread.commentable_id = request.POST["commentable_id"] if thread_context == "course" and not discussion_category_id_access(course, request.user, commentable_id):
else:
return JsonError(_("Topic doesn't exist")) return JsonError(_("Topic doesn't exist"))
else:
thread.commentable_id = commentable_id
thread.save() thread.save()
if request.is_ajax(): if request.is_ajax():
......
...@@ -176,6 +176,7 @@ def make_mock_request_impl( ...@@ -176,6 +176,7 @@ def make_mock_request_impl(
thread_id=thread_id, thread_id=thread_id,
num_children=num_thread_responses, num_children=num_thread_responses,
group_id=group_id, group_id=group_id,
commentable_id=commentable_id
) )
elif "/users/" in url: elif "/users/" in url:
data = { data = {
...@@ -336,8 +337,8 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase): ...@@ -336,8 +337,8 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase):
@ddt.data( @ddt.data(
# old mongo with cache # old mongo with cache
(ModuleStoreEnum.Type.mongo, 1, 7, 5, 14, 8), (ModuleStoreEnum.Type.mongo, 1, 6, 4, 14, 8),
(ModuleStoreEnum.Type.mongo, 50, 7, 5, 14, 8), (ModuleStoreEnum.Type.mongo, 50, 6, 4, 14, 8),
# split mongo: 3 queries, regardless of thread response size. # split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, 1, 3, 3, 14, 8), (ModuleStoreEnum.Type.split, 1, 3, 3, 14, 8),
(ModuleStoreEnum.Type.split, 50, 3, 3, 14, 8), (ModuleStoreEnum.Type.split, 50, 3, 3, 14, 8),
...@@ -668,6 +669,40 @@ class SingleThreadContentGroupTestCase(ContentGroupTestCase): ...@@ -668,6 +669,40 @@ class SingleThreadContentGroupTestCase(ContentGroupTestCase):
self.assert_can_access(self.non_cohorted_user, self.beta_module.discussion_id, thread_id, False) self.assert_can_access(self.non_cohorted_user, self.beta_module.discussion_id, thread_id, False)
def test_course_context_respected(self, mock_request):
"""
Verify that course threads go through discussion_category_id_access method.
"""
thread_id = "test_thread_id"
mock_request.side_effect = make_mock_request_impl(
course=self.course, text="dummy content", thread_id=thread_id
)
# Beta user does not have access to alpha_module.
self.assert_can_access(self.beta_user, self.alpha_module.discussion_id, thread_id, False)
def test_standalone_context_respected(self, mock_request):
"""
Verify that standalone threads don't go through discussion_category_id_access method.
"""
# For this rather pathological test, we are assigning the alpha module discussion_id (commentable_id)
# to a team so that we can verify that standalone threads don't go through discussion_category_id_access.
thread_id = "test_thread_id"
CourseTeamFactory(
name="A team",
course_id=self.course.id,
topic_id='topic_id',
discussion_topic_id=self.alpha_module.discussion_id
)
mock_request.side_effect = make_mock_request_impl(
course=self.course, text="dummy content", thread_id=thread_id,
commentable_id=self.alpha_module.discussion_id
)
# If a thread returns context other than "course", the access check is not done, and the beta user
# can see the alpha discussion module.
self.assert_can_access(self.beta_user, self.alpha_module.discussion_id, thread_id, True)
@patch('lms.lib.comment_client.utils.requests.request') @patch('lms.lib.comment_client.utils.requests.request')
class InlineDiscussionContextTestCase(ModuleStoreTestCase): class InlineDiscussionContextTestCase(ModuleStoreTestCase):
......
...@@ -320,10 +320,6 @@ def single_thread(request, course_key, discussion_id, thread_id): ...@@ -320,10 +320,6 @@ def single_thread(request, course_key, discussion_id, thread_id):
user_info = cc_user.to_dict() user_info = cc_user.to_dict()
is_moderator = has_permission(request.user, "see_all_cohorts", course_key) is_moderator = has_permission(request.user, "see_all_cohorts", course_key)
# Verify that the student has access to this thread if belongs to a discussion module
if discussion_id not in utils.get_discussion_categories_ids(course, request.user):
raise Http404
# Currently, the front end always loads responses via AJAX, even for this # Currently, the front end always loads responses via AJAX, even for this
# page; it would be a nice optimization to avoid that extra round trip to # page; it would be a nice optimization to avoid that extra round trip to
# the comments service. # the comments service.
...@@ -339,6 +335,11 @@ def single_thread(request, course_key, discussion_id, thread_id): ...@@ -339,6 +335,11 @@ def single_thread(request, course_key, discussion_id, thread_id):
raise Http404 raise Http404
raise raise
# Verify that the student has access to this thread if belongs to a course discussion module
thread_context = getattr(thread, "context", "course")
if thread_context == "course" and not utils.discussion_category_id_access(course, request.user, discussion_id):
raise Http404
# verify that the thread belongs to the requesting student's cohort # verify that the thread belongs to the requesting student's cohort
if is_commentable_cohorted(course_key, discussion_id) and not is_moderator: if is_commentable_cohorted(course_key, discussion_id) and not is_moderator:
user_group_id = get_cohort_id(request.user, course_key) user_group_id = get_cohort_id(request.user, course_key)
......
...@@ -22,6 +22,7 @@ from openedx.core.djangoapps.course_groups.tests.helpers import config_course_co ...@@ -22,6 +22,7 @@ from openedx.core.djangoapps.course_groups.tests.helpers import config_course_co
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from student.roles import CourseStaffRole
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MIXED_TOY_MODULESTORE
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -80,6 +81,8 @@ class AccessUtilsTestCase(ModuleStoreTestCase): ...@@ -80,6 +81,8 @@ class AccessUtilsTestCase(ModuleStoreTestCase):
self.community_ta_role.users.add(self.community_ta1) self.community_ta_role.users.add(self.community_ta1)
self.community_ta2 = UserFactory(username='community_ta2', email='community_ta2@edx.org') self.community_ta2 = UserFactory(username='community_ta2', email='community_ta2@edx.org')
self.community_ta_role.users.add(self.community_ta2) self.community_ta_role.users.add(self.community_ta2)
self.course_staff = UserFactory(username='course_staff', email='course_staff@edx.org')
CourseStaffRole(self.course_id).add_users(self.course_staff)
def test_get_role_ids(self): def test_get_role_ids(self):
ret = utils.get_role_ids(self.course_id) ret = utils.get_role_ids(self.course_id)
...@@ -89,6 +92,7 @@ class AccessUtilsTestCase(ModuleStoreTestCase): ...@@ -89,6 +92,7 @@ class AccessUtilsTestCase(ModuleStoreTestCase):
def test_has_discussion_privileges(self): def test_has_discussion_privileges(self):
self.assertFalse(utils.has_discussion_privileges(self.student1, self.course_id)) self.assertFalse(utils.has_discussion_privileges(self.student1, self.course_id))
self.assertFalse(utils.has_discussion_privileges(self.student2, self.course_id)) self.assertFalse(utils.has_discussion_privileges(self.student2, self.course_id))
self.assertFalse(utils.has_discussion_privileges(self.course_staff, self.course_id))
self.assertTrue(utils.has_discussion_privileges(self.moderator, self.course_id)) self.assertTrue(utils.has_discussion_privileges(self.moderator, self.course_id))
self.assertTrue(utils.has_discussion_privileges(self.community_ta1, self.course_id)) self.assertTrue(utils.has_discussion_privileges(self.community_ta1, self.course_id))
self.assertTrue(utils.has_discussion_privileges(self.community_ta2, self.course_id)) self.assertTrue(utils.has_discussion_privileges(self.community_ta2, self.course_id))
......
...@@ -351,6 +351,7 @@ def get_discussion_category_map(course, user, cohorted_if_in_list=False, exclude ...@@ -351,6 +351,7 @@ def get_discussion_category_map(course, user, cohorted_if_in_list=False, exclude
def discussion_category_id_access(course, user, discussion_id): def discussion_category_id_access(course, user, discussion_id):
""" """
Returns True iff the given discussion_id is accessible for user in course. Returns True iff the given discussion_id is accessible for user in course.
Assumes that the commentable identified by discussion_id has a null or 'course' context.
Uses the discussion id cache if available, falling back to Uses the discussion id cache if available, falling back to
get_discussion_categories_ids if there is no cache. get_discussion_categories_ids if there is no cache.
""" """
......
...@@ -16,6 +16,10 @@ ...@@ -16,6 +16,10 @@
country: '', country: '',
language: '', language: '',
membership: [] membership: []
},
initialize: function(options) {
this.url = options.url;
} }
}); });
return Team; return Team;
......
...@@ -10,6 +10,10 @@ ...@@ -10,6 +10,10 @@
description: '', description: '',
team_count: 0, team_count: 0,
id: '' id: ''
},
initialize: function(options) {
this.url = options.url;
} }
}); });
return Topic; return Topic;
......
...@@ -102,10 +102,10 @@ define([ ...@@ -102,10 +102,10 @@ define([
teamEditView.$('.create-team.form-actions .action-primary').click(); teamEditView.$('.create-team.form-actions .action-primary').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', teamsUrl, teamsData); AjaxHelpers.expectJsonRequest(requests, 'POST', teamsUrl, teamsData);
AjaxHelpers.respondWithJson(requests, teamsData); AjaxHelpers.respondWithJson(requests, _.extend(_.extend({}, teamsData), { id: '123'}));
expect(teamEditView.$('.create-team.wrapper-msg .copy').text().trim().length).toBe(0); expect(teamEditView.$('.create-team.wrapper-msg .copy').text().trim().length).toBe(0);
expect(Backbone.history.navigate.calls[0].args).toContain('topics/awesomeness'); expect(Backbone.history.navigate.calls[0].args).toContain('teams/awesomeness/123');
}); });
it('shows validation error message when field is empty', function () { it('shows validation error message when field is empty', function () {
......
...@@ -2,17 +2,17 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"], ...@@ -2,17 +2,17 @@ define(["jquery", "backbone", "teams/js/teams_tab_factory"],
function($, Backbone, TeamsTabFactory) { function($, Backbone, TeamsTabFactory) {
'use strict'; 'use strict';
describe("Teams tab", function() { describe("Teams Tab Factory", function() {
var teamsTab; var teamsTab;
beforeEach(function() { beforeEach(function() {
setFixtures('<section class="teams-content"></section>'); setFixtures('<section class="teams-content"></section>');
teamsTab = new TeamsTabFactory({ teamsTab = new TeamsTabFactory({
topics: {results: []}, topics: {results: []},
topics_url: '', topicsUrl: '',
teams_url: '', teamsUrl: '',
maxTeamSize: 9999, maxTeamSize: 9999,
course_id: 'edX/DemoX/Demo_Course' courseID: 'edX/DemoX/Demo_Course'
}); });
}); });
......
define([
'underscore', 'common/js/spec_helpers/ajax_helpers', 'teams/js/views/team_discussion',
'teams/js/spec_helpers/team_discussion_helpers',
'xmodule_js/common_static/coffee/spec/discussion/discussion_spec_helper'
], function (_, AjaxHelpers, TeamDiscussionView, TeamDiscussionSpecHelper, DiscussionSpecHelper) {
'use strict';
describe('TeamDiscussionView', function() {
var discussionView, createDiscussionView, createPost, expandReplies, postReply;
beforeEach(function() {
setFixtures('<div class="discussion-module""></div>');
$('.discussion-module').data('course-id', TeamDiscussionSpecHelper.testCourseID);
$('.discussion-module').data('discussion-id', TeamDiscussionSpecHelper.testTeamDiscussionID);
$('.discussion-module').data('user-create-comment', true);
$('.discussion-module').data('user-create-subcomment', true);
DiscussionSpecHelper.setUnderscoreFixtures();
});
createDiscussionView = function(requests, threads) {
discussionView = new TeamDiscussionView({
el: '.discussion-module'
});
discussionView.render();
AjaxHelpers.expectRequest(
requests, 'GET',
interpolate(
'/courses/%(courseID)s/discussion/forum/%(discussionID)s/inline?page=1&ajax=1',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
discussionID: TeamDiscussionSpecHelper.testTeamDiscussionID
},
true
)
);
AjaxHelpers.respondWithJson(requests, TeamDiscussionSpecHelper.createMockDiscussionResponse(threads));
return discussionView;
};
createPost = function(requests, view, title, body, threadID) {
title = title || "Test title";
body = body || "Test body";
threadID = threadID || "999";
view.$('.new-post-button').click();
view.$('.js-post-title').val(title);
view.$('.js-post-body textarea').val(body);
view.$('.submit').click();
AjaxHelpers.expectRequest(
requests, 'POST',
interpolate(
'/courses/%(courseID)s/discussion/%(discussionID)s/threads/create?ajax=1',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
discussionID: TeamDiscussionSpecHelper.testTeamDiscussionID
},
true
),
interpolate(
'thread_type=discussion&title=%(title)s&body=%(body)s&anonymous=false&anonymous_to_peers=false&auto_subscribe=true',
{
title: title.replace(/ /g, '+'),
body: body.replace(/ /g, '+')
},
true
)
);
AjaxHelpers.respondWithJson(requests, {
content: TeamDiscussionSpecHelper.createMockPostResponse({
id: threadID,
title: title,
body: body
}),
annotated_content_info: TeamDiscussionSpecHelper.createAnnotatedContentInfo()
});
};
expandReplies = function(requests, view, threadID) {
view.$('.forum-thread-expand').first().click();
AjaxHelpers.expectRequest(
requests, 'GET',
interpolate(
'/courses/%(courseID)s/discussion/forum/%(discussionID)s/threads/%(threadID)s?ajax=1&resp_skip=0&resp_limit=25',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
discussionID: TeamDiscussionSpecHelper.testTeamDiscussionID,
threadID: threadID || "999"
},
true
)
);
AjaxHelpers.respondWithJson(requests, {
content: TeamDiscussionSpecHelper.createMockThreadResponse(),
annotated_content_info: TeamDiscussionSpecHelper.createAnnotatedContentInfo()
});
};
postReply = function(requests, view, reply, threadID) {
var replyForm = view.$('.discussion-reply-new').first();
replyForm.find('.reply-body textarea').val(reply);
replyForm.find('.discussion-submit-post').click();
AjaxHelpers.expectRequest(
requests, 'POST',
interpolate(
'/courses/%(courseID)s/discussion/threads/%(threadID)s/reply?ajax=1',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
threadID: threadID || "999"
},
true
),
'body=' + reply.replace(/ /g, '+')
);
AjaxHelpers.respondWithJson(requests, {
content: TeamDiscussionSpecHelper.createMockThreadResponse({
body: reply,
comments_count: 1
}),
"annotated_content_info": TeamDiscussionSpecHelper.createAnnotatedContentInfo()
});
};
it('can render itself', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests);
expect(view.$('.discussion-thread').length).toEqual(3);
});
it('can create a new post', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests),
testTitle = 'New Post',
testBody = 'New post body',
newThreadElement;
createPost(requests, view, testTitle, testBody);
// Expect the first thread to be the new post
expect(view.$('.discussion-thread').length).toEqual(4);
newThreadElement = view.$('.discussion-thread').first();
expect(newThreadElement.find('.post-header-content h1').text().trim()).toEqual(testTitle);
expect(newThreadElement.find('.post-body').text().trim()).toEqual(testBody);
});
it('can post a reply', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests),
testReply = "Test reply",
testThreadID = "1";
expandReplies(requests, view, testThreadID);
postReply(requests, view, testReply, testThreadID);
expect(view.$('.discussion-response .response-body').text().trim()).toBe(testReply);
});
it('can post a reply to a new post', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests, []),
testReply = "Test reply";
createPost(requests, view);
expandReplies(requests, view);
postReply(requests, view, testReply);
expect(view.$('.discussion-response .response-body').text().trim()).toBe(testReply);
});
it('cannot move an existing thread to a different topic', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests),
postTopicButton, updatedThreadElement,
updatedTitle = 'Updated title',
updatedBody = 'Updated body',
testThreadID = "1";
expandReplies(requests, view, testThreadID);
view.$('.action-more .icon').first().click();
view.$('.action-edit').first().click();
postTopicButton = view.$('.post-topic');
expect(postTopicButton.length).toBe(0);
view.$('.js-post-post-title').val(updatedTitle);
view.$('.js-post-body textarea').val(updatedBody);
view.$('.submit').click();
AjaxHelpers.expectRequest(
requests, 'POST',
interpolate(
'/courses/%(courseID)s/discussion/%(discussionID)s/threads/create?ajax=1',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
discussionID: TeamDiscussionSpecHelper.testTeamDiscussionID
},
true
),
'thread_type=discussion&title=&body=Updated+body&anonymous=false&anonymous_to_peers=false&auto_subscribe=true'
);
AjaxHelpers.respondWithJson(requests, {
content: TeamDiscussionSpecHelper.createMockPostResponse({
id: "999", title: updatedTitle, body: updatedBody
}),
annotated_content_info: TeamDiscussionSpecHelper.createAnnotatedContentInfo()
});
// Expect the thread to have been updated
updatedThreadElement = view.$('.discussion-thread').first();
expect(updatedThreadElement.find('.post-header-content h1').text().trim()).toEqual(updatedTitle);
expect(updatedThreadElement.find('.post-body').text().trim()).toEqual(updatedBody);
});
it('cannot move a new thread to a different topic', function() {
var requests = AjaxHelpers.requests(this),
view = createDiscussionView(requests),
postTopicButton;
createPost(requests, view);
expandReplies(requests, view);
view.$('.action-more .icon').first().click();
view.$('.action-edit').first().click();
expect(view.$('.post-topic').length).toBe(0);
});
});
});
define([
'underscore', 'common/js/spec_helpers/ajax_helpers', 'teams/js/models/team',
'teams/js/views/team_profile', 'teams/js/spec_helpers/team_discussion_helpers',
'xmodule_js/common_static/coffee/spec/discussion/discussion_spec_helper'
], function (_, AjaxHelpers, TeamModel, TeamProfileView, TeamDiscussionSpecHelper, DiscussionSpecHelper) {
'use strict';
describe('TeamProfileView', function () {
var discussionView, createTeamProfileView;
beforeEach(function () {
DiscussionSpecHelper.setUnderscoreFixtures();
});
createTeamProfileView = function(requests) {
var model = new TeamModel(
{
id: "test-team",
name: "Test Team",
discussion_topic_id: TeamDiscussionSpecHelper.testTeamDiscussionID
},
{ parse: true }
);
discussionView = new TeamProfileView({
courseID: TeamDiscussionSpecHelper.testCourseID,
model: model
});
discussionView.render();
AjaxHelpers.expectRequest(
requests,
'GET',
interpolate(
'/courses/%(courseID)s/discussion/forum/%(topicID)s/inline?page=1&ajax=1',
{
courseID: TeamDiscussionSpecHelper.testCourseID,
topicID: TeamDiscussionSpecHelper.testTeamDiscussionID
},
true
)
);
AjaxHelpers.respondWithJson(requests, TeamDiscussionSpecHelper.createMockDiscussionResponse());
return discussionView;
};
it('can render itself', function () {
var requests = AjaxHelpers.requests(this),
view = createTeamProfileView(requests);
expect(view.$('.discussion-thread').length).toEqual(3);
});
});
});
...@@ -2,7 +2,7 @@ define([ ...@@ -2,7 +2,7 @@ define([
'backbone', 'teams/js/collections/team', 'teams/js/views/teams' 'backbone', 'teams/js/collections/team', 'teams/js/views/teams'
], function (Backbone, TeamCollection, TeamsView) { ], function (Backbone, TeamCollection, TeamsView) {
'use strict'; 'use strict';
describe('TeamsView', function () { describe('Teams View', function () {
var teamsView, teamCollection, initialTeams, var teamsView, teamCollection, initialTeams,
createTeams = function (startIndex, stopIndex) { createTeams = function (startIndex, stopIndex) {
return _.map(_.range(startIndex, stopIndex + 1), function (i) { return _.map(_.range(startIndex, stopIndex + 1), function (i) {
......
...@@ -2,8 +2,9 @@ define([ ...@@ -2,8 +2,9 @@ define([
'jquery', 'jquery',
'backbone', 'backbone',
'common/js/spec_helpers/ajax_helpers', 'common/js/spec_helpers/ajax_helpers',
'teams/js/views/teams_tab' 'teams/js/views/teams_tab',
], function ($, Backbone, AjaxHelpers, TeamsTabView) { 'URI'
], function ($, Backbone, AjaxHelpers, TeamsTabView, URI) {
'use strict'; 'use strict';
describe('TeamsTab', function () { describe('TeamsTab', function () {
...@@ -33,14 +34,14 @@ define([ ...@@ -33,14 +34,14 @@ define([
results: [{ results: [{
description: 'test description', description: 'test description',
name: 'test topic', name: 'test topic',
id: 'test_id', id: 'test_topic',
team_count: 0 team_count: 0
}] }]
}, },
topic_url: 'api/topics/topic_id,course_id', topicsUrl: 'api/topics/',
topics_url: 'topics_url', topicUrl: 'api/topics/topic_id,test/course/id',
teams_url: 'teams_url', teamsUrl: 'api/teams/',
course_id: 'test/course/id' courseID: 'test/course/id'
}).render(); }).render();
Backbone.history.start(); Backbone.history.start();
spyOn($.fn, 'focus'); spyOn($.fn, 'focus');
...@@ -55,26 +56,67 @@ define([ ...@@ -55,26 +56,67 @@ define([
expectContent('This is the new Teams tab.'); expectContent('This is the new Teams tab.');
}); });
it('can switch tabs', function () { describe('Navigation', function () {
teamsTabView.$('a.nav-item[data-url="browse"]').click(); it('can switch tabs', function () {
expectContent('test description'); teamsTabView.$('a.nav-item[data-url="browse"]').click();
teamsTabView.$('a.nav-item[data-url="teams"]').click(); expectContent('test description');
expectContent('This is the new Teams tab.'); teamsTabView.$('a.nav-item[data-url="teams"]').click();
}); expectContent('This is the new Teams tab.');
});
it('displays and focuses an error message when trying to navigate to a nonexistent page', function () {
teamsTabView.router.navigate('no_such_page', {trigger: true});
expectError('The page "no_such_page" could not be found.');
expectFocus(teamsTabView.$('.warning'));
});
it('displays and focuses an error message when trying to navigate to a nonexistent route', function () { it('displays and focuses an error message when trying to navigate to a nonexistent topic', function () {
teamsTabView.router.navigate('test', {trigger: true}); var requests = AjaxHelpers.requests(this);
expectError('The page "test" could not be found.'); teamsTabView.router.navigate('topics/no_such_topic', {trigger: true});
expectFocus(teamsTabView.$('.warning')); AjaxHelpers.expectRequest(requests, 'GET', 'api/topics/no_such_topic,test/course/id', null);
AjaxHelpers.respondWithError(requests, 404);
expectError('The topic "no_such_topic" could not be found.');
expectFocus(teamsTabView.$('.warning'));
});
it('displays and focuses an error message when trying to navigate to a nonexistent team', function () {
var requests = AjaxHelpers.requests(this);
teamsTabView.router.navigate('teams/test_topic/no_such_team', {trigger: true});
AjaxHelpers.expectRequest(requests, 'GET', 'api/teams/no_such_team', null);
AjaxHelpers.respondWithError(requests, 404);
expectError('The team "no_such_team" could not be found.');
expectFocus(teamsTabView.$('.warning'));
});
}); });
it('displays and focuses an error message when trying to navigate to a nonexistent topic', function () { describe('Discussion privileges', function () {
var requests = AjaxHelpers.requests(this); it('allows privileged access to any team', function () {
teamsTabView.router.navigate('topics/test', {trigger: true}); teamsTabView.$el.data('privileged', true);
AjaxHelpers.expectRequest(requests, 'GET', 'api/topics/test,course_id', null); // Note: using `undefined` here to ensure that we
AjaxHelpers.respondWithError(requests, 404); // don't even look at the team when the user is
expectError('The topic "test" could not be found.'); // privileged
expectFocus(teamsTabView.$('.warning')); expect(teamsTabView.readOnlyDiscussion(undefined)).toBe(false);
});
it('allows access to a team which an unprivileged user is a member of', function () {
teamsTabView.$el.data('privileged', false).data('username', 'test-user');
expect(teamsTabView.readOnlyDiscussion({
attributes: {
membership: [{
user: {
username: 'test-user'
}
}]
}
})).toBe(false);
});
it('does not allow access if the user is neither privileged nor a team member', function () {
teamsTabView.$el.data('privileged', false).data('username', 'test-user');
expect(teamsTabView.readOnlyDiscussion({
attributes: { membership: [] }
})).toBe(true);
});
}); });
}); });
}); });
define([
'underscore', 'common/js/spec_helpers/ajax_helpers'
], function (_, AjaxHelpers) {
'use strict';
var createMockPostResponse, createMockDiscussionResponse, createAnnotatedContentInfo, createMockThreadResponse,
testCourseID = 'course/1',
testUser = 'testUser',
testTeamDiscussionID = "12345";
createMockPostResponse = function(options) {
return _.extend(
{
username: testUser,
course_id: testCourseID,
commentable_id: testTeamDiscussionID,
type: 'thread',
body: "",
anonymous_to_peers: false,
unread_comments_count: 0,
updated_at: '2015-07-29T18:44:56Z',
group_name: 'Default Group',
pinned: false,
votes: {count: 0, down_count: 0, point: 0, up_count: 0},
user_id: "9",
abuse_flaggers: [],
closed: false,
at_position_list: [],
read: false,
anonymous: false,
created_at: "2015-07-29T18:44:56Z",
thread_type: 'discussion',
comments_count: 0,
group_id: 1,
endorsed: false
},
options || {}
);
};
createMockDiscussionResponse = function(threads) {
if (_.isUndefined(threads)) {
threads = [
createMockPostResponse({ id: "1", title: "First Post"}),
createMockPostResponse({ id: "2", title: "Second Post"}),
createMockPostResponse({ id: "3", title: "Third Post"})
];
}
return {
"num_pages": 1,
"page": 1,
"discussion_data": threads,
"user_info": {
"username": testUser,
"follower_ids": [],
"default_sort_key": "date",
"downvoted_ids": [],
"subscribed_thread_ids": [],
"upvoted_ids": [],
"external_id": "9",
"id": "9",
"subscribed_user_ids": [],
"subscribed_commentable_ids": []
},
"annotated_content_info": {
},
"roles": {"Moderator": [], "Administrator": [], "Community TA": []},
"course_settings": {
"is_cohorted": false,
"allow_anonymous_to_peers": false,
"allow_anonymous": true,
"category_map": {"subcategories": {}, "children": [], "entries": {}},
"cohorts": []
}
};
};
createAnnotatedContentInfo = function() {
return {
voted: '',
subscribed: true,
ability: {
can_reply: true,
editable: true,
can_openclose: true,
can_delete: true,
can_vote: true
}
};
};
createMockThreadResponse = function(options) {
return _.extend(
{
username: testUser,
course_id: testCourseID,
commentable_id: testTeamDiscussionID,
children: [],
comments_count: 0,
anonymous_to_peers: false,
unread_comments_count: 0,
updated_at: "2015-08-04T21:44:28Z",
resp_skip: 0,
id: "55c1323c56c02ce921000001",
pinned: false,
votes: {"count": 0, "down_count": 0, "point": 0, "up_count": 0},
resp_limit: 25,
abuse_flaggers: [],
closed: false,
resp_total: 1,
at_position_list: [],
type: "thread",
read: true,
anonymous: false,
user_id: "5",
created_at: "2015-08-04T21:44:28Z",
thread_type: "discussion",
context: "standalone",
endorsed: false
},
options || {}
);
};
return {
testCourseID: testCourseID,
testUser: testUser,
testTeamDiscussionID: testTeamDiscussionID,
createMockPostResponse: createMockPostResponse,
createMockDiscussionResponse: createMockDiscussionResponse,
createAnnotatedContentInfo: createAnnotatedContentInfo,
createMockThreadResponse: createMockThreadResponse
};
});
...@@ -78,6 +78,11 @@ ...@@ -78,6 +78,11 @@
{span_start: '<span class="sr">', team_name: this.model.get('name'), span_end: '</span>'}, {span_start: '<span class="sr">', team_name: this.model.get('name'), span_end: '</span>'},
true true
); );
},
action: function (event) {
var url = 'teams/' + this.topic.get('id') + '/' + this.model.get('id');
event.preventDefault();
this.router.navigate(url, {trigger: true});
} }
}); });
return TeamCardView; return TeamCardView;
......
/**
* View that shows the discussion for a team.
*/
;(function (define) {
'use strict';
define(['backbone', 'underscore', 'gettext', 'DiscussionModuleView'],
function (Backbone, _, gettext, DiscussionModuleView) {
var TeamDiscussionView = Backbone.View.extend({
initialize: function () {
window.$$course_id = this.$el.data("course-id");
this.render();
},
render: function () {
var discussionModuleView = new DiscussionModuleView({
el: this.$el,
context: 'standalone'
});
discussionModuleView.render();
discussionModuleView.loadPage(this.$el);
return this;
}
});
return TeamDiscussionView;
});
}).call(this, define || RequireJS.define);
/**
* View for an individual team.
*/
;(function (define) {
'use strict';
define(['backbone', 'underscore', 'gettext', 'teams/js/views/team_discussion',
'text!teams/templates/team-profile.underscore'],
function (Backbone, _, gettext, TeamDiscussionView, teamTemplate) {
var TeamProfileView = Backbone.View.extend({
initialize: function (options) {
this.courseID = options.courseID;
this.discussionTopicID = this.model.get('discussion_topic_id');
this.readOnly = options.readOnly;
},
render: function () {
this.$el.html(_.template(teamTemplate, {
courseID: this.courseID,
discussionTopicID: this.discussionTopicID,
readOnly: this.readOnly
}));
this.discussionView = new TeamDiscussionView({
el: this.$('.discussion-module')
});
this.discussionView.render();
return this;
}
});
return TeamProfileView;
});
}).call(this, define || RequireJS.define);
...@@ -10,8 +10,10 @@ ...@@ -10,8 +10,10 @@
type: 'teams', type: 'teams',
initialize: function (options) { initialize: function (options) {
this.topic = options.topic;
this.itemViewClass = TeamCardView.extend({ this.itemViewClass = TeamCardView.extend({
router: options.router, router: options.router,
topic: options.topic,
maxTeamSize: options.maxTeamSize maxTeamSize: options.maxTeamSize
}); });
PaginatedView.prototype.initialize.call(this); PaginatedView.prototype.initialize.call(this);
......
<div class="team-profile">
<div class="discussion-module" data-course-id="<%= courseID %>" data-discussion-id="<%= discussionTopicID %>"
data-read-only="<%= readOnly %>"
data-user-create-comment="<%= !readOnly %>"
data-user-create-subcomment="<%= !readOnly %>">
<% if ( !readOnly) { %>
<a href="#" class="new-post-btn" role="button"><span class="icon fa fa-edit new-post-icon"></span><%= gettext("New Post") %></a>
<% } %>
</div>
</div>
...@@ -7,30 +7,42 @@ ...@@ -7,30 +7,42 @@
<%block name="bodyclass">view-teams is-in-course course</%block> <%block name="bodyclass">view-teams is-in-course course</%block>
<%block name="pagetitle">${_("Teams")}</%block> <%block name="pagetitle">${_("Teams")}</%block>
<%block name="headextra"> <%block name="headextra">
<%static:css group='style-course-vendor'/>
<%static:css group='style-course'/> <%static:css group='style-course'/>
<%include file="../discussion/_js_head_dependencies.html" />
</%block> </%block>
<%include file="/courseware/course_navigation.html" args="active_page='teams'" /> <%include file="/courseware/course_navigation.html" args="active_page='teams'" />
<div class="container"> <div class="container">
<div class="teams-wrapper"> <div class="teams-wrapper">
<section class="teams-content"> <section class="teams-content" data-username=${json.dumps(username, cls=EscapedEdxJSONEncoder)} data-privileged="${json.dumps(privileged)}">
</section> </section>
</div> </div>
</div> </div>
<%block name="js_extra"> <%block name="js_extra">
<%include file="../discussion/_js_body_dependencies.html" />
<%static:js group='discussion'/>
<script type="text/javascript">
RequireJS.define('DiscussionModuleView', [], function() {return window['DiscussionModuleView'];});
</script>
<%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory"> <%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory">
new TeamsTabFactory({ TeamsTabFactory({
courseID: '${ unicode(course.id) }',
topics: ${ json.dumps(topics, cls=EscapedEdxJSONEncoder) }, topics: ${ json.dumps(topics, cls=EscapedEdxJSONEncoder) },
topic_url: '${ topic_url }', topicUrl: '${ topic_url }',
topics_url: '${ topics_url }', topicsUrl: '${ topics_url }',
teams_url: '${ teams_url }', teamsUrl: '${ teams_url }',
maxTeamSize: ${ course.teams_max_size }, maxTeamSize: ${ course.teams_max_size },
course_id: '${ unicode(course.id) }',
languages: ${ json.dumps(languages, cls=EscapedEdxJSONEncoder) }, languages: ${ json.dumps(languages, cls=EscapedEdxJSONEncoder) },
countries: ${ json.dumps(countries, cls=EscapedEdxJSONEncoder) } countries: ${ json.dumps(countries, cls=EscapedEdxJSONEncoder) }
}); });
</%static:require_module> </%static:require_module>
</%block> </%block>
<%include file="../discussion/_underscore_templates.html" />
...@@ -467,7 +467,7 @@ class TestCreateTeamAPI(TeamAPITestCase): ...@@ -467,7 +467,7 @@ class TestCreateTeamAPI(TeamAPITestCase):
# Verify that the creating user gets added to the team. # Verify that the creating user gets added to the team.
self.assertEqual(len(team_membership), 1) self.assertEqual(len(team_membership), 1)
member = team_membership[0]['user'] member = team_membership[0]['user']
self.assertEqual(member['id'], creator) self.assertEqual(member['username'], creator)
self.assertEqual(team, { self.assertEqual(team, {
'name': 'Fully specified team', 'name': 'Fully specified team',
...@@ -688,7 +688,7 @@ class TestListMembershipAPI(TeamAPITestCase): ...@@ -688,7 +688,7 @@ class TestListMembershipAPI(TeamAPITestCase):
membership = self.get_membership_list(status, {'team_id': self.test_team_1.team_id}, user=user) membership = self.get_membership_list(status, {'team_id': self.test_team_1.team_id}, user=user)
if status == 200: if status == 200:
self.assertEqual(membership['count'], 1) self.assertEqual(membership['count'], 1)
self.assertEqual(membership['results'][0]['user']['id'], self.users['student_enrolled'].username) self.assertEqual(membership['results'][0]['user']['username'], self.users['student_enrolled'].username)
@ddt.data( @ddt.data(
(None, 401, False), (None, 401, False),
...@@ -705,7 +705,7 @@ class TestListMembershipAPI(TeamAPITestCase): ...@@ -705,7 +705,7 @@ class TestListMembershipAPI(TeamAPITestCase):
if status == 200: if status == 200:
if has_content: if has_content:
self.assertEqual(membership['count'], 1) self.assertEqual(membership['count'], 1)
self.assertEqual(membership['results'][0]['team']['id'], self.test_team_1.team_id) self.assertEqual(membership['results'][0]['team']['team_id'], self.test_team_1.team_id)
else: else:
self.assertEqual(membership['count'], 0) self.assertEqual(membership['count'], 0)
...@@ -754,8 +754,8 @@ class TestCreateMembershipAPI(TeamAPITestCase): ...@@ -754,8 +754,8 @@ class TestCreateMembershipAPI(TeamAPITestCase):
user=user user=user
) )
if status == 200: if status == 200:
self.assertEqual(membership['user']['id'], self.users['student_enrolled_not_on_team'].username) self.assertEqual(membership['user']['username'], self.users['student_enrolled_not_on_team'].username)
self.assertEqual(membership['team']['id'], self.test_team_1.team_id) self.assertEqual(membership['team']['team_id'], self.test_team_1.team_id)
memberships = self.get_membership_list(200, {'team_id': self.test_team_1.team_id}) memberships = self.get_membership_list(200, {'team_id': self.test_team_1.team_id})
self.assertEqual(memberships['count'], 2) self.assertEqual(memberships['count'], 2)
......
...@@ -90,6 +90,7 @@ class TeamsDashboardView(View): ...@@ -90,6 +90,7 @@ class TeamsDashboardView(View):
instance=topics_page, instance=topics_page,
context={'course_id': course.id, 'sort_order': sort_order} context={'course_id': course.id, 'sort_order': sort_order}
) )
user = request.user
context = { context = {
"course": course, "course": course,
"topics": topics_serializer.data, "topics": topics_serializer.data,
...@@ -100,6 +101,8 @@ class TeamsDashboardView(View): ...@@ -100,6 +101,8 @@ class TeamsDashboardView(View):
"teams_url": reverse('teams_list', request=request), "teams_url": reverse('teams_list', request=request),
"languages": settings.ALL_LANGUAGES, "languages": settings.ALL_LANGUAGES,
"countries": list(countries), "countries": list(countries),
"username": user.username,
"privileged": has_discussion_privileges(user, course_key)
} }
return render_to_response("teams/teams.html", context) return render_to_response("teams/teams.html", context)
......
...@@ -39,6 +39,7 @@ lib_paths: ...@@ -39,6 +39,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jquery.min.js - xmodule_js/common_static/js/vendor/jquery.min.js
- xmodule_js/common_static/js/vendor/jquery-ui.min.js - xmodule_js/common_static/js/vendor/jquery-ui.min.js
- xmodule_js/common_static/js/vendor/jquery.cookie.js - xmodule_js/common_static/js/vendor/jquery.cookie.js
- xmodule_js/common_static/js/vendor/jquery.timeago.js
- xmodule_js/common_static/js/vendor/flot/jquery.flot.js - xmodule_js/common_static/js/vendor/flot/jquery.flot.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js - xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/common_static/js/vendor/URI.min.js - xmodule_js/common_static/js/vendor/URI.min.js
...@@ -66,8 +67,10 @@ lib_paths: ...@@ -66,8 +67,10 @@ lib_paths:
# Paths to source JavaScript files # Paths to source JavaScript files
src_paths: src_paths:
- js - js
- coffee/src
- common/js - common/js
- teams/js - teams/js
- xmodule_js/common_static/coffee
# Paths to spec (test) JavaScript files # Paths to spec (test) JavaScript files
spec_paths: spec_paths:
......
...@@ -64,6 +64,7 @@ ...@@ -64,6 +64,7 @@
'logger': 'empty:', 'logger': 'empty:',
'utility': 'empty:', 'utility': 'empty:',
'URI': 'empty:', 'URI': 'empty:',
'DiscussionModuleView': 'empty:'
}, },
/** /**
......
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
defineDependency("Logger", "logger"); defineDependency("Logger", "logger");
defineDependency("URI", "URI"); defineDependency("URI", "URI");
defineDependency("Backbone", "backbone"); defineDependency("Backbone", "backbone");
// utility.js adds two functions to the window object, but does not return anything // utility.js adds two functions to the window object, but does not return anything
defineDependency("isExternal", "utility", true); defineDependency("isExternal", "utility", true);
} }
......
...@@ -753,10 +753,10 @@ body.discussion { ...@@ -753,10 +753,10 @@ body.discussion {
} }
section.discussion { section.discussion {
margin-top: ($baseline*1.5); clear: both;
padding-top: $baseline;
.threads { .threads {
margin-top: $baseline;
} }
.discussion-thread { .discussion-thread {
...@@ -936,7 +936,7 @@ body.discussion { ...@@ -936,7 +936,7 @@ body.discussion {
color: $white; color: $white;
} }
section.pagination { section.discussion-pagination {
margin-top: ($baseline*1.5); margin-top: ($baseline*1.5);
nav.discussion-paginator { nav.discussion-paginator {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
%> %>
<div class="discussion-module" data-discussion-id="${discussion_id | h}" data-user-create-comment="${can_create_comment}" data-user-create-subcomment="${can_create_subcomment}"> <div class="discussion-module" data-discussion-id="${discussion_id | h}" data-user-create-comment="${can_create_comment}" data-user-create-subcomment="${can_create_subcomment}" data-read-only="false">
<a class="discussion-show control-button" href="javascript:void(0)" data-discussion-id="${discussion_id | h}" role="button"><span class="show-hide-discussion-icon"></span><span class="button-text">${_("Show Discussion")}</span></a> <a class="discussion-show control-button" href="javascript:void(0)" data-discussion-id="${discussion_id | h}" role="button"><span class="show-hide-discussion-icon"></span><span class="button-text">${_("Show Discussion")}</span></a>
% if can_create_thread: % if can_create_thread:
<a href="#" class="new-post-btn" role="button"><span class="icon fa fa-edit new-post-icon"></span>${_("New Post")}</a> <a href="#" class="new-post-btn" role="button"><span class="icon fa fa-edit new-post-icon"></span>${_("New Post")}</a>
......
...@@ -31,6 +31,7 @@ from django.core.urlresolvers import reverse ...@@ -31,6 +31,7 @@ from django.core.urlresolvers import reverse
data-user-info="${user_info}" data-user-info="${user_info}"
data-user-create-comment="${can_create_comment}" data-user-create-comment="${can_create_comment}"
data-user-create-subcomment="${can_create_subcomment}" data-user-create-subcomment="${can_create_subcomment}"
data-read-only="false"
data-threads="${threads}" data-threads="${threads}"
data-thread-pages="${thread_pages}" data-thread-pages="${thread_pages}"
data-content-info="${annotated_content_info}" data-content-info="${annotated_content_info}"
......
...@@ -31,7 +31,6 @@ class PaginationSerializer(pagination.PaginationSerializer): ...@@ -31,7 +31,6 @@ class PaginationSerializer(pagination.PaginationSerializer):
class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer): class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
"""Serializes arbitrary models in a collapsed format, with just an id and url.""" """Serializes arbitrary models in a collapsed format, with just an id and url."""
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
url = serializers.HyperlinkedIdentityField(view_name='') url = serializers.HyperlinkedIdentityField(view_name='')
def __init__(self, model_class, view_name, id_source='id', lookup_field=None, *args, **kwargs): def __init__(self, model_class, view_name, id_source='id', lookup_field=None, *args, **kwargs):
...@@ -42,7 +41,8 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer): ...@@ -42,7 +41,8 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
view_name (string): Name of the Django view used to lookup the view_name (string): Name of the Django view used to lookup the
model. model.
id_source (string): Optional name of the id field on the model. id_source (string): Optional name of the id field on the model.
Defaults to 'id'. Defaults to 'id'. Also used as the property name of the field
in the serialized representation.
lookup_field (string): Optional name of the model field used to lookup_field (string): Optional name of the model field used to
lookup the model in the view. Defaults to the value of lookup the model in the view. Defaults to the value of
id_source. id_source.
...@@ -54,7 +54,7 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer): ...@@ -54,7 +54,7 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
super(CollapsedReferenceSerializer, self).__init__(*args, **kwargs) super(CollapsedReferenceSerializer, self).__init__(*args, **kwargs)
self.fields['id'].source = id_source self.fields[id_source] = serializers.CharField(read_only=True, source=id_source)
self.fields['url'].view_name = view_name self.fields['url'].view_name = view_name
self.fields['url'].lookup_field = lookup_field self.fields['url'].lookup_field = lookup_field
...@@ -63,4 +63,4 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer): ...@@ -63,4 +63,4 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
model is set dynamically in __init__. model is set dynamically in __init__.
""" """
fields = ("id", "url") fields = ("url",)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment