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. ...@@ -6,7 +6,7 @@ Teams pages.
from .course_page import CoursePage from .course_page import CoursePage
from .discussion import InlineDiscussionPage from .discussion import InlineDiscussionPage
from ..common.paging import PaginatedUIMixin from ..common.paging import PaginatedUIMixin
from ...pages.studio.utils import confirm_prompt from ...pages.common.utils import confirm_prompt
from .fields import FieldsMixin from .fields import FieldsMixin
...@@ -39,7 +39,24 @@ class TeamCardsMixin(object): ...@@ -39,7 +39,24 @@ class TeamCardsMixin(object):
return self.q(css='p.card-description').map(lambda e: e.text).results 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. Teams page/tab.
""" """
...@@ -88,7 +105,7 @@ class TeamsPage(CoursePage): ...@@ -88,7 +105,7 @@ class TeamsPage(CoursePage):
# Click to "My Team" and verify that it contains the expected number of teams. # Click to "My Team" and verify that it contains the expected number of teams.
self.q(css=MY_TEAMS_BUTTON_CSS).click() self.q(css=MY_TEAMS_BUTTON_CSS).click()
self.wait_for_ajax()
self.wait_for( self.wait_for(
lambda: len(self.q(css='.team-card')) == expected_count, lambda: len(self.q(css='.team-card')) == expected_count,
description="Expected number of teams is wrong" description="Expected number of teams is wrong"
...@@ -169,7 +186,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -169,7 +186,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
self.wait_for_ajax() 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 The paginated UI for browsing teams within a Topic on the Teams
page. page.
...@@ -207,6 +224,11 @@ class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): ...@@ -207,6 +224,11 @@ class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
lambda e: e.is_selected() lambda e: e.is_selected()
).results[0].text.strip() ).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): def click_create_team_link(self):
""" Click on create team link.""" """ Click on create team link."""
query = self.q(css=CREATE_TEAM_LINK_CSS) query = self.q(css=CREATE_TEAM_LINK_CSS)
...@@ -278,9 +300,9 @@ class SearchTeamsPage(BaseTeamsPage): ...@@ -278,9 +300,9 @@ class SearchTeamsPage(BaseTeamsPage):
self.url_path = "teams/#topics/{topic_id}/search".format(topic_id=self.topic['id']) 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): def __init__(self, browser, course_id, topic):
""" """
...@@ -289,7 +311,7 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -289,7 +311,7 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
representation of a topic following the same convention as a representation of a topic following the same convention as a
course module's topic. course module's topic.
""" """
super(CreateOrEditTeamPage, self).__init__(browser, course_id) super(TeamManagementPage, self).__init__(browser, course_id)
self.topic = topic self.topic = topic
self.url_path = "teams/#topics/{topic_id}/create-team".format(topic_id=self.topic['id']) self.url_path = "teams/#topics/{topic_id}/create-team".format(topic_id=self.topic['id'])
...@@ -310,11 +332,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -310,11 +332,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
return self.q(css='.page-header .page-description')[0].text return self.q(css='.page-header .page-description')[0].text
@property @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): def validation_message_text(self):
"""Get the error message text""" """Get the error message text"""
return self.q(css='.create-team.wrapper-msg .copy')[0].text return self.q(css='.create-team.wrapper-msg .copy')[0].text
...@@ -329,8 +346,13 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -329,8 +346,13 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin):
self.q(css='.create-team .action-cancel').first.click() self.q(css='.create-team .action-cancel').first.click()
self.wait_for_ajax() self.wait_for_ajax()
@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 The page for a specific Team within the Teams tab
""" """
...@@ -479,11 +501,6 @@ class TeamPage(CoursePage, PaginatedUIMixin): ...@@ -479,11 +501,6 @@ class TeamPage(CoursePage, PaginatedUIMixin):
""" Returns True if New Post button is present else False """ """ Returns True if New Post button is present else False """
return self.q(css='.discussion-module .new-post-btn').present 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 @property
def edit_team_button_present(self): def edit_team_button_present(self):
""" Returns True if Edit Team button is present else False """ """ Returns True if Edit Team button is present else False """
......
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from selenium.webdriver.common.keys import Keys 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 from selenium.webdriver.support.ui import Select
......
...@@ -6,7 +6,9 @@ from bok_choy.page_object import PageObject ...@@ -6,7 +6,9 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise, EmptyPromise from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL 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): class ContainerPage(PageObject):
......
...@@ -9,7 +9,8 @@ import os ...@@ -9,7 +9,8 @@ import os
import re import re
import requests import requests
from .utils import click_css from ..common.utils import click_css
from .library import LibraryPage from .library import LibraryPage
from .course_page import CoursePage from .course_page import CoursePage
from . import BASE_URL from . import BASE_URL
......
...@@ -9,7 +9,9 @@ from .component_editor import ComponentEditorView ...@@ -9,7 +9,9 @@ from .component_editor import ComponentEditorView
from .container import XBlockWrapper from .container import XBlockWrapper
from ...pages.studio.users import UsersPageMixin from ...pages.studio.users import UsersPageMixin
from ...pages.studio.pagination import PaginatedMixin 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 from . import BASE_URL
......
...@@ -9,9 +9,11 @@ from bok_choy.promise import EmptyPromise ...@@ -9,9 +9,11 @@ from bok_choy.promise import EmptyPromise
from selenium.webdriver.support.ui import Select from selenium.webdriver.support.ui import Select
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from ..common.utils import click_css, confirm_prompt
from .course_page import CoursePage from .course_page import CoursePage
from .container import ContainerPage 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): class CourseOutlineItem(object):
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
Course Group Configurations page. Course Group Configurations page.
""" """
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from ..common.utils import confirm_prompt
from .course_page import CoursePage from .course_page import CoursePage
from .utils import confirm_prompt
class GroupConfigurationsPage(CoursePage): class GroupConfigurationsPage(CoursePage):
......
...@@ -4,8 +4,8 @@ Course Textbooks page. ...@@ -4,8 +4,8 @@ Course Textbooks page.
import requests import requests
from path import Path as path from path import Path as path
from ..common.utils import click_css
from .course_page import CoursePage from .course_page import CoursePage
from .utils import click_css
class TextbooksPage(CoursePage): class TextbooksPage(CoursePage):
......
...@@ -3,52 +3,9 @@ Utility methods useful for Studio page tests. ...@@ -3,52 +3,9 @@ Utility methods useful for Studio page tests.
""" """
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from bok_choy.promise import EmptyPromise
from bok_choy.javascript import js_defined from bok_choy.javascript import js_defined
from ...tests.helpers import disable_animations from ..common.utils import click_css, wait_for_notification
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()
@js_defined('window.jQuery') @js_defined('window.jQuery')
...@@ -177,18 +134,6 @@ def get_codemirror_value(page, index=0, find_prefix="$"): ...@@ -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): def set_input_value(page, css, value):
""" """
Sets the text field with the given label (display name) to the specified 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 ...@@ -8,8 +8,8 @@ from bok_choy.promise import EmptyPromise, Promise
from bok_choy.javascript import wait_for_js, js_defined from bok_choy.javascript import wait_for_js, js_defined
from ....tests.helpers import YouTubeStubConfig from ....tests.helpers import YouTubeStubConfig
from ...lms.video.video import VideoPage from ...lms.video.video import VideoPage
from ...common.utils import wait_for_notification
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from ..utils import wait_for_notification
CLASS_SELECTORS = { CLASS_SELECTORS = {
......
...@@ -7,7 +7,8 @@ from nose.plugins.attrib import attr ...@@ -7,7 +7,8 @@ from nose.plugins.attrib import attr
from ..helpers import UniqueCourseTest, remove_file from ..helpers import UniqueCourseTest, remove_file
from ...pages.common.logout import LogoutPage 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.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage from ...pages.studio.container import ContainerPage
......
...@@ -7,7 +7,8 @@ import json ...@@ -7,7 +7,8 @@ import json
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from ..helpers import generate_course_key from ..helpers import generate_course_key
from ...pages.common.logout import LogoutPage 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.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.container import ContainerPage from ...pages.studio.container import ContainerPage
......
...@@ -22,7 +22,15 @@ from ...pages.lms.auto_auth import AutoAuthPage ...@@ -22,7 +22,15 @@ from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.course_info import CourseInfoPage from ...pages.lms.course_info import CourseInfoPage
from ...pages.lms.learner_profile import LearnerProfilePage from ...pages.lms.learner_profile import LearnerProfilePage
from ...pages.lms.tab_nav import TabNavPage from ...pages.lms.tab_nav import TabNavPage
from ...pages.lms.teams import TeamsPage, MyTeamsPage, BrowseTopicsPage, BrowseTeamsPage, CreateOrEditTeamPage, TeamPage from ...pages.lms.teams import (
TeamsPage,
MyTeamsPage,
BrowseTopicsPage,
BrowseTeamsPage,
TeamManagementPage,
TeamPage
)
from ...pages.common.utils import confirm_prompt
TOPICS_PER_PAGE = 12 TOPICS_PER_PAGE = 12
...@@ -384,7 +392,7 @@ class BrowseTopicsTest(TeamsTabBase): ...@@ -384,7 +392,7 @@ class BrowseTopicsTest(TeamsTabBase):
browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic) browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, topic)
self.assertTrue(browse_teams_page.is_browser_on_page()) self.assertTrue(browse_teams_page.is_browser_on_page())
browse_teams_page.click_create_team_link() browse_teams_page.click_create_team_link()
create_team_page = CreateOrEditTeamPage(self.browser, self.course_id, topic) create_team_page = TeamManagementPage(self.browser, self.course_id, topic)
create_team_page.value_for_text_field(field_id='name', value='Team Name', press_enter=False) create_team_page.value_for_text_field(field_id='name', value='Team Name', press_enter=False)
create_team_page.value_for_textarea_field( create_team_page.value_for_textarea_field(
field_id='description', field_id='description',
...@@ -393,8 +401,9 @@ class BrowseTopicsTest(TeamsTabBase): ...@@ -393,8 +401,9 @@ class BrowseTopicsTest(TeamsTabBase):
create_team_page.submit_form() create_team_page.submit_form()
team_page = TeamPage(self.browser, self.course_id) team_page = TeamPage(self.browser, self.course_id)
self.assertTrue(team_page.is_browser_on_page) self.assertTrue(team_page.is_browser_on_page)
team_page.click_all_topics_breadcrumb() team_page.click_all_topics()
self.assertTrue(self.topics_page.is_browser_on_page()) self.assertTrue(self.topics_page.is_browser_on_page())
self.topics_page.wait_for_ajax()
self.assertEqual(topic_name, self.topics_page.topic_names[0]) self.assertEqual(topic_name, self.topics_page.topic_names[0])
def test_list_topics(self): def test_list_topics(self):
...@@ -834,21 +843,25 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase): ...@@ -834,21 +843,25 @@ class BrowseTeamsWithinTopicTest(TeamsTabBase):
@attr('shard_5') @attr('shard_5')
class TeamFormActions(TeamsTabBase): class TeamFormActions(TeamsTabBase):
""" """
Base class for create & edit team. Base class for create, edit, and delete team.
""" """
TEAM_DESCRIPTION = 'The Avengers are a fictional team of superheroes.' TEAM_DESCRIPTION = 'The Avengers are a fictional team of superheroes.'
topic = {'name': 'Example Topic', 'id': 'example_topic', 'description': 'Description'} topic = {'name': 'Example Topic', 'id': 'example_topic', 'description': 'Description'}
TEAMS_NAME = 'Avengers' TEAMS_NAME = 'Avengers'
def setUp(self):
super(TeamFormActions, self).setUp()
self.team_management_page = TeamManagementPage(self.browser, self.course_id, self.topic)
def verify_page_header(self, title, description, breadcrumbs): def verify_page_header(self, title, description, breadcrumbs):
""" """
Verify that the page header correctly reflects the Verify that the page header correctly reflects the
create team header, description and breadcrumb. create team header, description and breadcrumb.
""" """
self.assertEqual(self.create_or_edit_team_page.header_page_name, title) self.assertEqual(self.team_management_page.header_page_name, title)
self.assertEqual(self.create_or_edit_team_page.header_page_description, description) self.assertEqual(self.team_management_page.header_page_description, description)
self.assertEqual(self.create_or_edit_team_page.header_page_breadcrumbs, breadcrumbs) self.assertEqual(self.team_management_page.header_page_breadcrumbs, breadcrumbs)
def verify_and_navigate_to_create_team_page(self): def verify_and_navigate_to_create_team_page(self):
"""Navigates to the create team page and verifies.""" """Navigates to the create team page and verifies."""
...@@ -868,7 +881,7 @@ class TeamFormActions(TeamsTabBase): ...@@ -868,7 +881,7 @@ class TeamFormActions(TeamsTabBase):
self.team_page.click_edit_team_button() self.team_page.click_edit_team_button()
self.create_or_edit_team_page.wait_for_page() self.team_management_page.wait_for_page()
# Edit page header. # Edit page header.
self.verify_page_header( self.verify_page_header(
...@@ -891,33 +904,37 @@ class TeamFormActions(TeamsTabBase): ...@@ -891,33 +904,37 @@ class TeamFormActions(TeamsTabBase):
def fill_create_or_edit_form(self): def fill_create_or_edit_form(self):
"""Fill the create/edit team form fields with appropriate values.""" """Fill the create/edit team form fields with appropriate values."""
self.create_or_edit_team_page.value_for_text_field(field_id='name', value=self.TEAMS_NAME, press_enter=False) self.team_management_page.value_for_text_field(
self.create_or_edit_team_page.value_for_textarea_field( field_id='name',
value=self.TEAMS_NAME,
press_enter=False
)
self.team_management_page.value_for_textarea_field(
field_id='description', field_id='description',
value=self.TEAM_DESCRIPTION value=self.TEAM_DESCRIPTION
) )
self.create_or_edit_team_page.value_for_dropdown_field(field_id='language', value='English') self.team_management_page.value_for_dropdown_field(field_id='language', value='English')
self.create_or_edit_team_page.value_for_dropdown_field(field_id='country', value='Pakistan') self.team_management_page.value_for_dropdown_field(field_id='country', value='Pakistan')
def verify_all_fields_exist(self): def verify_all_fields_exist(self):
""" """
Verify the fields for create/edit page. Verify the fields for create/edit page.
""" """
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.message_for_field('name'), self.team_management_page.message_for_field('name'),
'A name that identifies your team (maximum 255 characters).' 'A name that identifies your team (maximum 255 characters).'
) )
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.message_for_textarea_field('description'), self.team_management_page.message_for_textarea_field('description'),
'A short description of the team to help other learners understand ' 'A short description of the team to help other learners understand '
'the goals or direction of the team (maximum 300 characters).' 'the goals or direction of the team (maximum 300 characters).'
) )
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.message_for_field('country'), self.team_management_page.message_for_field('country'),
'The country that team members primarily identify with.' 'The country that team members primarily identify with.'
) )
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.message_for_field('language'), self.team_management_page.message_for_field('language'),
'The language that team members primarily use to communicate with each other.' 'The language that team members primarily use to communicate with each other.'
) )
...@@ -932,7 +949,6 @@ class CreateTeamTest(TeamFormActions): ...@@ -932,7 +949,6 @@ class CreateTeamTest(TeamFormActions):
super(CreateTeamTest, self).setUp() super(CreateTeamTest, self).setUp()
self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}) self.set_team_configuration({'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]})
self.create_or_edit_team_page = CreateOrEditTeamPage(self.browser, self.course_id, self.topic)
self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic) self.browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
self.browse_teams_page.visit() self.browse_teams_page.visit()
...@@ -960,14 +976,14 @@ class CreateTeamTest(TeamFormActions): ...@@ -960,14 +976,14 @@ class CreateTeamTest(TeamFormActions):
Then I should see the error message and highlighted fields. Then I should see the error message and highlighted fields.
""" """
self.verify_and_navigate_to_create_team_page() self.verify_and_navigate_to_create_team_page()
self.create_or_edit_team_page.submit_form() self.team_management_page.submit_form()
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.validation_message_text, self.team_management_page.validation_message_text,
'Check the highlighted fields below and try again.' 'Check the highlighted fields below and try again.'
) )
self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='name')) self.assertTrue(self.team_management_page.error_for_field(field_id='name'))
self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='description')) self.assertTrue(self.team_management_page.error_for_field(field_id='description'))
def test_user_can_see_error_message_for_incorrect_data(self): def test_user_can_see_error_message_for_incorrect_data(self):
""" """
...@@ -982,7 +998,7 @@ class CreateTeamTest(TeamFormActions): ...@@ -982,7 +998,7 @@ class CreateTeamTest(TeamFormActions):
self.verify_and_navigate_to_create_team_page() self.verify_and_navigate_to_create_team_page()
# Fill the name field with >255 characters to see validation message. # Fill the name field with >255 characters to see validation message.
self.create_or_edit_team_page.value_for_text_field( self.team_management_page.value_for_text_field(
field_id='name', field_id='name',
value='EdX is a massive open online course (MOOC) provider and online learning platform. ' value='EdX is a massive open online course (MOOC) provider and online learning platform. '
'It hosts online university-level courses in a wide range of disciplines to a worldwide ' 'It hosts online university-level courses in a wide range of disciplines to a worldwide '
...@@ -994,13 +1010,13 @@ class CreateTeamTest(TeamFormActions): ...@@ -994,13 +1010,13 @@ class CreateTeamTest(TeamFormActions):
'edX has more than 4 million users taking more than 500 courses online.', 'edX has more than 4 million users taking more than 500 courses online.',
press_enter=False press_enter=False
) )
self.create_or_edit_team_page.submit_form() self.team_management_page.submit_form()
self.assertEqual( self.assertEqual(
self.create_or_edit_team_page.validation_message_text, self.team_management_page.validation_message_text,
'Check the highlighted fields below and try again.' 'Check the highlighted fields below and try again.'
) )
self.assertTrue(self.create_or_edit_team_page.error_for_field(field_id='name')) self.assertTrue(self.team_management_page.error_for_field(field_id='name'))
def test_user_can_create_new_team_successfully(self): def test_user_can_create_new_team_successfully(self):
""" """
...@@ -1040,7 +1056,7 @@ class CreateTeamTest(TeamFormActions): ...@@ -1040,7 +1056,7 @@ class CreateTeamTest(TeamFormActions):
} }
] ]
with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events): with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events):
self.create_or_edit_team_page.submit_form() self.team_management_page.submit_form()
# Verify that the page is shown for the new team # Verify that the page is shown for the new team
team_page = TeamPage(self.browser, self.course_id) team_page = TeamPage(self.browser, self.course_id)
...@@ -1072,7 +1088,7 @@ class CreateTeamTest(TeamFormActions): ...@@ -1072,7 +1088,7 @@ class CreateTeamTest(TeamFormActions):
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total')) self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
self.verify_and_navigate_to_create_team_page() self.verify_and_navigate_to_create_team_page()
self.create_or_edit_team_page.cancel_team() self.team_management_page.cancel_team()
self.assertTrue(self.browse_teams_page.is_browser_on_page()) self.assertTrue(self.browse_teams_page.is_browser_on_page())
self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total')) self.assertTrue(self.browse_teams_page.get_pagination_header_text().startswith('Showing 0 out of 0 total'))
...@@ -1102,6 +1118,96 @@ class CreateTeamTest(TeamFormActions): ...@@ -1102,6 +1118,96 @@ class CreateTeamTest(TeamFormActions):
@ddt.ddt @ddt.ddt
class DeleteTeamTest(TeamFormActions):
"""
Tests for deleting teams.
"""
def setUp(self):
super(DeleteTeamTest, self).setUp()
self.set_team_configuration(
{'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]},
global_staff=True
)
self.team = self.create_teams(self.topic, num_teams=1)[0]
self.team_page = TeamPage(self.browser, self.course_id, team=self.team)
self.team_page.visit()
def test_cancel_delete(self):
"""
Scenario: The user should be able to cancel the Delete Team dialog
Given I am staff user for a course with a team
When I visit the Team profile page
Then I should see the Edit Team button
And When I click edit team button
Then I should see the Delete Team button
When I click the delete team button
And I cancel the prompt
And I refresh the page
Then I should still see the team
"""
self.delete_team(cancel=True)
self.assertTrue(self.team_management_page.is_browser_on_page())
self.browser.refresh()
self.team_management_page.wait_for_page()
self.assertEqual(
' '.join(('All Topics', self.topic['name'], self.team['name'])),
self.team_management_page.header_page_breadcrumbs
)
@ddt.data('Moderator', 'Community TA', 'Administrator', None)
def test_delete_team(self, role):
"""
Scenario: The user should be able to see and navigate to the delete team page.
Given I am staff user for a course with a team
When I visit the Team profile page
Then I should see the Edit Team button
And When I click edit team button
Then I should see the Delete Team button
When I click the delete team button
And I confirm the prompt
Then I should see the browse teams page
And the team should not be present
"""
# If role is None, remain logged in as global staff
if role is not None:
AutoAuthPage(
self.browser,
course_id=self.course_id,
staff=False,
roles=role
).visit()
self.team_page.visit()
self.delete_team(require_notification=False)
browse_teams_page = BrowseTeamsPage(self.browser, self.course_id, self.topic)
self.assertTrue(browse_teams_page.is_browser_on_page())
self.assertNotIn(self.team['name'], browse_teams_page.team_names)
def delete_team(self, **kwargs):
"""Delete a team. Passes `kwargs` to `confirm_prompt`."""
self.team_page.click_edit_team_button()
self.team_management_page.wait_for_page()
self.team_management_page.delete_team_button.click()
confirm_prompt(self.team_management_page, **kwargs)
def test_delete_team_updates_topics(self):
"""
Scenario: Deleting a team should update the team count on the topics page
Given I am staff user for a course with a team
And I delete a team
When I navigate to the browse topics page
Then the team count for the deletd team's topic should be updated
"""
self.delete_team(require_notification=False)
BrowseTeamsPage(self.browser, self.course_id, self.topic).click_all_topics()
topics_page = BrowseTopicsPage(self.browser, self.course_id)
self.assertTrue(topics_page.is_browser_on_page())
self.teams_page.verify_topic_team_count(0)
@ddt.ddt
class EditTeamTest(TeamFormActions): class EditTeamTest(TeamFormActions):
""" """
Tests for editing the team. Tests for editing the team.
...@@ -1114,7 +1220,6 @@ class EditTeamTest(TeamFormActions): ...@@ -1114,7 +1220,6 @@ class EditTeamTest(TeamFormActions):
{'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]}, {'course_id': self.course_id, 'max_team_size': 10, 'topics': [self.topic]},
global_staff=True global_staff=True
) )
self.create_or_edit_team_page = CreateOrEditTeamPage(self.browser, self.course_id, self.topic)
self.team = self.create_teams(self.topic, num_teams=1)[0] self.team = self.create_teams(self.topic, num_teams=1)[0]
self.team_page = TeamPage(self.browser, self.course_id, team=self.team) self.team_page = TeamPage(self.browser, self.course_id, team=self.team)
...@@ -1204,7 +1309,7 @@ class EditTeamTest(TeamFormActions): ...@@ -1204,7 +1309,7 @@ class EditTeamTest(TeamFormActions):
}, },
] ]
with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events): with self.assert_events_match_during(event_filter=self.only_team_events, expected_events=expected_events):
self.create_or_edit_team_page.submit_form() self.team_management_page.submit_form()
self.team_page.wait_for_page() self.team_page.wait_for_page()
...@@ -1237,7 +1342,7 @@ class EditTeamTest(TeamFormActions): ...@@ -1237,7 +1342,7 @@ class EditTeamTest(TeamFormActions):
self.verify_and_navigate_to_edit_team_page() self.verify_and_navigate_to_edit_team_page()
self.fill_create_or_edit_form() self.fill_create_or_edit_form()
self.create_or_edit_team_page.cancel_team() self.team_management_page.cancel_team()
self.team_page.wait_for_page() self.team_page.wait_for_page()
...@@ -1289,7 +1394,7 @@ class EditTeamTest(TeamFormActions): ...@@ -1289,7 +1394,7 @@ class EditTeamTest(TeamFormActions):
self.verify_and_navigate_to_edit_team_page() self.verify_and_navigate_to_edit_team_page()
self.fill_create_or_edit_form() self.fill_create_or_edit_form()
self.create_or_edit_team_page.submit_form() self.team_management_page.submit_form()
self.team_page.wait_for_page() self.team_page.wait_for_page()
......
...@@ -4,9 +4,10 @@ import logging ...@@ -4,9 +4,10 @@ import logging
from requests import ConnectionError from requests import ConnectionError
from django.conf import settings 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.dispatch import receiver
from django.utils import translation from django.utils import translation
from functools import wraps
from search.search_engine_base import SearchEngine from search.search_engine_base import SearchEngine
...@@ -14,6 +15,19 @@ from .errors import ElasticSearchConnectionError ...@@ -14,6 +15,19 @@ from .errors import ElasticSearchConnectionError
from .serializers import CourseTeamSerializer, CourseTeam 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): class CourseTeamIndexer(object):
""" """
This is the index object for searching and storing CourseTeam model instances. This is the index object for searching and storing CourseTeam model instances.
...@@ -70,16 +84,25 @@ class CourseTeamIndexer(object): ...@@ -70,16 +84,25 @@ class CourseTeamIndexer(object):
return self.course_team.language return self.course_team.language
@classmethod @classmethod
@if_search_enabled
def index(cls, course_team): def index(cls, course_team):
""" """
Update index with course_team object (if feature is enabled). Update index with course_team object (if feature is enabled).
""" """
if cls.search_is_enabled(): search_engine = cls.engine()
search_engine = cls.engine() serialized_course_team = CourseTeamIndexer(course_team).data()
serialized_course_team = CourseTeamIndexer(course_team).data() search_engine.index(cls.DOCUMENT_TYPE_NAME, [serialized_course_team])
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 @classmethod
@if_search_enabled
def engine(cls): def engine(cls):
""" """
Return course team search engine (if feature is enabled). Return course team search engine (if feature is enabled).
...@@ -108,3 +131,11 @@ def course_team_post_save_callback(**kwargs): ...@@ -108,3 +131,11 @@ def course_team_post_save_callback(**kwargs):
CourseTeamIndexer.index(kwargs['instance']) CourseTeamIndexer.index(kwargs['instance'])
except ElasticSearchConnectionError: except ElasticSearchConnectionError:
pass 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 @@ ...@@ -25,7 +25,7 @@
}, },
onUpdate: function(event) { onUpdate: function(event) {
if (event.action === 'create') { if (_.contains(['create', 'delete'], event.action)) {
this.isStale = true; this.isStale = true;
} }
}, },
......
...@@ -35,5 +35,14 @@ define(['backbone', 'URI', 'underscore', 'common/js/spec_helpers/ajax_helpers', ...@@ -35,5 +35,14 @@ define(['backbone', 'URI', 'underscore', 'common/js/spec_helpers/ajax_helpers',
topicCollection.course_id = 'my+course+id'; topicCollection.course_id = 'my+course+id';
testRequestParam(this, '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([ ...@@ -61,7 +61,7 @@ define([
it('does not interfere with anchor links to #content', function () { it('does not interfere with anchor links to #content', function () {
var teamsTabView = createTeamsTabView(); var teamsTabView = createTeamsTabView();
teamsTabView.router.navigate('#content', {trigger: true}); 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 () { it('displays and focuses an error message when trying to navigate to a nonexistent page', function () {
......
...@@ -29,13 +29,15 @@ define([ ...@@ -29,13 +29,15 @@ define([
var createMockTeamData = function (startIndex, stopIndex) { var createMockTeamData = function (startIndex, stopIndex) {
return _.map(_.range(startIndex, stopIndex + 1), function (i) { return _.map(_.range(startIndex, stopIndex + 1), function (i) {
var id = "id" + i;
return { return {
name: "team " + i, name: "team " + i,
id: "id " + i, id: id,
language: testLanguages[i%4][0], language: testLanguages[i%4][0],
country: testCountries[i%4][0], country: testCountries[i%4][0],
membership: [], membership: [],
last_activity_at: '' last_activity_at: '',
url: 'api/team/v0/teams/' + id
}; };
}); });
}; };
...@@ -124,6 +126,10 @@ define([ ...@@ -124,6 +126,10 @@ define([
}); });
}; };
var triggerTeamEvent = function (action) {
teamEvents.trigger('teams:update', {action: action});
};
createMockPostResponse = function(options) { createMockPostResponse = function(options) {
return _.extend( return _.extend(
{ {
...@@ -327,6 +333,7 @@ define([ ...@@ -327,6 +333,7 @@ define([
createMockThreadResponse: createMockThreadResponse, createMockThreadResponse: createMockThreadResponse,
createMockTopicData: createMockTopicData, createMockTopicData: createMockTopicData,
createMockTopicCollection: createMockTopicCollection, createMockTopicCollection: createMockTopicCollection,
triggerTeamEvent: triggerTeamEvent,
verifyCards: verifyCards verifyCards: verifyCards
}; };
}); });
...@@ -5,8 +5,9 @@ ...@@ -5,8 +5,9 @@
'underscore', 'underscore',
'gettext', 'gettext',
'teams/js/views/team_utils', 'teams/js/views/team_utils',
'common/js/components/utils/view_utils',
'text!teams/templates/instructor-tools.underscore'], 'text!teams/templates/instructor-tools.underscore'],
function (Backbone, _, gettext, TeamUtils, instructorToolbarTemplate) { function (Backbone, _, gettext, TeamUtils, ViewUtils, instructorToolbarTemplate) {
return Backbone.View.extend({ return Backbone.View.extend({
events: { events: {
...@@ -16,6 +17,8 @@ ...@@ -16,6 +17,8 @@
initialize: function(options) { initialize: function(options) {
this.template = _.template(instructorToolbarTemplate); this.template = _.template(instructorToolbarTemplate);
this.team = options.team;
this.teamEvents = options.teamEvents;
}, },
render: function() { render: function() {
...@@ -25,14 +28,46 @@ ...@@ -25,14 +28,46 @@
deleteTeam: function (event) { deleteTeam: function (event) {
event.preventDefault(); event.preventDefault();
alert("You clicked the button!"); ViewUtils.confirmThenRunOperation(
//placeholder; will route to delete team page 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) { editMembership: function (event) {
event.preventDefault(); event.preventDefault();
alert("You clicked the button!"); alert("You clicked the button!");
//placeholder; will route to remove team member page //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 @@ ...@@ -40,9 +40,16 @@
); );
}, },
showMessage: function (message) { hideMessage: function () {
var messageElement = $('.teams-content .wrapper-msg'); $('#teams-message').addClass('.is-hidden');
messageElement.removeClass('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); $('.teams-content .msg-content .copy').text(message);
messageElement.focus(); messageElement.focus();
}, },
...@@ -50,12 +57,14 @@ ...@@ -50,12 +57,14 @@
/** /**
* Parse `data` and show user message. If parsing fails than show `genericErrorMessage` * Parse `data` and show user message. If parsing fails than show `genericErrorMessage`
*/ */
parseAndShowMessage: function (data, genericErrorMessage) { parseAndShowMessage: function (data, genericErrorMessage, type) {
try { try {
var errors = JSON.parse(data.responseText); 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) { } catch (error) {
this.showMessage(genericErrorMessage); this.showMessage(genericErrorMessage, type);
} }
} }
}; };
......
...@@ -20,8 +20,8 @@ ...@@ -20,8 +20,8 @@
'teams/js/views/topic_teams', 'teams/js/views/topic_teams',
'teams/js/views/edit_team', 'teams/js/views/edit_team',
'teams/js/views/team_profile_header_actions', 'teams/js/views/team_profile_header_actions',
'teams/js/views/team_utils',
'teams/js/views/instructor_tools', 'teams/js/views/instructor_tools',
'teams/js/views/team_utils',
'text!teams/templates/teams_tab.underscore'], 'text!teams/templates/teams_tab.underscore'],
function (Backbone, _, gettext, SearchFieldView, HeaderView, HeaderModel, function (Backbone, _, gettext, SearchFieldView, HeaderView, HeaderModel,
TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics, TopicModel, TopicCollection, TeamModel, TeamCollection, TeamMembershipCollection, TeamAnalytics,
...@@ -45,8 +45,9 @@ ...@@ -45,8 +45,9 @@
this.$el.html(_.template(teamsTemplate)); this.$el.html(_.template(teamsTemplate));
this.$('p.error').hide(); this.$('p.error').hide();
this.header.setElement(this.$('.teams-header')).render(); this.header.setElement(this.$('.teams-header')).render();
if (this.instructorTools) if (this.instructorTools) {
this.instructorTools.setElement(this.$('.teams-instructor-tools-bar')).render(); this.instructorTools.setElement(this.$('.teams-instructor-tools-bar')).render();
}
this.main.setElement(this.$('.page-content')).render(); this.main.setElement(this.$('.page-content')).render();
return this; return this;
} }
...@@ -173,7 +174,7 @@ ...@@ -173,7 +174,7 @@
render: function() { render: function() {
this.mainView.setElement(this.$el).render(); this.mainView.setElement(this.$el).render();
this.hideWarning(); TeamUtils.hideMessage();
return this; return this;
}, },
...@@ -253,7 +254,10 @@ ...@@ -253,7 +254,10 @@
topic: topic, topic: topic,
model: team model: team
}); });
var instructorToolsView = new InstructorToolsView(); var instructorToolsView = new InstructorToolsView({
team: team,
teamEvents: self.teamEvents
});
editViewWithHeader = self.createViewWithHeader({ editViewWithHeader = self.createViewWithHeader({
title: gettext("Edit Team"), 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."),
...@@ -570,18 +574,7 @@ ...@@ -570,18 +574,7 @@
*/ */
notFoundError: function (message) { notFoundError: function (message) {
this.router.navigate('my-teams', {trigger: true}); this.router.navigate('my-teams', {trigger: true});
this.showWarning(message); TeamUtils.showMessage(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);
}, },
/** /**
......
...@@ -3,9 +3,10 @@ ...@@ -3,9 +3,10 @@
define([ define([
'gettext', 'gettext',
'teams/js/views/topic_card', 'teams/js/views/topic_card',
'teams/js/views/team_utils',
'common/js/components/views/paging_header', 'common/js/components/views/paging_header',
'common/js/components/views/paginated_view' 'common/js/components/views/paginated_view'
], function (gettext, TopicCardView, PagingHeader, PaginatedView) { ], function (gettext, TopicCardView, TeamUtils, PagingHeader, PaginatedView) {
var TopicsView = PaginatedView.extend({ var TopicsView = PaginatedView.extend({
type: 'topics', type: 'topics',
...@@ -35,6 +36,7 @@ ...@@ -35,6 +36,7 @@
this.collection.refresh() this.collection.refresh()
.done(function() { .done(function() {
PaginatedView.prototype.render.call(self); PaginatedView.prototype.render.call(self);
TeamUtils.hideMessage();
}); });
return this; 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">
<div class="msg-content"> <div class="msg-content">
<div class="copy"> <div class="copy">
......
...@@ -21,6 +21,7 @@ from util.testing import EventTestMixin ...@@ -21,6 +21,7 @@ from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from .factories import CourseTeamFactory, LAST_ACTIVITY_AT from .factories import CourseTeamFactory, LAST_ACTIVITY_AT
from ..models import CourseTeamMembership
from ..search_indexes import CourseTeamIndexer, CourseTeam, course_team_post_save_callback from ..search_indexes import CourseTeamIndexer, CourseTeam, course_team_post_save_callback
from django_comment_common.models import Role, FORUM_ROLE_COMMUNITY_TA from django_comment_common.models import Role, FORUM_ROLE_COMMUNITY_TA
...@@ -339,6 +340,10 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase): ...@@ -339,6 +340,10 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
"""Gets detailed team information for team_id. Verifies expected_status.""" """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) 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): def patch_team_detail(self, team_id, expected_status, data=None, **kwargs):
"""Patches the team with team_id using data. Verifies expected_status.""" """Patches the team with team_id using data. Verifies expected_status."""
return self.make_call( return self.make_call(
...@@ -437,8 +442,9 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase): ...@@ -437,8 +442,9 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
def verify_names(self, data, status, names=None, **kwargs): 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.""" """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) teams = self.get_teams_list(data=data, expected_status=status, **kwargs)
if names: if names is not None and 200 <= status < 300:
self.assertEqual(names, [team['name'] for team in teams['results']]) results = teams['results']
self.assertEqual(names, [team['name'] for team in results])
def test_filter_invalid_course_id(self): def test_filter_invalid_course_id(self):
self.verify_names({'course_id': 'no_such_course'}, 400) self.verify_names({'course_id': 'no_such_course'}, 400)
...@@ -562,6 +568,26 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase): ...@@ -562,6 +568,26 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase):
user='student_enrolled_public_profile' 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 @ddt.ddt
class TestCreateTeamAPI(EventTestMixin, TeamAPITestCase): class TestCreateTeamAPI(EventTestMixin, TeamAPITestCase):
...@@ -777,6 +803,32 @@ class TestDetailTeamAPI(TeamAPITestCase): ...@@ -777,6 +803,32 @@ class TestDetailTeamAPI(TeamAPITestCase):
@ddt.ddt @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): class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase):
"""Test cases for the team update endpoint.""" """Test cases for the team update endpoint."""
......
"""HTTP endpoints for the Teams API.""" """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.http import Http404
from django.conf import settings from django.conf import settings
from django.core.paginator import Paginator from django.core.paginator import Paginator
...@@ -61,6 +63,8 @@ TEAM_MEMBERSHIPS_PER_PAGE = 2 ...@@ -61,6 +63,8 @@ TEAM_MEMBERSHIPS_PER_PAGE = 2
TOPICS_PER_PAGE = 12 TOPICS_PER_PAGE = 12
MAXIMUM_SEARCH_SIZE = 100000 MAXIMUM_SEARCH_SIZE = 100000
log = logging.getLogger(__name__)
@receiver(post_save, sender=CourseTeam) @receiver(post_save, sender=CourseTeam)
def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unused-argument
...@@ -504,7 +508,7 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -504,7 +508,7 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
""" """
**Use Cases** **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. only through merge patch.
**Example Requests**: **Example Requests**:
...@@ -513,6 +517,8 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -513,6 +517,8 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json" PATCH /api/team/v0/teams/{team_id} "application/merge-patch+json"
DELETE /api/team/v0/teams/{team_id}
**Query Parameters for GET** **Query Parameters for GET**
* expand: Comma separated list of types for which to return * expand: Comma separated list of types for which to return
...@@ -577,6 +583,20 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -577,6 +583,20 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
If the update could not be completed due to validation errors, this If the update could not be completed due to validation errors, this
method returns a 400 error with all error messages in the method returns a 400 error with all error messages in the
"field_errors" field of the returned JSON. "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) authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,) permission_classes = (permissions.IsAuthenticated, IsStaffOrPrivilegedOrReadOnly, IsEnrolledOrIsStaff,)
...@@ -588,6 +608,15 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -588,6 +608,15 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
"""Returns the queryset used to access the given team.""" """Returns the queryset used to access the given team."""
return CourseTeam.objects.all() 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): class TopicListView(GenericAPIView):
""" """
......
...@@ -702,6 +702,7 @@ ...@@ -702,6 +702,7 @@
'lms/include/teams/js/spec/collections/topic_collection_spec.js', '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/teams_tab_factory_spec.js',
'lms/include/teams/js/spec/views/edit_team_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/my_teams_spec.js',
'lms/include/teams/js/spec/views/team_card_spec.js', 'lms/include/teams/js/spec/views/team_card_spec.js',
'lms/include/teams/js/spec/views/team_discussion_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