Commit 17e64535 by Eric Fischer

Merge pull request #9520 from edx/efischer/teams-instructor-toolbar

Teams Instructor Tools bar
parents 55b2ea37 65cbab73
"""
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
...@@ -43,7 +43,24 @@ class TeamCardsMixin(object): ...@@ -43,7 +43,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.
""" """
...@@ -92,7 +109,7 @@ class TeamsPage(CoursePage): ...@@ -92,7 +109,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"
...@@ -173,7 +190,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -173,7 +190,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.
...@@ -211,6 +228,11 @@ class BaseTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): ...@@ -211,6 +228,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)
...@@ -282,9 +304,9 @@ class SearchTeamsPage(BaseTeamsPage): ...@@ -282,9 +304,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):
""" """
...@@ -293,15 +315,13 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -293,15 +315,13 @@ 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'])
def is_browser_on_page(self): def is_browser_on_page(self):
"""Check if we're on the create team page for a particular topic.""" """Check if we're on the create team page for a particular topic."""
has_correct_url = self.url.endswith(self.url_path) return self.q(css='.team-edit-fields').present
teams_create_view_present = self.q(css='.team-edit-fields').present
return has_correct_url and teams_create_view_present
@property @property
def header_page_name(self): def header_page_name(self):
...@@ -314,11 +334,6 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -314,11 +334,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
...@@ -333,8 +348,70 @@ class CreateOrEditTeamPage(CoursePage, FieldsMixin): ...@@ -333,8 +348,70 @@ 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): def click_membership_button(self):
"""Clicks the 'edit membership' button"""
self.q(css='.action-edit-members').first.click()
self.wait_for_ajax()
@property
def membership_button_present(self):
"""Checks if the edit membership button is present"""
return self.q(css='.action-edit-members').present
class EditMembershipPage(CoursePage):
"""
Staff or discussion-privileged user page to remove troublesome or inactive
students from a team
"""
def __init__(self, browser, course_id, team):
"""
Set up `self.url_path` on instantiation, since it dynamically
reflects the current team.
"""
super(EditMembershipPage, self).__init__(browser, course_id)
self.team = team
self.url_path = "teams/#teams/{topic_id}/{team_id}/edit-team/manage-members".format(
topic_id=self.team['topic_id'], team_id=self.team['id']
)
def is_browser_on_page(self):
"""Check if we're on the team membership page for a particular team."""
self.wait_for_ajax()
if self.q(css='.edit-members').present:
return True
empty_query = self.q(css='.teams-main>.page-content>p').first
return (
len(empty_query.results) > 0 and
empty_query[0].text == "This team does not have any members."
)
@property
def team_members(self):
"""Returns the number of team members shown on the page."""
return len(self.q(css='.team-member'))
def click_first_remove(self):
"""Clicks the remove link on the first member listed."""
self.q(css='.action-remove-member').first.click()
def confirm_delete_membership_dialog(self):
"""Click 'delete' on the warning dialog."""
confirm_prompt(self, require_notification=False)
self.wait_for_ajax()
def cancel_delete_membership_dialog(self):
"""Click 'delete' on the warning dialog."""
confirm_prompt(self, cancel=True)
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
""" """
...@@ -483,11 +560,6 @@ class TeamPage(CoursePage, PaginatedUIMixin): ...@@ -483,11 +560,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
......
...@@ -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',
'underscore',
'backbone',
'common/js/spec_helpers/ajax_helpers',
'teams/js/views/edit_team_members',
'teams/js/models/team',
'teams/js/views/team_utils',
'teams/js/spec_helpers/team_spec_helpers'
], function ($, _, Backbone, AjaxHelpers, TeamEditMembershipView, TeamModel, TeamUtils, TeamSpecHelpers) {
'use strict';
describe('CreateEditTeam', function() {
var editTeamID = 'av',
DEFAULT_MEMBERSHIP = [
{
'user': {
'username': 'frodo',
'profile_image': {
'has_image': true,
'image_url_medium': '/frodo-image-url'
},
},
last_activity_at: "2015-08-21T18:53:01.145Z",
date_joined: "2014-01-01T18:53:01.145Z"
}
],
deleteTeamMemember = function (view, confirm) {
view.$('.action-remove-member').click();
// Confirm delete dialog
if (confirm) {
$('.action-primary').click();
}
else {
$('.action-secondary').click();
}
},
verifyTeamMembersView = function (view) {
expect(view.$('.team-member').length).toEqual(1);
expect(view.$('.member-profile').attr('href')).toEqual('/u/frodo');
expect(view.$('img.image-url').attr('src')).toEqual('/frodo-image-url');
expect(view.$('.member-info-container .primary').text()).toBe('frodo');
expect(view.$el.find('#last-active abbr').attr('title')).toEqual("2015-08-21T18:53:01.145Z");
expect(view.$el.find('#date-joined abbr').attr('title')).toEqual("2014-01-01T18:53:01.145Z");
},
verifyNoMembersView = function (view){
expect(view.$el.text().trim()).toBe('This team does not have any members.');
},
createTeamModelData = function (membership) {
return {
id: editTeamID,
name: 'Avengers',
description: 'Team of dumbs',
language: 'en',
country: 'US',
membership: membership,
url: '/api/team/v0/teams/' + editTeamID
};
},
createEditTeamMembersView = function (membership) {
var teamModel = new TeamModel(
createTeamModelData(membership),
{ parse: true }
);
return new TeamEditMembershipView({
teamEvents: TeamSpecHelpers.teamEvents,
el: $('.teams-content'),
model: teamModel,
context: TeamSpecHelpers.testContext
}).render();
};
beforeEach(function () {
setFixtures('<div id="page-prompt"></div><div class="teams-content"></div>');
spyOn(Backbone.history, 'navigate');
spyOn(TeamUtils, 'showMessage');
});
it('can render a message when there are no members', function () {
var view = createEditTeamMembersView([]);
verifyNoMembersView(view);
});
it('can delete a team member and update the view', function () {
var requests = AjaxHelpers.requests(this),
view = createEditTeamMembersView(DEFAULT_MEMBERSHIP);
spyOn(view.teamEvents, 'trigger');
verifyTeamMembersView(view);
deleteTeamMemember(view, true);
AjaxHelpers.expectJsonRequest(
requests,
'DELETE',
'/api/team/v0/team_membership/av,frodo?admin=true'
);
AjaxHelpers.respondWithNoContent(requests);
expect(view.teamEvents.trigger).toHaveBeenCalledWith(
'teams:update', {
action: 'leave',
team: view.model
}
);
AjaxHelpers.expectJsonRequest(requests, 'GET', view.model.get('url'));
AjaxHelpers.respondWithJson(requests, createTeamModelData([]));
verifyNoMembersView(view);
});
it('can show an error message if removing the user fails', function () {
var requests = AjaxHelpers.requests(this),
view = createEditTeamMembersView(DEFAULT_MEMBERSHIP);
spyOn(view.teamEvents, 'trigger');
verifyTeamMembersView(view);
deleteTeamMemember(view, true);
AjaxHelpers.expectJsonRequest(
requests,
'DELETE',
'/api/team/v0/team_membership/av,frodo?admin=true'
);
AjaxHelpers.respondWithError(requests);
expect(TeamUtils.showMessage).toHaveBeenCalledWith(
'An error occurred while removing the member from the team. Try again.',
undefined
);
expect(view.teamEvents.trigger).not.toHaveBeenCalled();
verifyTeamMembersView(view);
});
it('can cancel team membership deletion', function () {
var requests = AjaxHelpers.requests(this);
var view = createEditTeamMembersView(DEFAULT_MEMBERSHIP);
spyOn(view.teamEvents, 'trigger');
verifyTeamMembersView(view);
deleteTeamMemember(view, false);
expect(requests.length).toBe(0);
expect(view.teamEvents.trigger).not.toHaveBeenCalled();
verifyTeamMembersView(view);
});
});
});
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);
});
it('can trigger the edit membership view', function () {
view.$('.action-edit-members').click();
expect(Backbone.history.navigate).toHaveBeenCalledWith(
'teams/' + view.team.get('topic_id') + "/" + view.team.id + "/edit-team/manage-members",
{trigger: true}
);
});
});
});
...@@ -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 () {
...@@ -156,7 +156,7 @@ define([ ...@@ -156,7 +156,7 @@ define([
} }
], ],
'fires a page view event for the edit team page': [ 'fires a page view event for the edit team page': [
'topics/' + TeamSpecHelpers.testTopicID + '/' + 'test_team_id/edit-team', 'teams/' + TeamSpecHelpers.testTopicID + '/' + 'test_team_id/edit-team',
{ {
page_name: 'edit-team', page_name: 'edit-team',
topic_id: TeamSpecHelpers.testTopicID, topic_id: TeamSpecHelpers.testTopicID,
...@@ -165,7 +165,9 @@ define([ ...@@ -165,7 +165,9 @@ define([
] ]
}, function (url, expectedEvent) { }, function (url, expectedEvent) {
var requests = AjaxHelpers.requests(this), var requests = AjaxHelpers.requests(this),
teamsTabView = createTeamsTabView(); teamsTabView = createTeamsTabView({
userInfo: TeamSpecHelpers.createMockUserInfo({ staff: true })
});
teamsTabView.router.navigate(url, {trigger: true}); teamsTabView.router.navigate(url, {trigger: true});
if (requests.length) { if (requests.length) {
AjaxHelpers.respondWithJson(requests, {}); AjaxHelpers.respondWithJson(requests, {});
......
...@@ -29,13 +29,16 @@ define([ ...@@ -29,13 +29,16 @@ 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: '',
topic_id: 'topic_id' + i,
url: 'api/team/v0/teams/' + id
}; };
}); });
}; };
...@@ -124,6 +127,10 @@ define([ ...@@ -124,6 +127,10 @@ define([
}); });
}; };
var triggerTeamEvent = function (action) {
teamEvents.trigger('teams:update', {action: action});
};
createMockPostResponse = function(options) { createMockPostResponse = function(options) {
return _.extend( return _.extend(
{ {
...@@ -327,6 +334,7 @@ define([ ...@@ -327,6 +334,7 @@ define([
createMockThreadResponse: createMockThreadResponse, createMockThreadResponse: createMockThreadResponse,
createMockTopicData: createMockTopicData, createMockTopicData: createMockTopicData,
createMockTopicCollection: createMockTopicCollection, createMockTopicCollection: createMockTopicCollection,
triggerTeamEvent: triggerTeamEvent,
verifyCards: verifyCards verifyCards: verifyCards
}; };
}); });
;(function (define) {
'use strict';
define(['backbone',
'jquery',
'underscore',
'gettext',
'teams/js/models/team',
'teams/js/views/team_utils',
'common/js/components/utils/view_utils',
'text!teams/templates/edit-team-member.underscore',
'text!teams/templates/date.underscore'
],
function (Backbone, $, _, gettext, TeamModel, TeamUtils, ViewUtils, editTeamMemberTemplate, dateTemplate) {
return Backbone.View.extend({
dateTemplate: _.template(dateTemplate),
teamMemberTemplate: _.template(editTeamMemberTemplate),
errorMessage: gettext("An error occurred while removing the member from the team. Try again."),
events: {
'click .action-remove-member': 'removeMember'
},
initialize: function(options) {
this.teamMembershipDetailUrl = options.context.teamMembershipDetailUrl;
// The URL ends with team_id,request_username. We want to replace
// the last occurrence of team_id with the actual team_id, and remove request_username
// as the actual user to be removed from the team will be added on before calling DELETE.
this.teamMembershipDetailUrl = this.teamMembershipDetailUrl.substring(
0, this.teamMembershipDetailUrl.lastIndexOf('team_id')
) + this.model.get('id') + ",";
this.teamEvents = options.teamEvents;
},
render: function() {
if (this.model.get('membership').length === 0) {
this.$el.html('<p>' + gettext('This team does not have any members.') + '</p>');
}
else {
this.$el.html('<ul class="edit-members"></ul>');
this.renderTeamMembers();
}
return this;
},
renderTeamMembers: function() {
var self = this, dateJoined, lastActivity;
_.each(this.model.get('membership'), function(membership) {
dateJoined = interpolate(
// Translators: 'date' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago)
gettext("Joined %(date)s"),
{date: self.dateTemplate({date: membership.date_joined})},
true
);
lastActivity = interpolate(
// Translators: 'date' is a placeholder for a fuzzy, relative timestamp (see: https://github.com/rmm5t/jquery-timeago)
gettext("Last Activity %(date)s"),
{date: self.dateTemplate({date: membership.last_activity_at})},
true
);
// It is assumed that the team member array is automatically in the order of date joined.
self.$('.edit-members').append(self.teamMemberTemplate({
imageUrl: membership.user.profile_image.image_url_medium,
username: membership.user.username,
memberProfileUrl: '/u/' + membership.user.username,
dateJoined: dateJoined,
lastActive: lastActivity
}));
});
this.$('abbr').timeago();
},
removeMember: function (event) {
var self = this, username = $(event.currentTarget).data('username');
event.preventDefault();
ViewUtils.confirmThenRunOperation(
gettext('Remove this team member?'),
gettext('This learner will be removed from the team, allowing another learner to take the available spot.'),
gettext('Remove'),
function () {
$.ajax({
type: 'DELETE',
url: self.teamMembershipDetailUrl.concat(username, '?admin=true')
}).done(function () {
self.teamEvents.trigger('teams:update', {
action: 'leave',
team: self.model
});
self.model.fetch().done(function() { self.render(); });
}).fail(function (data) {
TeamUtils.parseAndShowMessage(data, self.errorMessage);
});
}
);
}
});
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone',
'underscore',
'gettext',
'teams/js/views/team_utils',
'common/js/components/utils/view_utils',
'text!teams/templates/instructor-tools.underscore'],
function (Backbone, _, gettext, TeamUtils, ViewUtils, instructorToolbarTemplate) {
return Backbone.View.extend({
events: {
'click .action-delete': 'deleteTeam',
'click .action-edit-members': 'editMembership'
},
initialize: function(options) {
this.template = _.template(instructorToolbarTemplate);
this.team = options.team;
this.teamEvents = options.teamEvents;
},
render: function() {
this.$el.html(this.template);
return this;
},
deleteTeam: function (event) {
event.preventDefault();
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();
Backbone.history.navigate(
'teams/' + this.team.get('topic_id') + '/' + this.team.id +'/edit-team/manage-members',
{trigger: true}
);
},
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();
}
});
}
});
});
}).call(this, define || RequireJS.define);
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
'teams/js/views/team_utils', 'teams/js/views/team_utils',
'text!teams/templates/team-membership-details.underscore', 'text!teams/templates/team-membership-details.underscore',
'text!teams/templates/team-country-language.underscore', 'text!teams/templates/team-country-language.underscore',
'text!teams/templates/team-activity.underscore' 'text!teams/templates/date.underscore'
], function ( ], function (
$, $,
Backbone, Backbone,
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
TeamUtils, TeamUtils,
teamMembershipDetailsTemplate, teamMembershipDetailsTemplate,
teamCountryLanguageTemplate, teamCountryLanguageTemplate,
teamActivityTemplate dateTemplate
) { ) {
var TeamMembershipView, TeamCountryLanguageView, TeamActivityView, TeamCardView; var TeamMembershipView, TeamCountryLanguageView, TeamActivityView, TeamCardView;
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
TeamActivityView = Backbone.View.extend({ TeamActivityView = Backbone.View.extend({
tagName: 'div', tagName: 'div',
className: 'team-activity', className: 'team-activity',
template: _.template(teamActivityTemplate), template: _.template(dateTemplate),
initialize: function (options) { initialize: function (options) {
this.date = options.date; this.date = options.date;
......
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
'use strict'; 'use strict';
define(['backbone', define(['backbone',
'jquery',
'underscore', 'underscore',
'gettext', 'gettext',
'teams/js/views/team_utils', 'teams/js/views/team_utils',
'text!teams/templates/team-profile-header-actions.underscore'], 'text!teams/templates/team-profile-header-actions.underscore'],
function (Backbone, _, gettext, TeamUtils, teamProfileHeaderActionsTemplate) { function (Backbone, $, _, gettext, TeamUtils, teamProfileHeaderActionsTemplate) {
return Backbone.View.extend({ return Backbone.View.extend({
errorMessage: gettext("An error occurred. Try again."), errorMessage: gettext("An error occurred. Try again."),
...@@ -56,8 +57,10 @@ ...@@ -56,8 +57,10 @@
return view; return view;
}, },
joinTeam: function () { joinTeam: function (event) {
var view = this; var view = this;
event.preventDefault();
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: view.context.teamMembershipsUrl, url: view.context.teamMembershipsUrl,
...@@ -117,7 +120,7 @@ ...@@ -117,7 +120,7 @@
editTeam: function (event) { editTeam: function (event) {
event.preventDefault(); event.preventDefault();
Backbone.history.navigate( Backbone.history.navigate(
'topics/' + this.topic.id + '/' + this.model.get('id') +'/edit-team', 'teams/' + this.topic.id + '/' + this.model.get('id') +'/edit-team',
{trigger: true} {trigger: true}
); );
} }
......
/* Team utility methods*/ /* Team utility methods*/
;(function (define) { ;(function (define) {
'use strict'; 'use strict';
define([ define(["jquery", "underscore"
], function () { ], function ($, _) {
return { return {
/** /**
...@@ -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);
} }
} }
}; };
......
...@@ -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;
} }
......
<li class="team-member">
<a class="member-profile" href="<%= memberProfileUrl %>">
<img class="image-url" src="<%= imageUrl %>" alt="<%= username %>'s profile page" />
</a>
<div class="member-info-container">
<span class="primary"><%= username %></span>
<div class="secondary">
<span id="date-joined"><%= dateJoined %></span>
<span> | </span>
<span id="last-active"><%= lastActive %></span>
</div>
</div>
<button class="action-remove-member" data-username="<%= username %>">
<%- gettext("Remove") %><span class="sr">&nbsp;<%= username %></span>
</button>
</li>
<div class="wrapper-msg">
<h3 class="left-floater">
<%- gettext("Instructor tools") %>
</h3>
<span class="right-floater">
<button class="action-delete">
<%- gettext("Delete Team") %>
</button>
<button class="action-edit-members">
<%- gettext("Edit Membership") %>
</button>
</span>
</div>
<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">
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
</div> </div>
</div> </div>
<div class="teams-header"></div> <div class="teams-header"></div>
<div class="teams-instructor-tools-bar"></div>
<div class="teams-main"> <div class="teams-main">
<div class="page-content"></div> <div class="page-content"></div>
</div> </div>
...@@ -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(
...@@ -384,12 +389,8 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase): ...@@ -384,12 +389,8 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
def delete_membership(self, team_id, username, expected_status=200, **kwargs): def delete_membership(self, team_id, username, expected_status=200, **kwargs):
"""Deletes an individual membership record. Verifies expected_status.""" """Deletes an individual membership record. Verifies expected_status."""
return self.make_call( url = reverse('team_membership_detail', args=[team_id, username]) + '?admin=true'
reverse('team_membership_detail', args=[team_id, username]), return self.make_call(url, expected_status, 'delete', **kwargs)
expected_status,
'delete',
**kwargs
)
def verify_expanded_public_user(self, user): def verify_expanded_public_user(self, user):
"""Verifies that fields exist on the returned user json indicating that it is expanded.""" """Verifies that fields exist on the returned user json indicating that it is expanded."""
...@@ -437,8 +438,9 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase): ...@@ -437,8 +438,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 +564,26 @@ class TestListTeamsAPI(EventTestMixin, TeamAPITestCase): ...@@ -562,6 +564,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 +799,60 @@ class TestDetailTeamAPI(TeamAPITestCase): ...@@ -777,6 +799,60 @@ class TestDetailTeamAPI(TeamAPITestCase):
@ddt.ddt @ddt.ddt
class TestDeleteTeamAPI(EventTestMixin, TeamAPITestCase):
"""Test cases for the team delete endpoint."""
def setUp(self): # pylint: disable=arguments-differ
super(TestDeleteTeamAPI, self).setUp('teams.views.tracker')
@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)
if status == 204:
self.assert_event_emitted(
'edx.team.deleted',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id)
)
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id),
remove_method='team_deleted',
user_id=self.users['student_enrolled'].id
)
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.assert_event_emitted(
'edx.team.deleted',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id)
)
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.test_course_1.id),
remove_method='team_deleted',
user_id=self.users['student_enrolled'].id
)
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."""
...@@ -1299,17 +1375,32 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase): ...@@ -1299,17 +1375,32 @@ class TestDeleteMembershipAPI(EventTestMixin, TeamAPITestCase):
) )
if status == 204: if status == 204:
remove_method = 'self_removal' if user == 'student_enrolled' else 'removed_by_admin'
self.assert_event_emitted( self.assert_event_emitted(
'edx.team.learner_removed', 'edx.team.learner_removed',
team_id=self.solar_team.team_id, team_id=self.solar_team.team_id,
course_id=unicode(self.solar_team.course_id), course_id=unicode(self.solar_team.course_id),
user_id=self.users['student_enrolled'].id, user_id=self.users['student_enrolled'].id,
remove_method=remove_method remove_method='removed_by_admin'
) )
else: else:
self.assert_no_events_were_emitted() self.assert_no_events_were_emitted()
def test_leave_team(self):
"""
The key difference between this test and test_access above is that
removal via "Edit Membership" and "Leave Team" emit different events
despite hitting the same API endpoint, due to the 'admin' query string.
"""
url = reverse('team_membership_detail', args=[self.solar_team.team_id, self.users['student_enrolled'].username])
self.make_call(url, 204, 'delete', user='student_enrolled')
self.assert_event_emitted(
'edx.team.learner_removed',
team_id=self.solar_team.team_id,
course_id=unicode(self.solar_team.course_id),
user_id=self.users['student_enrolled'].id,
remove_method='self_removal'
)
def test_bad_team(self): def test_bad_team(self):
self.delete_membership('no_such_team', self.users['student_enrolled'].username, 404) self.delete_membership('no_such_team', self.users['student_enrolled'].username, 404)
......
"""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,29 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): ...@@ -588,6 +608,29 @@ 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: list() forces the queryset to be evualuated before delete()
memberships = list(CourseTeamMembership.get_memberships(team_id=team_id))
# Note: also deletes all team memberships associated with this team
team.delete()
log.info('user %d deleted team %s', request.user.id, team_id)
tracker.emit('edx.team.deleted', {
'team_id': team_id,
'course_id': unicode(team.course_id),
})
for member in memberships:
tracker.emit('edx.team.learner_removed', {
'team_id': team_id,
'course_id': unicode(team.course_id),
'remove_method': 'team_deleted',
'user_id': member.user_id
})
return Response(status=status.HTTP_204_NO_CONTENT)
class TopicListView(GenericAPIView): class TopicListView(GenericAPIView):
""" """
...@@ -1155,6 +1198,9 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -1155,6 +1198,9 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
team = self.get_team(team_id) team = self.get_team(team_id)
if has_team_api_access(request.user, team.course_id, access_username=username): if has_team_api_access(request.user, team.course_id, access_username=username):
membership = self.get_membership(username, team) membership = self.get_membership(username, team)
removal_method = 'self_removal'
if 'admin' in request.QUERY_PARAMS:
removal_method = 'removed_by_admin'
membership.delete() membership.delete()
tracker.emit( tracker.emit(
'edx.team.learner_removed', 'edx.team.learner_removed',
...@@ -1162,7 +1208,7 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -1162,7 +1208,7 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView):
'team_id': team.team_id, 'team_id': team.team_id,
'course_id': unicode(team.course_id), 'course_id': unicode(team.course_id),
'user_id': membership.user.id, 'user_id': membership.user.id,
'remove_method': 'self_removal' if membership.user == request.user else 'removed_by_admin' 'remove_method': removal_method
} }
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
......
...@@ -704,6 +704,8 @@ ...@@ -704,6 +704,8 @@
'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/edit_team_members_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',
......
...@@ -232,3 +232,85 @@ ...@@ -232,3 +232,85 @@
} }
} }
} }
//efischer; scratch space for teams instructor tools
.view-teams {
.wrapper-msg {
@include clearfix();
max-width: grid-width(12);
margin: 0 auto;
border-top: 3px solid $orange;
.left-floater {
@include float(left);
text-transform: uppercase;
font-weight: $font-semibold;
color: $white;
line-height: $body-line-height;
}
.right-floater {
@include float(right);
line-height: $body-line-height;
button {
background: transparent;
border: 1px solid transparent;
color: $white;
box-shadow: 0 0 0 0;
font-weight: $font-regular;
text-shadow: 0 0;
&:hover {
color: $orange;
background: transparent;
border: 1px solid $orange;
box-shadow: 0 0 0 0;
}
&:focus {
box-shadow: 0 0 0 0;
}
}
}
}
.edit-members {
@extend %ui-no-list;
.team-member {
line-height: $body-line-height;
padding: 10px;
}
.member-info-container {
display: inline-block;
vertical-align: middle;
@include margin-left($baseline/2);
.primary {
font-size: 120%;
}
.secondary {
color: $lighter-base-font-color;
font-size: 80%;
display: block;
}
}
.member-profile {
img {
border: 1px solid $gray;
}
}
.action-remove-member {
color: $blue;
background: transparent;
border: 1px solid transparent;
font: inherit;
}
}
}
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