Create UI for CSV team management (#22310)
Adds "Manage" sub-tab to course "Teams" tab with UI for downloading and uploading team membership CSVs. The upload and download function- ality are currently not implemented. The new tab only appears when the user is course staff and the course has at least one instructor-managed team-set, which is not the case for any existing courses, so not current course staff will see this change. This ticket will be followed-up upon in MST-44 and MST-49. MST-41
This commit is contained in:
@@ -32,6 +32,14 @@ class OrganizationProtectionStatus(Enum):
|
||||
protection_exempt = 'org_protection_exempt'
|
||||
unprotected = 'org_unprotected'
|
||||
|
||||
@property
|
||||
def is_protected(self):
|
||||
return self == self.protected
|
||||
|
||||
@property
|
||||
def is_exempt(self):
|
||||
return self == self.protection_exempt
|
||||
|
||||
|
||||
ORGANIZATION_PROTECTED_MODES = (
|
||||
CourseMode.MASTERS,
|
||||
@@ -93,13 +101,15 @@ def discussion_visible_by_user(discussion_id, user):
|
||||
return not is_team_discussion_private(team) or user_is_a_team_member(user, team)
|
||||
|
||||
|
||||
def _has_course_staff_privileges(user, course_key):
|
||||
def has_course_staff_privileges(user, course_key):
|
||||
"""
|
||||
Returns True if the user is an admin for the course, else returns False
|
||||
"""
|
||||
if user.is_staff:
|
||||
return True
|
||||
if CourseStaffRole(course_key).has_user(user) or CourseInstructorRole(course_key).has_user(user):
|
||||
if CourseStaffRole(course_key).has_user(user):
|
||||
return True
|
||||
if CourseInstructorRole(course_key).has_user(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -117,7 +127,7 @@ def has_team_api_access(user, course_key, access_username=None):
|
||||
Returns:
|
||||
bool: True if the user has access, False otherwise.
|
||||
"""
|
||||
if _has_course_staff_privileges(user, course_key):
|
||||
if has_course_staff_privileges(user, course_key):
|
||||
return True
|
||||
if has_discussion_privileges(user, course_key):
|
||||
return True
|
||||
@@ -133,7 +143,7 @@ def user_organization_protection_status(user, course_key):
|
||||
If the user is a staff of the course, we return the protection_exempt status
|
||||
else, we return the unprotected status
|
||||
"""
|
||||
if _has_course_staff_privileges(user, course_key):
|
||||
if has_course_staff_privileges(user, course_key):
|
||||
return OrganizationProtectionStatus.protection_exempt
|
||||
enrollment = CourseEnrollment.get_enrollment(user, course_key)
|
||||
if enrollment and enrollment.is_active:
|
||||
@@ -200,5 +210,13 @@ def add_team_count(topics, course_id, organization_protection_status):
|
||||
topic['team_count'] = topics_to_team_count.get(topic['id'], 0)
|
||||
|
||||
|
||||
def can_user_modify_team(user, course_key, team):
|
||||
return not is_instructor_managed_team(team) or _has_course_staff_privileges(user, course_key)
|
||||
def can_user_modify_team(user, team):
|
||||
"""
|
||||
Returns whether a User has permission to modify the membership of a CourseTeam.
|
||||
|
||||
Assumes that user is enrolled in course run.
|
||||
"""
|
||||
return (
|
||||
(not is_instructor_managed_team(team)) or
|
||||
has_course_staff_privileges(user, team.course_id)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import (
|
||||
MembershipBulkManagementView,
|
||||
MembershipDetailView,
|
||||
MembershipListView,
|
||||
TeamsDetailView,
|
||||
@@ -56,5 +57,12 @@ urlpatterns = [
|
||||
),
|
||||
MembershipDetailView.as_view(),
|
||||
name="team_membership_detail"
|
||||
),
|
||||
url(
|
||||
r'^v0/bulk_team_membership/{course_id_pattern}$'.format(
|
||||
course_id_pattern=settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
MembershipBulkManagementView.as_view(),
|
||||
name="team_membership_bulk_management"
|
||||
)
|
||||
]
|
||||
|
||||
21
lms/djangoapps/teams/csv.py
Normal file
21
lms/djangoapps/teams/csv.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
CSV processing and generation utilities for Teams LMS app.
|
||||
"""
|
||||
|
||||
|
||||
def load_team_membership_csv(course, response):
|
||||
"""
|
||||
Load a CSV detailing course membership.
|
||||
|
||||
Arguments:
|
||||
course (CourseDescriptor): Course module for which CSV
|
||||
download has been requested.
|
||||
response (HttpResponse): Django response object to which
|
||||
the CSV content will be written.
|
||||
"""
|
||||
# This function needs to be implemented (TODO MST-31).
|
||||
_ = course
|
||||
not_implemented_message = (
|
||||
"Team membership CSV download is not yet implemented."
|
||||
)
|
||||
response.write(not_implemented_message + "\n")
|
||||
@@ -9,11 +9,17 @@
|
||||
name: '',
|
||||
description: '',
|
||||
team_count: 0,
|
||||
id: ''
|
||||
id: '',
|
||||
type: 'open'
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.url = options.url;
|
||||
},
|
||||
|
||||
isInstructorManaged: function() {
|
||||
var topicType = this.get('type');
|
||||
return topicType === 'public_managed' || topicType === 'private_managed';
|
||||
}
|
||||
});
|
||||
return Topic;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
define([
|
||||
'jquery',
|
||||
'backbone',
|
||||
'underscore',
|
||||
'teams/js/views/manage',
|
||||
'teams/js/spec_helpers/team_spec_helpers',
|
||||
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
|
||||
], function($, Backbone, _, ManageView, TeamSpecHelpers, AjaxHelpers) {
|
||||
'use strict';
|
||||
|
||||
describe('Team Management Dashboard', function() {
|
||||
var view;
|
||||
var uploadFile = new File([], 'empty-test-file.csv');
|
||||
var mockUploadClickEvent = {target: {files: [uploadFile]}};
|
||||
|
||||
beforeEach(function() {
|
||||
setFixtures('<div class="teams-container"></div>');
|
||||
view = new ManageView({
|
||||
teamEvents: TeamSpecHelpers.teamEvents,
|
||||
teamMembershipManagementUrl: '/manage-test-url'
|
||||
}).render();
|
||||
spyOn(view, 'handleCsvUploadSuccess');
|
||||
spyOn(view, 'handleCsvUploadFailure');
|
||||
});
|
||||
|
||||
it('can render itself', function() {
|
||||
expect(_.strip(view.$('.download-team-csv').text())).toEqual('Download Memberships');
|
||||
expect(_.strip(view.$('.upload-team-csv').text())).toEqual('Upload Memberships');
|
||||
});
|
||||
|
||||
it('can handle a successful file upload', function() {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
view.uploadCsv(mockUploadClickEvent);
|
||||
AjaxHelpers.expectRequest(requests, 'POST', view.csvUploadUrl);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
expect(view.handleCsvUploadSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can handle a failed file upload', function() {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
view.uploadCsv(mockUploadClickEvent);
|
||||
AjaxHelpers.expectRequest(requests, 'POST', view.csvUploadUrl);
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
expect(view.handleCsvUploadFailure).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
define([
|
||||
'jquery',
|
||||
'underscore',
|
||||
'backbone',
|
||||
'logger',
|
||||
'edx-ui-toolkit/js/utils/spec-helpers/spec-helpers',
|
||||
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
|
||||
'common/js/spec_helpers/page_helpers',
|
||||
'teams/js/views/teams_tab',
|
||||
'teams/js/spec_helpers/team_spec_helpers',
|
||||
'underscore'
|
||||
], function($, Backbone, Logger, SpecHelpers, AjaxHelpers, PageHelpers, TeamsTabView, TeamSpecHelpers, _) {
|
||||
'teams/js/spec_helpers/team_spec_helpers'
|
||||
], function($, _, Backbone, Logger, SpecHelpers, AjaxHelpers, PageHelpers, TeamsTabView, TeamSpecHelpers) {
|
||||
'use strict';
|
||||
|
||||
describe('TeamsTab', function() {
|
||||
@@ -230,6 +230,53 @@ define([
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manage Tab', function() {
|
||||
var manageTabSelector = '.page-content-nav>.nav-item[data-url=manage]';
|
||||
var noManagedData = TeamSpecHelpers.createMockTopicData(1, 2);
|
||||
var withManagedData = TeamSpecHelpers.createMockTopicData(1, 2);
|
||||
var topicsNoManaged, topicsWithManaged;
|
||||
|
||||
topicsNoManaged = {
|
||||
count: 2,
|
||||
num_pages: 1,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
results: noManagedData
|
||||
};
|
||||
withManagedData[1].type = 'public_managed';
|
||||
topicsWithManaged = {
|
||||
count: 2,
|
||||
num_pages: 1,
|
||||
current_page: 1,
|
||||
start: 0,
|
||||
results: withManagedData
|
||||
};
|
||||
|
||||
it('is not visible to unprivileged users', function() {
|
||||
var teamsTabView = createTeamsTabView(this, {
|
||||
userInfo: TeamSpecHelpers.createMockUserInfo({privileged: false}),
|
||||
topics: topicsNoManaged
|
||||
});
|
||||
expect(teamsTabView.$(manageTabSelector).length).toBe(0);
|
||||
});
|
||||
|
||||
it('is not visible when there are no managed topics', function() {
|
||||
var teamsTabView = createTeamsTabView(this, {
|
||||
userInfo: TeamSpecHelpers.createMockUserInfo({privileged: true}),
|
||||
topics: topicsNoManaged
|
||||
});
|
||||
expect(teamsTabView.$(manageTabSelector).length).toBe(0);
|
||||
});
|
||||
|
||||
it('is visible to privileged users when there is a managed topic', function() {
|
||||
var teamsTabView = createTeamsTabView(this, {
|
||||
userInfo: TeamSpecHelpers.createMockUserInfo({privileged: true}),
|
||||
topics: topicsWithManaged
|
||||
});
|
||||
expect(teamsTabView.$(manageTabSelector).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search', function() {
|
||||
var performSearch = function(reqs, teamsTabView) {
|
||||
teamsTabView.$('.search-field').val('foo');
|
||||
|
||||
72
lms/djangoapps/teams/static/teams/js/views/manage.js
Normal file
72
lms/djangoapps/teams/static/teams/js/views/manage.js
Normal file
@@ -0,0 +1,72 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define([
|
||||
'backbone',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'common/js/components/utils/view_utils',
|
||||
'teams/js/views/team_utils',
|
||||
'text!teams/templates/manage.underscore'
|
||||
], function(Backbone, _, gettext, HtmlUtils, ViewUtils, TeamUtils, manageTemplate) {
|
||||
var ManageView = Backbone.View.extend({
|
||||
|
||||
srInfo: {
|
||||
id: 'heading-manage',
|
||||
text: gettext('Manage')
|
||||
},
|
||||
|
||||
events: {
|
||||
'click #download-team-csv-input': ViewUtils.withDisabledElement('downloadCsv'),
|
||||
'change #upload-team-csv-input': ViewUtils.withDisabledElement('uploadCsv')
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.teamEvents = options.teamEvents;
|
||||
this.csvUploadUrl = options.teamMembershipManagementUrl;
|
||||
this.csvDownloadUrl = options.teamMembershipManagementUrl;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
HtmlUtils.setHtml(
|
||||
this.$el,
|
||||
HtmlUtils.template(manageTemplate)({})
|
||||
);
|
||||
return this;
|
||||
},
|
||||
|
||||
downloadCsv: function() {
|
||||
window.location.href = this.csvDownloadUrl;
|
||||
},
|
||||
|
||||
uploadCsv: function(event) {
|
||||
var file = event.target.files[0];
|
||||
var self = this;
|
||||
var formData = new FormData();
|
||||
|
||||
formData.append('csv', file); // xss-lint: disable=javascript-jquery-append
|
||||
return $.ajax({
|
||||
type: 'POST',
|
||||
url: self.csvUploadUrl,
|
||||
data: formData,
|
||||
processData: false, // tell jQuery not to process the data
|
||||
contentType: false // tell jQuery not to set contentType
|
||||
}).done(
|
||||
self.handleCsvUploadSuccess
|
||||
).fail(
|
||||
self.handleCsvUploadFailure
|
||||
);
|
||||
},
|
||||
|
||||
handleCsvUploadSuccess: function() {
|
||||
// This handler is currently unimplemented (TODO MST-44)
|
||||
this.teamEvents.trigger('teams:update', {});
|
||||
},
|
||||
|
||||
handleCsvUploadFailure: function() {
|
||||
// This handler is currently unimplemented (TODO MST-44)
|
||||
}
|
||||
});
|
||||
return ManageView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -20,6 +20,7 @@
|
||||
'teams/js/views/topics',
|
||||
'teams/js/views/team_profile',
|
||||
'teams/js/views/my_teams',
|
||||
'teams/js/views/manage',
|
||||
'teams/js/views/topic_teams',
|
||||
'teams/js/views/edit_team',
|
||||
'teams/js/views/edit_team_members',
|
||||
@@ -29,8 +30,9 @@
|
||||
'text!teams/templates/teams_tab.underscore'],
|
||||
function(Backbone, $, _, gettext, HtmlUtils, StringUtils, SearchFieldView, HeaderView, HeaderModel,
|
||||
TopicModel, TopicCollection, TeamModel, TeamCollection, MyTeamsCollection, TeamAnalytics,
|
||||
TeamsTabbedView, TopicsView, TeamProfileView, MyTeamsView, TopicTeamsView, TeamEditView,
|
||||
TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView, teamsTemplate) {
|
||||
TeamsTabbedView, TopicsView, TeamProfileView, MyTeamsView, ManageView, TopicTeamsView,
|
||||
TeamEditView, TeamMembersEditView, TeamProfileHeaderActionsView, TeamUtils, InstructorToolsView,
|
||||
teamsTemplate) {
|
||||
var TeamsHeaderModel = HeaderModel.extend({
|
||||
initialize: function() {
|
||||
_.extend(this.defaults, {nav_aria_label: gettext('Topics')});
|
||||
@@ -59,7 +61,7 @@
|
||||
|
||||
var TeamTabView = Backbone.View.extend({
|
||||
initialize: function(options) {
|
||||
var router;
|
||||
var router, tabsList;
|
||||
this.context = options.context;
|
||||
// This slightly tedious approach is necessary
|
||||
// to use regular expressions within Backbone
|
||||
@@ -78,10 +80,9 @@
|
||||
['topics/:topic_id/search(/)', _.bind(this.searchTeams, this)],
|
||||
['topics/:topic_id/create-team(/)', _.bind(this.newTeam, this)],
|
||||
['teams/:topic_id/:team_id(/)', _.bind(this.browseTeam, this)],
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
[new RegExp('^(browse)\/?$'), _.bind(this.goToTab, this)],
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
[new RegExp('^(my-teams)\/?$'), _.bind(this.goToTab, this)]
|
||||
[new RegExp('^(browse)/?$'), _.bind(this.goToTab, this)],
|
||||
[new RegExp('^(my-teams)/?$'), _.bind(this.goToTab, this)],
|
||||
[new RegExp('^(manage)/?$'), _.bind(this.goToTab, this)]
|
||||
], function(route) {
|
||||
router.route.apply(router, route);
|
||||
});
|
||||
@@ -133,21 +134,38 @@
|
||||
collection: this.topicsCollection
|
||||
});
|
||||
|
||||
this.manageView = new ManageView({
|
||||
router: this.router,
|
||||
teamEvents: this.teamEvents,
|
||||
teamMembershipManagementUrl: this.context.teamMembershipManagementUrl
|
||||
});
|
||||
|
||||
tabsList = [{
|
||||
title: gettext('My Team'),
|
||||
url: 'my-teams',
|
||||
view: this.myTeamsView
|
||||
}, {
|
||||
title: gettext('Browse'),
|
||||
url: 'browse',
|
||||
view: this.topicsView
|
||||
}];
|
||||
if (this.canViewManageTab()) {
|
||||
tabsList.push({
|
||||
title: gettext('Manage'),
|
||||
url: 'manage',
|
||||
view: this.manageView
|
||||
});
|
||||
}
|
||||
|
||||
this.mainView = this.tabbedView = this.createViewWithHeader({
|
||||
title: gettext('Teams'),
|
||||
description: gettext('See all teams in your course, organized by topic. ' +
|
||||
'Join a team to collaborate with other learners who are interested' +
|
||||
'in the same topic as you are.'),
|
||||
description: gettext(
|
||||
'See all teams in your course, organized by topic. ' +
|
||||
'Join a team to collaborate with other learners who are ' +
|
||||
'interested in the same topic as you are.'
|
||||
),
|
||||
mainView: new TeamsTabbedView({
|
||||
tabs: [{
|
||||
title: gettext('My Team'),
|
||||
url: 'my-teams',
|
||||
view: this.myTeamsView
|
||||
}, {
|
||||
title: gettext('Browse'),
|
||||
url: 'browse',
|
||||
view: this.topicsView
|
||||
}],
|
||||
tabs: tabsList,
|
||||
router: this.router
|
||||
})
|
||||
});
|
||||
@@ -239,8 +257,10 @@
|
||||
view.mainView = view.createViewWithHeader({
|
||||
topic: topic,
|
||||
title: gettext('Create a New Team'),
|
||||
description: gettext("Create a new team if you can't find an existing team to join, " +
|
||||
'or if you would like to learn with friends you know.'),
|
||||
description: gettext(
|
||||
'Create a new team if you can\'t find an existing team to join, ' +
|
||||
'or if you would like to learn with friends you know.'
|
||||
),
|
||||
breadcrumbs: view.createBreadcrumbs(topic),
|
||||
mainView: new TeamEditView({
|
||||
action: 'create',
|
||||
@@ -274,8 +294,10 @@
|
||||
});
|
||||
editViewWithHeader = self.createViewWithHeader({
|
||||
title: gettext('Edit Team'),
|
||||
description: gettext('If you make significant changes, ' +
|
||||
'make sure you notify members of the team before making these changes.'),
|
||||
description: gettext(
|
||||
'If you make significant changes, ' +
|
||||
'make sure you notify members of the team before making these changes.'
|
||||
),
|
||||
breadcrumbs: self.createBreadcrumbs(topic, team),
|
||||
mainView: view,
|
||||
topic: topic,
|
||||
@@ -304,8 +326,10 @@
|
||||
mainView: view,
|
||||
breadcrumbs: self.createBreadcrumbs(topic, team),
|
||||
title: gettext('Membership'),
|
||||
description: gettext('You can remove members from this team, ' +
|
||||
"especially if they have not participated in the team's activity."),
|
||||
description: gettext(
|
||||
'You can remove members from this team, ' +
|
||||
'especially if they have not participated in the team\'s activity.'
|
||||
),
|
||||
topic: topic,
|
||||
team: team
|
||||
}
|
||||
@@ -459,6 +483,31 @@
|
||||
return this.context.userInfo.privileged || this.context.userInfo.staff;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether the "Manage" tab should be shown to the user.
|
||||
*/
|
||||
canViewManageTab: function() {
|
||||
return this.canManageTeams() && this.anyInstructorManagedTopics();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether a user has permission to manage teams through CSV
|
||||
* upload & download in the "Manage" tab.
|
||||
*/
|
||||
canManageTeams: function() {
|
||||
return this.canEditTeam();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether _any_ of the topics are instructor-managed.
|
||||
*/
|
||||
anyInstructorManagedTopics: function() {
|
||||
return this.topicsCollection.some(
|
||||
function(topic) { return topic.isInstructorManaged(); }
|
||||
);
|
||||
},
|
||||
|
||||
createBreadcrumbs: function(topic, team) {
|
||||
var breadcrumbs = [{
|
||||
title: gettext('All Topics'),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define([
|
||||
'underscore',
|
||||
'backbone',
|
||||
'gettext',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'teams/js/views/teams',
|
||||
'common/js/components/views/paging_header',
|
||||
'text!teams/templates/team-actions.underscore',
|
||||
'underscore'
|
||||
], function(Backbone, gettext, TeamsView, PagingHeader, teamActionsTemplate, _) {
|
||||
'text!teams/templates/team-actions.underscore'
|
||||
], function(_, Backbone, gettext, HtmlUtils, TeamsView, PagingHeader, teamActionsTemplate) {
|
||||
var TopicTeamsView = TeamsView.extend({
|
||||
events: {
|
||||
'click a.browse-teams': 'browseTeams',
|
||||
@@ -34,9 +35,10 @@
|
||||
render: function() {
|
||||
var self = this;
|
||||
this.collection.refresh().done(function() {
|
||||
var message;
|
||||
TeamsView.prototype.render.call(self);
|
||||
if (self.canUserCreateTeam()) {
|
||||
var message = interpolate_text( // eslint-disable-line vars-on-top, no-undef
|
||||
message = interpolate_text( // eslint-disable-line no-undef
|
||||
// Translators: this string is shown at the bottom of the teams page
|
||||
// to find a team to join or else to create a new one. There are three
|
||||
// links that need to be included in the message:
|
||||
@@ -45,10 +47,12 @@
|
||||
// 3. create a new team
|
||||
// Be careful to start each link with the appropriate start indicator
|
||||
// (e.g. {browse_span_start} for #1) and finish it with {span_end}.
|
||||
_.escape(gettext('{browse_span_start}Browse teams in other topics{span_end} or ' +
|
||||
'{search_span_start}search teams{span_end} in this topic. ' +
|
||||
"If you still can't find a team to join, " +
|
||||
'{create_span_start}create a new team in this topic{span_end}.')),
|
||||
_.escape(gettext(
|
||||
'{browse_span_start}Browse teams in other ' +
|
||||
'topics{span_end} or {search_span_start}search teams{span_end} ' +
|
||||
'in this topic. If you still can\'t find a team to join, ' +
|
||||
'{create_span_start}create a new team in this topic{span_end}.'
|
||||
)),
|
||||
{
|
||||
browse_span_start: '<a class="browse-teams" href="">',
|
||||
search_span_start: '<a class="search-teams" href="">',
|
||||
@@ -56,8 +60,10 @@
|
||||
span_end: '</a>'
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line max-len
|
||||
self.$el.append(_.template(teamActionsTemplate)({message: message})); // xss-lint: disable=javascript-jquery-append
|
||||
HtmlUtils.append(
|
||||
self.$el,
|
||||
HtmlUtils.template(teamActionsTemplate)({message: message})
|
||||
);
|
||||
}
|
||||
});
|
||||
return this;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define([
|
||||
'underscore',
|
||||
'gettext',
|
||||
'teams/js/views/topic_card',
|
||||
'teams/js/views/team_utils',
|
||||
'common/js/components/views/paging_header',
|
||||
'common/js/components/views/paginated_view',
|
||||
'underscore'
|
||||
], function(gettext, TopicCardView, TeamUtils, PagingHeader, PaginatedView, _) {
|
||||
'common/js/components/views/paginated_view'
|
||||
], function(_, gettext, TopicCardView, TeamUtils, PagingHeader, PaginatedView) {
|
||||
var TopicsView = PaginatedView.extend({
|
||||
type: 'topics',
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="team-management">
|
||||
<div class="page-content-main">
|
||||
<div class="team-management-section">
|
||||
<h3 class="team-detail-header">
|
||||
<%- gettext("View Current Team Memberships") %>
|
||||
</h3>
|
||||
<p>
|
||||
<%- gettext(
|
||||
"Click to download a comma-separated values (CSV) file that, " +
|
||||
"for each user, lists the team they are on for each topic."
|
||||
) %>
|
||||
</p>
|
||||
<button
|
||||
id="download-team-csv-input"
|
||||
class="download-team-csv action action-primary"
|
||||
>
|
||||
<%- gettext("Download Memberships") %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="team-management-section">
|
||||
<h3 class="team-detail-header">
|
||||
<%- gettext("Assign Team Memberships") %>
|
||||
</h3>
|
||||
<p>
|
||||
<%- gettext(
|
||||
"Click to upload a comma-separated values (CSV) file to assign " +
|
||||
"users to teams."
|
||||
) %>
|
||||
</p>
|
||||
<div class="upload-team-csv btn action action-primary">
|
||||
<input
|
||||
id="upload-team-csv-input"
|
||||
type="file"
|
||||
accept="text/csv"
|
||||
class="input-overlay-hack"
|
||||
/>
|
||||
<%- gettext("Upload Memberships") %>
|
||||
</div>
|
||||
<!-- We need to describe the format of the CSV here (TODO MST-49) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +51,7 @@ from openedx.core.djangolib.js_utils import (
|
||||
teamMembershipsUrl: '${team_memberships_url | n, js_escaped_string}',
|
||||
teamMembershipDetailUrl: '${team_membership_detail_url | n, js_escaped_string}',
|
||||
myTeamsUrl: '${my_teams_url | n, js_escaped_string}',
|
||||
teamMembershipManagementUrl: '${team_membership_management_url | n, js_escaped_string}',
|
||||
maxTeamSize: ${course.teams_configuration.default_max_team_size | n, dump_js_escaped_json},
|
||||
languages: ${languages | n, dump_js_escaped_json},
|
||||
countries: ${countries | n, dump_js_escaped_json},
|
||||
|
||||
@@ -13,9 +13,9 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.teams import api as teams_api
|
||||
from lms.djangoapps.teams.tests.factories import CourseTeamFactory
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import CourseStaffRole
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
COURSE_KEY1 = CourseKey.from_string('edx/history/1')
|
||||
|
||||
@@ -154,10 +154,10 @@ class BaseTopicSerializerTestCase(SerializerTestCase):
|
||||
"""
|
||||
topics = [
|
||||
{
|
||||
u'name': u'Tøpic {}'.format(i),
|
||||
u'description': u'The bést topic! {}'.format(i),
|
||||
u'id': six.text_type(i),
|
||||
u'type': u'open'
|
||||
'name': 'Tøpic {}'.format(i),
|
||||
'description': 'The bést topic! {}'.format(i),
|
||||
'id': six.text_type(i),
|
||||
'type': 'open',
|
||||
}
|
||||
for i in six.moves.range(num_topics)
|
||||
]
|
||||
|
||||
@@ -1642,3 +1642,52 @@ class TestElasticSearchErrors(TeamAPITestCase):
|
||||
data={'description': 'new description'},
|
||||
user='staff'
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestBulkMembershipManagement(TeamAPITestCase):
|
||||
"""
|
||||
Test that CSVs can be uploaded and downloaded to manage course membership.
|
||||
|
||||
This test case will be expanded when the view is fully
|
||||
implemented (TODO MST-31).
|
||||
"""
|
||||
good_course_id = 'TestX/TS101/Test_Course'
|
||||
fake_course_id = 'TestX/TS101/Non_Existent_Course'
|
||||
|
||||
allow_username = 'course_staff'
|
||||
deny_username = 'student_enrolled'
|
||||
|
||||
@ddt.data(
|
||||
('GET', good_course_id, deny_username, 403),
|
||||
('GET', fake_course_id, allow_username, 404),
|
||||
('GET', fake_course_id, deny_username, 404),
|
||||
('POST', good_course_id, allow_username, 501), # TODO MST-31
|
||||
('POST', good_course_id, deny_username, 403),
|
||||
('POST', fake_course_id, allow_username, 404),
|
||||
('POST', fake_course_id, deny_username, 404),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_error_statuses(self, method, course_id, username, expected_status):
|
||||
url = self.get_url(course_id)
|
||||
self.login(username)
|
||||
response = self.client.generic(method, url)
|
||||
assert response.status_code == expected_status
|
||||
|
||||
def test_download_csv(self):
|
||||
url = self.get_url(self.good_course_id)
|
||||
self.login(self.allow_username)
|
||||
response = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'text/csv'
|
||||
assert response['Content-Disposition'] == (
|
||||
'attachment; filename="team-membership_TestX_TS101_Test_Course.csv"'
|
||||
)
|
||||
# For now, just assert that the file is non-empty.
|
||||
# Eventually, we will test contents (TODO MST-31).
|
||||
assert response.content
|
||||
|
||||
@staticmethod
|
||||
def get_url(course_id):
|
||||
# This strategy allows us to test with invalid course IDs
|
||||
return reverse('team_membership_bulk_management', args=[course_id])
|
||||
|
||||
@@ -8,12 +8,15 @@ import logging
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.http import Http404
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render_to_response
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_noop
|
||||
from django.views import View
|
||||
from django_countries import countries
|
||||
from edx_rest_framework_extensions.paginators import DefaultPagination, paginate_search_results
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -28,14 +31,6 @@ from rest_framework_oauth.authentication import OAuth2Authentication
|
||||
|
||||
from lms.djangoapps.courseware.courses import get_course_with_access, has_access
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
|
||||
from lms.djangoapps.teams.api import (
|
||||
OrganizationProtectionStatus,
|
||||
has_team_api_access,
|
||||
user_organization_protection_status,
|
||||
has_specific_team_access,
|
||||
add_team_count,
|
||||
can_user_modify_team
|
||||
)
|
||||
from lms.djangoapps.teams.models import CourseTeam, CourseTeamMembership
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from openedx.core.lib.api.permissions import IsStaffOrReadOnly
|
||||
@@ -50,6 +45,16 @@ from util.model_utils import truncate_fields
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from . import is_feature_enabled
|
||||
from .api import (
|
||||
OrganizationProtectionStatus,
|
||||
add_team_count,
|
||||
can_user_modify_team,
|
||||
has_course_staff_privileges,
|
||||
has_specific_team_access,
|
||||
has_team_api_access,
|
||||
user_organization_protection_status
|
||||
)
|
||||
from .csv import load_team_membership_csv
|
||||
from .errors import AlreadyOnTeamInCourse, ElasticSearchConnectionError, NotEnrolledInCourseForTeam
|
||||
from .search_indexes import CourseTeamIndexer
|
||||
from .serializers import (
|
||||
@@ -183,6 +188,9 @@ class TeamsDashboardView(GenericAPIView):
|
||||
"team_memberships_url": reverse('team_membership_list', request=request),
|
||||
"my_teams_url": reverse('teams_list', request=request),
|
||||
"team_membership_detail_url": reverse('team_membership_detail', args=['team_id', user.username]),
|
||||
"team_membership_management_url": reverse(
|
||||
'team_membership_bulk_management', request=request, kwargs={'course_id': course_id}
|
||||
),
|
||||
"languages": [[lang[0], _(lang[1])] for lang in settings.ALL_LANGUAGES], # pylint: disable=translation-of-non-string
|
||||
"countries": list(countries),
|
||||
"disable_courseware_js": True,
|
||||
@@ -363,34 +371,35 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = CourseTeamSerializer
|
||||
|
||||
def get(self, request): # pylint: disable=too-many-statements
|
||||
def get(self, request):
|
||||
"""GET /api/team/v0/teams/"""
|
||||
result_filter = {}
|
||||
|
||||
if 'course_id' in request.query_params:
|
||||
course_id_string = request.query_params['course_id']
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id_string)
|
||||
# Ensure the course exists
|
||||
course_module = modulestore().get_course(course_key)
|
||||
if course_module is None:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
result_filter.update({'course_id': course_key})
|
||||
except InvalidKeyError:
|
||||
error = build_api_error(
|
||||
ugettext_noop(u"The supplied course id {course_id} is not valid."),
|
||||
course_id=course_id_string,
|
||||
)
|
||||
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not has_team_api_access(request.user, course_key):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
if 'course_id' not in request.query_params:
|
||||
return Response(
|
||||
build_api_error(ugettext_noop("course_id must be provided")),
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
course_id_string = request.query_params['course_id']
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id_string)
|
||||
course_module = modulestore().get_course(course_key)
|
||||
except InvalidKeyError:
|
||||
error = build_api_error(
|
||||
ugettext_noop(u"The supplied course id {course_id} is not valid."),
|
||||
course_id=course_id_string,
|
||||
)
|
||||
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Ensure the course exists
|
||||
if course_module is None:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
result_filter.update({'course_id': course_key})
|
||||
|
||||
if not has_team_api_access(request.user, course_key):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
text_search = request.query_params.get('text_search', None)
|
||||
if text_search and request.query_params.get('order_by', None):
|
||||
return Response(
|
||||
@@ -411,13 +420,13 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||
result_filter.update({'topic_id': topic_id})
|
||||
|
||||
organization_protection_status = user_organization_protection_status(request.user, course_key)
|
||||
if organization_protection_status != OrganizationProtectionStatus.protection_exempt:
|
||||
result_filter.update(
|
||||
{
|
||||
'organization_protected': organization_protection_status == OrganizationProtectionStatus.protected
|
||||
}
|
||||
)
|
||||
organization_protection_status = user_organization_protection_status(
|
||||
request.user, course_key
|
||||
)
|
||||
if not organization_protection_status.is_exempt:
|
||||
result_filter.update({
|
||||
'organization_protected': organization_protection_status.is_protected
|
||||
})
|
||||
|
||||
if text_search and CourseTeamIndexer.search_is_enabled():
|
||||
try:
|
||||
@@ -450,33 +459,36 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
|
||||
page = self.paginate_queryset(paginated_results)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
order_by_input = None
|
||||
else:
|
||||
queryset = CourseTeam.objects.filter(**result_filter)
|
||||
order_by_input = request.query_params.get('order_by', 'name')
|
||||
if order_by_input == 'name':
|
||||
# MySQL does case-insensitive order_by.
|
||||
queryset = queryset.order_by('name')
|
||||
elif order_by_input == 'open_slots':
|
||||
queryset = queryset.order_by('team_size', '-last_activity_at')
|
||||
elif order_by_input == 'last_activity_at':
|
||||
queryset = queryset.order_by('-last_activity_at', 'team_size')
|
||||
else:
|
||||
return Response({
|
||||
'developer_message': u"unsupported order_by value {ordering}".format(ordering=order_by_input),
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
ordering_schemes = {
|
||||
'name': ('name',), # MySQL does case-insensitive order_by
|
||||
'open_slots': ('team_size', '-last_activity_at'),
|
||||
'last_activity_at': ('-last_activity_at', 'team_size'),
|
||||
}
|
||||
queryset = CourseTeam.objects.filter(**result_filter)
|
||||
order_by_input = request.query_params.get('order_by', 'name')
|
||||
if order_by_input not in ordering_schemes:
|
||||
return Response(
|
||||
{
|
||||
'developer_message': u"unsupported order_by value {ordering}".format(
|
||||
ordering=order_by_input,
|
||||
),
|
||||
# Translators: 'ordering' is a string describing a way
|
||||
# of ordering a list. For example, {ordering} may be
|
||||
# 'name', indicating that the user wants to sort the
|
||||
# list by lower case name.
|
||||
'user_message': _(u"The ordering {ordering} is not supported").format(ordering=order_by_input),
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
|
||||
'user_message': _(u"The ordering {ordering} is not supported").format(
|
||||
ordering=order_by_input,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
queryset = queryset.order_by(*ordering_schemes[order_by_input])
|
||||
page = self.paginate_queryset(queryset)
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
response = self.get_paginated_response(serializer.data)
|
||||
if order_by_input is not None:
|
||||
response.data['sort_order'] = order_by_input
|
||||
response.data['sort_order'] = order_by_input
|
||||
return response
|
||||
|
||||
def post(self, request):
|
||||
@@ -1168,7 +1180,7 @@ class MembershipListView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not can_user_modify_team(request.user, team.course_id, team):
|
||||
if not can_user_modify_team(request.user, team):
|
||||
return Response(
|
||||
build_api_error(ugettext_noop("You can't join an instructor managed team.")),
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
@@ -1317,7 +1329,7 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
if not has_specific_team_access(request.user, team):
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not can_user_modify_team(request.user, team.course_id, team):
|
||||
if not can_user_modify_team(request.user, team):
|
||||
return Response(
|
||||
build_api_error(ugettext_noop("You can't leave an instructor managed team.")),
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
@@ -1338,3 +1350,59 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
|
||||
}
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class MembershipBulkManagementView(View):
|
||||
"""
|
||||
Partially-implemented view for uploading and downloading team membership CSVs.
|
||||
|
||||
TODO MST-31
|
||||
"""
|
||||
def get(self, request, **_kwargs):
|
||||
"""
|
||||
Download CSV with team membership data for given course run.
|
||||
"""
|
||||
self.check_access()
|
||||
response = HttpResponse(content_type='text/csv')
|
||||
filename = "team-membership_{}_{}_{}.csv".format(
|
||||
self.course.id.org, self.course.id.course, self.course.id.run
|
||||
)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
load_team_membership_csv(self.course, response)
|
||||
return response
|
||||
|
||||
def post(self, request, **_kwargs):
|
||||
"""
|
||||
Process uploaded CSV to modify team memberships for given course run.
|
||||
"""
|
||||
self.check_access()
|
||||
return HttpResponse(status=status.HTTP_501_NOT_IMPLEMENTED)
|
||||
|
||||
def check_access(self):
|
||||
"""
|
||||
Raises 403 if user does not have access to this endpoint.
|
||||
"""
|
||||
if not has_course_staff_privileges(self.request.user, self.course.id):
|
||||
raise PermissionDenied(
|
||||
"To manage team membership of {}, you must be course staff.".format(
|
||||
self.course.id
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def course(self):
|
||||
"""
|
||||
Return a CourseDescriptor based on the `course_id` kwarg.
|
||||
If invalid or not found, raise 404.
|
||||
"""
|
||||
course_id_string = self.kwargs.get('course_id')
|
||||
if not course_id_string:
|
||||
raise Http404('No course key provided.')
|
||||
try:
|
||||
course_id = CourseKey.from_string(course_id_string)
|
||||
except InvalidKeyError:
|
||||
raise Http404('Invalid course key: {}'.format(course_id_string))
|
||||
course_module = modulestore().get_course(course_id)
|
||||
if not course_module:
|
||||
raise Http404('Course not found: {}'.format(course_id))
|
||||
return course_module
|
||||
|
||||
@@ -819,6 +819,7 @@
|
||||
'teams/js/spec/views/edit_team_members_spec.js',
|
||||
'teams/js/spec/views/edit_team_spec.js',
|
||||
'teams/js/spec/views/instructor_tools_spec.js',
|
||||
'teams/js/spec/views/manage_spec.js',
|
||||
'teams/js/spec/views/my_teams_spec.js',
|
||||
'teams/js/spec/views/team_card_spec.js',
|
||||
'teams/js/spec/views/team_discussion_spec.js',
|
||||
|
||||
@@ -353,7 +353,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.team-profile {
|
||||
.team-management {
|
||||
padding: 1%;
|
||||
|
||||
h3, p, .action, .team-management-section {
|
||||
margin-bottom: 2%;
|
||||
}
|
||||
|
||||
.input-overlay-hack {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-team-csv {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.team-profile, .team-management {
|
||||
.page-content-main {
|
||||
display: inline-block;
|
||||
width: flex-grid(8, 12);
|
||||
|
||||
Reference in New Issue
Block a user