Commit 0c3be8e1 by Peter Fogg Committed by Eric Fischer

Delete Team functionality and tests

TNL-3164 TNL-3163
parent 8942b31d
"""
Utility methods common to Studio and the LMS.
"""
from bok_choy.promise import EmptyPromise
from ...tests.helpers import disable_animations
def wait_for_notification(page):
"""
Waits for the "mini-notification" to appear and disappear on the given page (subclass of PageObject).
"""
def _is_saving():
"""Whether or not the notification is currently showing."""
return page.q(css='.wrapper-notification-mini.is-shown').present
def _is_saving_done():
"""Whether or not the notification is finished showing."""
return page.q(css='.wrapper-notification-mini.is-hiding').present
EmptyPromise(_is_saving, 'Notification should have been shown.', timeout=60).fulfill()
EmptyPromise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill()
def click_css(page, css, source_index=0, require_notification=True):
"""
Click the button/link with the given css and index on the specified page (subclass of PageObject).
Will only consider elements that are displayed and have a height and width greater than zero.
If require_notification is False (default value is True), the method will return immediately.
Otherwise, it will wait for the "mini-notification" to appear and disappear.
"""
def _is_visible(element):
"""Is the given element visible?"""
# Only make the call to size once (instead of once for the height and once for the width)
# because otherwise you will trigger a extra query on a remote element.
return element.is_displayed() and all(size > 0 for size in element.size.itervalues())
# Disable all animations for faster testing with more reliable synchronization
disable_animations(page)
# Click on the element in the browser
page.q(css=css).filter(_is_visible).nth(source_index).click()
if require_notification:
wait_for_notification(page)
# Some buttons trigger ajax posts
# (e.g. .add-missing-groups-button as configured in split_test_author_view.js)
# so after you click anything wait for the ajax call to finish
page.wait_for_ajax()
def confirm_prompt(page, cancel=False, require_notification=None):
"""
Ensures that a modal prompt and confirmation button are visible, then clicks the button. The prompt is canceled iff
cancel is True.
"""
page.wait_for_element_visibility('.prompt', 'Prompt is visible')
confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary')
page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible')
require_notification = (not cancel) if require_notification is None else require_notification
click_css(page, confirmation_button_css, require_notification=require_notification)
......@@ -6,7 +6,7 @@ Teams pages.
from .course_page import CoursePage
from .discussion import InlineDiscussionPage
from ..common.paging import PaginatedUIMixin
from ...pages.studio.utils import confirm_prompt
from ...pages.common.utils import confirm_prompt
from .fields import FieldsMixin
......@@ -39,7 +39,24 @@ class TeamCardsMixin(object):
return self.q(css='p.card-description').map(lambda e: e.text).results
class TeamsPage(CoursePage):
class BreadcrumbsMixin(object):
"""Provides common operations on teams page breadcrumb links."""
@property
def header_page_breadcrumbs(self):
"""Get the page breadcrumb text displayed by the page header"""
return self.q(css='.page-header .breadcrumbs')[0].text
def click_all_topics(self):
""" Click on the "All Topics" breadcrumb """
self.q(css='a.nav-item').filter(text='All Topics')[0].click()
def click_specific_topic(self, topic):
""" Click on the breadcrumb for a specific topic """
self.q(css='a.nav-item').filter(text=topic)[0].click()
class TeamsPage(CoursePage, BreadcrumbsMixin):
"""
Teams page/tab.
"""
......@@ -88,7 +105,7 @@ class TeamsPage(CoursePage):
# Click to "My Team" and verify that it contains the expected number of teams.
self.q(css=MY_TEAMS_BUTTON_CSS).click()
self.wait_for_ajax()
self.wait_for(
lambda: len(self.q(css='.team-card')) == expected_count,
description="Expected number of teams is wrong"
......@@ -169,7 +186,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
self.wait_for_ajax()
class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin, BreadcrumbsMixin):
"""
The paginated UI for browsing teams within a Topic on the Teams
page.
......@@ -207,6 +224,11 @@ class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
lambda e: e.is_selected()
).results[0].text.strip()
@property
def team_names(self):
"""Get all the team names on the page."""
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results
def click_create_team_link(self):
""" Click on create team link."""
query = self.q(css=CREATE_TEAM_LINK_CSS)
......@@ -278,9 +300,9 @@ class SearchTeamsPage(BaseTeamsPage):
self.url_path = "teams/#topics/{topic_id}/search".format(topic_id=self.topic['id'])
class CreateOrEditTeamPage(CoursePage, FieldsMixin):
class TeamManagementPage(CoursePage, FieldsMixin, BreadcrumbsMixin):
"""
Create team page.
Team page for creation, editing, and deletion.
"""
def __init__(self, browser, course_id, topic):
"""
......@@ -289,7 +311,7 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
representation of a topic following the same convention as a
course module's topic.
"""
super(CreateOrEditTeamPage, self).__init__(browser, course_id)
super(TeamManagementPage, self).__init__(browser, course_id)
self.topic = topic
self.url_path = "teams/#topics/{topic_id}/create-team".format(topic_id=self.topic['id'])
......@@ -310,11 +332,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
return self.q(css='.page-header .page-description')[0].text
@property
def header_page_breadcrumbs(self):
"""Get the page breadcrumb text displayed by the page header"""
return self.q(css='.page-header .breadcrumbs')[0].text
@property
def validation_message_text(self):
"""Get the error message text"""
return self.q(css='.create-team.wrapper-msg .copy')[0].text
......@@ -329,8 +346,13 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
self.q(css='.create-team .action-cancel').first.click()
self.wait_for_ajax()
@property
def delete_team_button(self):
"""Returns the 'delete team' button."""
return self.q(css='.action-delete').first
class TeamPage(CoursePage, PaginatedUIMixin):
class TeamPage(CoursePage, PaginatedUIMixin, BreadcrumbsMixin):
"""
The page for a specific Team within the Teams tab
"""
......@@ -479,11 +501,6 @@ class TeamPage(CoursePage, PaginatedUIMixin):
""" Returns True if New Post button is present else False """
return self.q(css='.discussion-module .new-post-btn').present
def click_all_topics_breadcrumb(self):
"""Navigate to the 'All Topics' page."""
self.q(css='.breadcrumbs a').results[0].click()
self.wait_for_ajax()
@property
def edit_team_button_present(self):
""" Returns True if Edit Team button is present else False """
......
from bok_choy.page_object import PageObject
from selenium.webdriver.common.keys import Keys
from utils import click_css
from ..common.utils import click_css
from selenium.webdriver.support.ui import Select
......
......@@ -6,7 +6,9 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
from .utils import click_css, confirm_prompt, type_in_codemirror
from ..common.utils import click_css, confirm_prompt
from .utils import type_in_codemirror
class ContainerPage(PageObject):
......
......@@ -9,7 +9,8 @@ import os
import re
import requests
from .utils import click_css
from ..common.utils import click_css
from .library import LibraryPage
from .course_page import CoursePage
from . import BASE_URL
......
......@@ -9,7 +9,9 @@ from .component_editor import ComponentEditorView
from .container import XBlockWrapper
from ...pages.studio.users import UsersPageMixin
from ...pages.studio.pagination import PaginatedMixin
from .utils import confirm_prompt, wait_for_notification
from ..common.utils import confirm_prompt, wait_for_notification
from . import BASE_URL
......
......@@ -9,9 +9,11 @@ from bok_choy.promise import EmptyPromise
from selenium.webdriver.support.ui import Select
from selenium.webdriver.common.keys import Keys
from ..common.utils import click_css, confirm_prompt
from .course_page import CoursePage
from .container import ContainerPage
from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt
from .utils import set_input_value_and_save, set_input_value
class CourseOutlineItem(object):
......
......@@ -2,8 +2,8 @@
Course Group Configurations page.
"""
from bok_choy.promise import EmptyPromise
from ..common.utils import confirm_prompt
from .course_page import CoursePage
from .utils import confirm_prompt
class GroupConfigurationsPage(CoursePage):
......
......@@ -4,8 +4,8 @@ Course Textbooks page.
import requests
from path import Path as path
from ..common.utils import click_css
from .course_page import CoursePage
from .utils import click_css
class TextbooksPage(CoursePage):
......
......@@ -3,52 +3,9 @@ Utility methods useful for Studio page tests.
"""
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from bok_choy.promise import EmptyPromise
from bok_choy.javascript import js_defined
from ...tests.helpers import disable_animations
def click_css(page, css, source_index=0, require_notification=True):
"""
Click the button/link with the given css and index on the specified page (subclass of PageObject).
Will only consider elements that are displayed and have a height and width greater than zero.
If require_notification is False (default value is True), the method will return immediately.
Otherwise, it will wait for the "mini-notification" to appear and disappear.
"""
def _is_visible(el):
# Only make the call to size once (instead of once for the height and once for the width)
# because otherwise you will trigger a extra query on a remote element.
return el.is_displayed() and all(size > 0 for size in el.size.itervalues())
# Disable all animations for faster testing with more reliable synchronization
disable_animations(page)
# Click on the element in the browser
page.q(css=css).filter(lambda el: _is_visible(el)).nth(source_index).click()
if require_notification:
wait_for_notification(page)
# Some buttons trigger ajax posts
# (e.g. .add-missing-groups-button as configured in split_test_author_view.js)
# so after you click anything wait for the ajax call to finish
page.wait_for_ajax()
def wait_for_notification(page):
"""
Waits for the "mini-notification" to appear and disappear on the given page (subclass of PageObject).
"""
def _is_saving():
return page.q(css='.wrapper-notification-mini.is-shown').present
def _is_saving_done():
return page.q(css='.wrapper-notification-mini.is-hiding').present
EmptyPromise(_is_saving, 'Notification should have been shown.', timeout=60).fulfill()
EmptyPromise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill()
from ..common.utils import click_css, wait_for_notification
@js_defined('window.jQuery')
......@@ -177,18 +134,6 @@ def get_codemirror_value(page, index=0, find_prefix="$"):
)
def confirm_prompt(page, cancel=False, require_notification=None):
"""
Ensures that a modal prompt and confirmation button are visible, then clicks the button. The prompt is canceled iff
cancel is True.
"""
page.wait_for_element_visibility('.prompt', 'Prompt is visible')
confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary')
page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible')
require_notification = (not cancel) if require_notification is None else require_notification
click_css(page, confirmation_button_css, require_notification=require_notification)
def set_input_value(page, css, value):
"""
Sets the text field with the given label (display name) to the specified value.
......
......@@ -8,8 +8,8 @@ from bok_choy.promise import EmptyPromise, Promise
from bok_choy.javascript import wait_for_js, js_defined
from ....tests.helpers import YouTubeStubConfig
from ...lms.video.video import VideoPage
from ...common.utils import wait_for_notification
from selenium.webdriver.common.keys import Keys
from ..utils import wait_for_notification
CLASS_SELECTORS = {
......
......@@ -7,7 +7,8 @@ from nose.plugins.attrib import attr
from ..helpers import UniqueCourseTest, remove_file
from ...pages.common.logout import LogoutPage
from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
from ...pages.common.utils import click_css
from ...pages.studio.utils import add_html_component, type_in_codemirror
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage
......
......@@ -7,7 +7,8 @@ import json
from bok_choy.web_app_test import WebAppTest
from ..helpers import generate_course_key
from ...pages.common.logout import LogoutPage
from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
from ...pages.common.utils import click_css
from ...pages.studio.utils import add_html_component, type_in_codemirror
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage
......
......@@ -4,9 +4,10 @@ import logging
from requests import ConnectionError
from django.conf import settings
from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import translation
from functools import wraps
from search.search_engine_base import SearchEngine
......@@ -14,6 +15,19 @@ from .errors import ElasticSearchConnectionError
from .serializers import CourseTeamSerializer, CourseTeam
def if_search_enabled(f):
"""
Only call `f` if search is enabled for the CourseTeamIndexer.
"""
@wraps(f)
def wrapper(*args, **kwargs):
"""Wraps the decorated function."""
cls = args[0]
if cls.search_is_enabled():
return f(*args, **kwargs)
return wrapper
class CourseTeamIndexer(object):
"""
This is the index object for searching and storing CourseTeam model instances.
......@@ -70,16 +84,25 @@ class CourseTeamIndexer(object):
return self.course_team.language
@classmethod
@if_search_enabled
def index(cls, course_team):
"""
Update index with course_team object (if feature is enabled).
"""
if cls.search_is_enabled():
search_engine = cls.engine()
serialized_course_team = CourseTeamIndexer(course_team).data()
search_engine.index(cls.DOCUMENT_TYPE_NAME, [serialized_course_team])
search_engine = cls.engine()
serialized_course_team = CourseTeamIndexer(course_team).data()
search_engine.index(cls.DOCUMENT_TYPE_NAME, [serialized_course_team])
@classmethod
@if_search_enabled
def remove(cls, course_team):
"""
Remove course_team from the index (if feature is enabled).
"""
cls.engine().remove(cls.DOCUMENT_TYPE_NAME, [course_team.team_id])
@classmethod
@if_search_enabled
def engine(cls):
"""
Return course team search engine (if feature is enabled).
......@@ -108,3 +131,11 @@ def course_team_post_save_callback(**kwargs):
CourseTeamIndexer.index(kwargs['instance'])
except ElasticSearchConnectionError:
pass
@receiver(post_delete, sender=CourseTeam, dispatch_uid='teams.signals.course_team_post_delete_callback')
def course_team_post_delete_callback(**kwargs): # pylint: disable=invalid-name
"""
Reindex object after delete.
"""
CourseTeamIndexer.remove(kwargs['instance'])
......@@ -25,7 +25,7 @@
},
onUpdate: function(event) {
if (event.action === 'create') {
if (_.contains(['create', 'delete'], event.action)) {
this.isStale = true;
}
},
......
......@@ -35,5 +35,14 @@ define(['backbone', 'URI', 'underscore', 'common/js/spec_helpers/ajax_helpers',
topicCollection.course_id = 'my+course+id';
testRequestParam(this, 'course_id', 'my+course+id');
});
it('sets itself to stale on receiving a teams create or delete event', function () {
expect(topicCollection.isStale).toBe(false);
TeamSpecHelpers.triggerTeamEvent('create');
expect(topicCollection.isStale).toBe(true);
topicCollection.isStale = false;
TeamSpecHelpers.triggerTeamEvent('delete');
expect(topicCollection.isStale).toBe(true);
});
});
});
define([
'jquery',
'backbone',
'underscore',
'teams/js/models/team',
'teams/js/views/instructor_tools',
'teams/js/views/team_utils',
'teams/js/spec_helpers/team_spec_helpers',
'common/js/spec_helpers/ajax_helpers'
], function ($, Backbone, _, Team, InstructorToolsView, TeamUtils, TeamSpecHelpers, AjaxHelpers) {
'use strict';
describe('Instructor Tools', function () {
var view,
createInstructorTools = function () {
return new InstructorToolsView({
team: new Team(TeamSpecHelpers.createMockTeamData(1, 1)[0]),
teamEvents: TeamSpecHelpers.teamEvents,
});
},
deleteTeam = function (view, confirm) {
view.$('.action-delete').click();
// Confirm delete dialog
if (confirm) {
$('.action-primary').click();
}
else {
$('.action-secondary').click();
}
},
expectSuccessMessage = function (team) {
expect(TeamUtils.showMessage).toHaveBeenCalledWith(
'Team "' + team.get('name') + '" successfully deleted.',
'success'
);
};
beforeEach(function () {
setFixtures('<div id="page-prompt"></div>');
spyOn(Backbone.history, 'navigate');
spyOn(TeamUtils, 'showMessage');
view = createInstructorTools().render();
spyOn(view.teamEvents, 'trigger');
});
it('can render itself', function () {
expect(_.strip(view.$('.action-delete').text())).toEqual('Delete Team');
expect(_.strip(view.$('.action-edit-members').text())).toEqual('Edit Membership');
expect(view.$el.text()).toContain('Instructor tools');
});
it('can delete a team and shows a success message', function () {
var requests = AjaxHelpers.requests(this);
deleteTeam(view, true);
AjaxHelpers.expectJsonRequest(requests, 'DELETE', view.team.url, null);
AjaxHelpers.respondWithNoContent(requests);
expect(Backbone.history.navigate).toHaveBeenCalledWith(
'topics/' + view.team.get('topic_id'),
{trigger: true}
);
expect(view.teamEvents.trigger).toHaveBeenCalledWith(
'teams:update', {
action: 'delete',
team: view.team
}
);
expectSuccessMessage(view.team);
});
it('can cancel team deletion', function () {
var requests = AjaxHelpers.requests(this);
deleteTeam(view, false);
expect(requests.length).toBe(0);
expect(Backbone.history.navigate).not.toHaveBeenCalled();
});
it('shows a success message after receiving a 404', function () {
var requests = AjaxHelpers.requests(this);
deleteTeam(view, true);
AjaxHelpers.respondWithError(requests, 404);
expectSuccessMessage(view.team);
});
});
});
......@@ -61,7 +61,7 @@ define([
it('does not interfere with anchor links to #content', function () {
var teamsTabView = createTeamsTabView();
teamsTabView.router.navigate('#content', {trigger: true});
expect(teamsTabView.$('.warning')).toHaveClass('is-hidden');
expect(teamsTabView.$('.wrapper-msg')).toHaveClass('is-hidden');
});
it('displays and focuses an error message when trying to navigate to a nonexistent page', function () {
......
......@@ -29,13 +29,15 @@ define([
var createMockTeamData = function (startIndex, stopIndex) {
return _.map(_.range(startIndex, stopIndex + 1), function (i) {
var id = "id" + i;
return {
name: "team " + i,
id: "id " + i,
id: id,
language: testLanguages[i%4][0],
country: testCountries[i%4][0],
membership: [],
last_activity_at: ''
last_activity_at: '',
url: 'api/team/v0/teams/' + id
};
});
};
......@@ -124,6 +126,10 @@ define([
});
};
var triggerTeamEvent = function (action) {
teamEvents.trigger('teams:update', {action: action});
};
createMockPostResponse = function(options) {
return _.extend(
{
......@@ -327,6 +333,7 @@ define([
createMockThreadResponse: createMockThreadResponse,
createMockTopicData: createMockTopicData,
createMockTopicCollection: createMockTopicCollection,
triggerTeamEvent: triggerTeamEvent,
verifyCards: verifyCards
};
});
......@@ -5,8 +5,9 @@
'underscore',
'gettext',
'teams/js/views/team_utils',
'common/js/components/utils/view_utils',
'text!teams/templates/instructor-tools.underscore'],
function (Backbone, _, gettext, TeamUtils, instructorToolbarTemplate) {
function (Backbone, _, gettext, TeamUtils, ViewUtils, instructorToolbarTemplate) {
return Backbone.View.extend({
events: {
......@@ -16,6 +17,8 @@
initialize: function(options) {
this.template = _.template(instructorToolbarTemplate);
this.team = options.team;
this.teamEvents = options.teamEvents;
},
render: function() {
......@@ -25,14 +28,46 @@
deleteTeam: function (event) {
event.preventDefault();
alert("You clicked the button!");
//placeholder; will route to delete team page
ViewUtils.confirmThenRunOperation(
gettext('Delete this team?'),
gettext('Deleting a team is permanent and cannot be undone. All members are removed from the team, and team discussions can no longer be accessed.'),
gettext('Delete'),
_.bind(this.handleDelete, this)
);
},
editMembership: function (event) {
event.preventDefault();
alert("You clicked the button!");
//placeholder; will route to remove team member page
},
handleDelete: function () {
var self = this,
postDelete = function () {
self.teamEvents.trigger('teams:update', {
action: 'delete',
team: self.team
});
Backbone.history.navigate('topics/' + self.team.get('topic_id'), {trigger: true});
TeamUtils.showMessage(
interpolate(
gettext('Team "%(team)s" successfully deleted.'),
{team: self.team.get('name')},
true
),
'success'
);
};
this.team.destroy().then(postDelete).fail(function (response) {
// In the 404 case, this team has already been
// deleted by someone else. Since the team was
// successfully deleted anyway, just show a
// success message.
if (response.status === 404) {
postDelete();
}
});
}
});
});
......
......@@ -40,9 +40,16 @@
);
},
showMessage: function (message) {
var messageElement = $('.teams-content .wrapper-msg');
messageElement.removeClass('is-hidden');
hideMessage: function () {
$('#teams-message').addClass('.is-hidden');
},
showMessage: function (message, type) {
var messageElement = $('#teams-message');
if (_.isUndefined(type)) {
type = 'warning';
}
messageElement.removeClass('is-hidden').addClass(type);
$('.teams-content .msg-content .copy').text(message);
messageElement.focus();
},
......@@ -50,12 +57,14 @@
/**
* Parse `data` and show user message. If parsing fails than show `genericErrorMessage`
*/
parseAndShowMessage: function (data, genericErrorMessage) {
parseAndShowMessage: function (data, genericErrorMessage, type) {
try {
var errors = JSON.parse(data.responseText);
this.showMessage(_.isUndefined(errors.user_message) ? genericErrorMessage : errors.user_message);
this.showMessage(
_.isUndefined(errors.user_message) ? genericErrorMessage : errors.user_message, type
);
} catch (error) {
this.showMessage(genericErrorMessage);
this.showMessage(genericErrorMessage, type);
}
}
};
......
......@@ -20,8 +20,8 @@
'teams/js/views/topic_teams',
'teams/js/views/edit_team',
'teams/js/views/team_profile_header_actions',
'teams/js/views/team_utils',
'teams/js/views/instructor_tools',
'teams/js/views/team_utils',
'text!teams/templates/teams_tab.underscore'],
function (Backbone, _, gettext, SearchFieldView, HeaderView, HeaderModel,
TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics,
......@@ -45,8 +45,9 @@
this.$el.html(_.template(teamsTemplate));
this.$('p.error').hide();
this.header.setElement(this.$('.teams-header')).render();
if (this.instructorTools)
if (this.instructorTools) {
this.instructorTools.setElement(this.$('.teams-instructor-tools-bar')).render();
}
this.main.setElement(this.$('.page-content')).render();
return this;
}
......@@ -173,7 +174,7 @@
render: function() {
this.mainView.setElement(this.$el).render();
this.hideWarning();
TeamUtils.hideMessage();
return this;
},
......@@ -253,7 +254,10 @@
topic: topic,
model: team
});
var instructorToolsView = new InstructorToolsView();
var instructorToolsView = new InstructorToolsView({
team: team,
teamEvents: self.teamEvents
});
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."),
......@@ -570,18 +574,7 @@
*/
notFoundError: function (message) {
this.router.navigate('my-teams', {trigger: true});
this.showWarning(message);
},
showWarning: function (message) {
var warningEl = this.$('.warning');
warningEl.find('.copy').html('<p>' + message + '</p');
warningEl.toggleClass('is-hidden', false);
warningEl.focus();
},
hideWarning: function () {
this.$('.warning').toggleClass('is-hidden', true);
TeamUtils.showMessage(message);
},
/**
......
......@@ -3,9 +3,10 @@
define([
'gettext',
'teams/js/views/topic_card',
'teams/js/views/team_utils',
'common/js/components/views/paging_header',
'common/js/components/views/paginated_view'
], function (gettext, TopicCardView, PagingHeader, PaginatedView) {
], function (gettext, TopicCardView, TeamUtils, PagingHeader, PaginatedView) {
var TopicsView = PaginatedView.extend({
type: 'topics',
......@@ -35,6 +36,7 @@
this.collection.refresh()
.done(function() {
PaginatedView.prototype.render.call(self);
TeamUtils.hideMessage();
});
return this;
}
......
<div class="wrapper-msg is-incontext urgency-low warning is-hidden" tabindex="-1">
<div id="teams-message" class="wrapper-msg is-incontext urgency-low is-hidden" tabindex="-1">
<div class="msg">
<div class="msg-content">
<div class="copy">
......
......@@ -21,6 +21,7 @@ from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from .factories import CourseTeamFactory, LAST_ACTIVITY_AT
from ..models import CourseTeamMembership
from ..search_indexes import CourseTeamIndexer, CourseTeam, course_team_post_save_callback
from django_comment_common.models import Role, FORUM_ROLE_COMMUNITY_TA
......@@ -339,6 +340,10 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
"""Gets detailed team information for team_id. Verifies expected_status."""
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'get', data, **kwargs)
def delete_team(self, team_id, expected_status, **kwargs):
"""Delete the given team. Verifies expected_status."""
return self.make_call(reverse('teams_detail', args=[team_id]), expected_status, 'delete', **kwargs)
def patch_team_detail(self, team_id, expected_status, data=None, **kwargs):
"""Patches the team with team_id using data. Verifies expected_status."""
return self.make_call(
......@@ -437,8 +442,9 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
def verify_names(self, data, status, names=None, **kwargs):
"""Gets a team listing with data as query params, verifies status, and then verifies team names if specified."""
teams = self.get_teams_list(data=data, expected_status=status, **kwargs)
if names:
self.assertEqual(names, [team['name'] for team in teams['results']])
if names is not None and 200 <= status < 300:
results = teams['results']
self.assertEqual(names, [team['name'] for team in results])
def test_filter_invalid_course_id(self):
self.verify_names({'course_id': 'no_such_course'}, 400)
......@@ -562,6 +568,26 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
user='student_enrolled_public_profile'
)
def test_delete_removed_from_search(self):
team = CourseTeamFactory.create(
name=u'zoinks',
course_id=self.test_course_1.id,
topic_id='topic_0'
)
self.verify_names(
{'course_id': self.test_course_1.id, 'text_search': 'zoinks'},
200,
[team.name],
user='staff'
)
team.delete()
self.verify_names(
{'course_id': self.test_course_1.id, 'text_search': 'zoinks'},
200,
[],
user='staff'
)
@ddt.ddt
class TestCreateTeamAPI(EventTestMixin, TeamAPITestCase):
......@@ -777,6 +803,32 @@ class TestDetailTeamAPI(TeamAPITestCase):
@ddt.ddt
class TestDeleteTeamAPI(TeamAPITestCase):
"""Test cases for the team delete endpoint."""
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_enrolled', 403),
('staff', 204),
('course_staff', 204),
('community_ta', 204)
)
@ddt.unpack
def test_access(self, user, status):
self.delete_team(self.solar_team.team_id, status, user=user)
def test_does_not_exist(self):
self.delete_team('nonexistent', 404)
def test_memberships_deleted(self):
self.assertEqual(CourseTeamMembership.objects.filter(team=self.solar_team).count(), 1)
self.delete_team(self.solar_team.team_id, 204, user='staff')
self.assertEqual(CourseTeamMembership.objects.filter(team=self.solar_team).count(), 0)
@ddt.ddt
class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase):
"""Test cases for the team update endpoint."""
......
"""HTTP endpoints for the Teams API."""
from django.shortcuts import render_to_response
import logging
from django.shortcuts import get_object_or_404, render_to_response
from django.http import Http404
from django.conf import settings
from django.core.paginator import Paginator
......@@ -61,6 +63,8 @@ TEAM_MEMBERSHIPS_PER_PAGE = 2
TOPICS_PER_PAGE = 12
MAXIMUM_SEARCH_SIZE = 100000
log = logging.getLogger(__name__)
@receiver(post_save, sender=CourseTeam)
def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument
......@@ -504,7 +508,7 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""
**Use Cases**
Get or update a course team's information. Updates are supported
Get, update, or delete a course team's information. Updates are supported
only through merge patch.
**Example Requests**:
......@@ -513,6 +517,8 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"
DELETE /api/team/v0/teams/{team_id}
**Query Parameters for GET**
* expand: Comma separated list of types for which to return
......@@ -577,6 +583,20 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
If the update could not be completed due to validation errors, this
method returns a 400 error with all error messages in the
"field_errors" field of the returned JSON.
**Response Values for DELETE**
Only staff can delete teams. When a team is deleted, all
team memberships associated with that team are also
deleted. Returns 204 on successful deletion.
If the user is anonymous or inactive, a 401 is returned.
If the user is not course or global staff and does not
have discussion privileges, a 403 is returned.
If the user is logged in and the team does not exist, a 404 is returned.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,)
......@@ -588,6 +608,15 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""Returns the queryset used to access the given team."""
return CourseTeam.objects.all()
def delete(self, request, team_id):
"""DELETE /api/team/v0/teams/{team_id}"""
team = get_object_or_404(CourseTeam, team_id=team_id)
self.check_object_permissions(request, team)
# Note: also deletes all team memberships associated with this team
team.delete()
log.info('user %d deleted team %s', request.user.id, team_id)
return Response(status=status.HTTP_204_NO_CONTENT)
class TopicListView(GenericAPIView):
"""
......
......@@ -702,6 +702,7 @@
'lms/include/teams/js/spec/collections/topic_collection_spec.js',
'lms/include/teams/js/spec/teams_tab_factory_spec.js',
'lms/include/teams/js/spec/views/edit_team_spec.js',
'lms/include/teams/js/spec/views/instructor_tools_spec.js',
'lms/include/teams/js/spec/views/my_teams_spec.js',
'lms/include/teams/js/spec/views/team_card_spec.js',
'lms/include/teams/js/spec/views/team_discussion_spec.js',
......
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