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:
Kyle McCormick
2019-12-02 11:27:05 -05:00
committed by GitHub
parent c1f7b35483
commit dcf5d70bc4
18 changed files with 571 additions and 114 deletions

View File

@@ -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)
)

View File

@@ -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"
)
]

View 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")

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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');

View 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);

View File

@@ -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'),

View File

@@ -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;

View File

@@ -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',

View File

@@ -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>

View File

@@ -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},

View File

@@ -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')

View File

@@ -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)
]

View File

@@ -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])

View File

@@ -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

View File

@@ -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',

View File

@@ -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);