Team Assignments Dashboard (#24019)

* Add team assignments to frontend

* Limit team assignments to the given teamset

* Remove deprecated django render_to_response

* Move team assignments panel behind feature flag
This commit is contained in:
Nathan Sprenkle
2020-06-02 15:05:47 -04:00
committed by GitHub
parent de0e8524a2
commit e0ddb1e0e2
11 changed files with 404 additions and 6 deletions

View File

@@ -365,3 +365,20 @@ def anonymous_user_ids_for_team(user, team):
anonymous_id_for_user(user=team_member, course_id=team.course_id, save=False)
for team_member in team.users.all()
])
def get_assignments_for_team(user, team):
""" Get openassessment XBlocks configured for the current teamset """
# Confirm access
if not has_specific_team_access(user, team):
raise Exception("User {user} is not permitted to access team info for {team}".format(
user=user.username,
team=team.team_id
))
# Limit to team-enabled ORAs for the matching teamset in the course
return modulestore().get_items(
team.course_id,
qualifiers={'category': 'openassessment'},
settings={'teams_enabled': True, 'selected_teamset_id': team.topic_id}
)

View File

@@ -10,6 +10,7 @@ from .views import (
MembershipBulkManagementView,
MembershipDetailView,
MembershipListView,
TeamsAssignmentsView,
TeamsDetailView,
TeamsListView,
TopicDetailView,
@@ -32,6 +33,13 @@ urlpatterns = [
TeamsDetailView.as_view(),
name="teams_detail"
),
url(
r'^v0/teams/{team_id_pattern}/assignments$'.format(
team_id_pattern=TEAM_ID_PATTERN,
),
TeamsAssignmentsView.as_view(),
name="teams_assignments_list"
),
url(
r'^v0/topics/$',
TopicListView.as_view(),

View File

@@ -48,7 +48,7 @@ define([
el: $('.profile-view'),
teamEvents: TeamSpecHelpers.teamEvents,
courseID: TeamSpecHelpers.testCourseID,
context: TeamSpecHelpers.testContext,
context: options.context || TeamSpecHelpers.testContext,
model: teamModel,
topic: isInstructorManagedTopic ?
TeamSpecHelpers.createMockInstructorManagedTopic() :
@@ -72,6 +72,23 @@ define([
)
);
AjaxHelpers.respondWithJson(requests, TeamSpecHelpers.createMockDiscussionResponse());
// Assignments are feature-flagged
if (profileView.context.teamsAssignmentsUrl) {
AjaxHelpers.expectRequest(
requests,
'GET',
interpolate( // eslint-disable-line no-undef
'/api/team/v0/teams/%(teamId)s/assignments',
{
teamId: teamModel.id
},
true
)
);
AjaxHelpers.respondWithJson(requests, TeamSpecHelpers.createMockTeamAssignments(options.assignments));
}
return profileView;
};
@@ -101,6 +118,76 @@ define([
}
};
describe('TeamAssignmentsView', function() {
it('can render itself', function() {
// Given a member of a team with team assignments
var mockAssignments = TeamSpecHelpers.createMockTeamAssignments(),
options = {
membership: DEFAULT_MEMBERSHIP
},
requests = AjaxHelpers.requests(this);
// When they go to the team profile view
var view = createTeamProfileView(requests, options);
// The Assignments section renders with their assignments
expect(view.$('.team-assignment').length).toEqual(mockAssignments.length);
});
it('displays a message when no assignments are found', function() {
// Given a member viewing a team with no assignments
var mockAssignments = [],
options = {
assignments: mockAssignments,
membership: DEFAULT_MEMBERSHIP
},
requests = AjaxHelpers.requests(this);
// When they view the team
var view = createTeamProfileView(requests, options);
// There should be filler text that says there are no assignments
expect(view.$('#assignments').text()).toEqual('No assignments for team');
expect(view.$('.team-assignment').length).toEqual(0);
});
it('does not show at all for someone who is not on the team or staff', function() {
// Given a user who is not on a team viewing a team with assignments
var mockAssignments = TeamSpecHelpers.createMockTeamAssignments(),
options = {
assignments: mockAssignments
},
requests = AjaxHelpers.requests(this);
// When the user goes to the team detail page
var view = createTeamProfileView(requests, options);
// Then then assignments view does not appear on the page
expect(view.$('.team-assignments').length).toBe(0);
});
it('does not show at all when the feature flag is turned off', function() {
// Given the team submissions feature is turned off
// (teamAsssignmentsUrl isn't surfaced to user)
var mockAssignments = TeamSpecHelpers.createMockTeamAssignments(),
options = {
assignments: mockAssignments,
membership: DEFAULT_MEMBERSHIP,
context: Object.assign({}, TeamSpecHelpers.testContext)
},
requests = AjaxHelpers.requests(this),
view;
delete options.context.teamsAssignmentsUrl;
// When the user goes to the team detail page
view = createTeamProfileView(requests, options);
// Then then assignments view does not appear on the page
expect(view.$('.team-assignments').length).toBe(0);
});
});
describe('DiscussionsView', function() {
it('can render itself', function() {
var requests = AjaxHelpers.requests(this),

View File

@@ -73,6 +73,18 @@ define([
);
};
var createMockTeamAssignments = function(assignments, options) {
if (_.isUndefined(assignments)) {
assignments = [ // eslint-disable-line no-param-reassign
{
display_name: 'Send me',
location: 'your location'
}
];
}
return _.extend(assignments, options);
};
var createMockTeamMembershipsData = function(startIndex, stopIndex) {
var teams = createMockTeamData(startIndex, stopIndex);
return _.map(_.range(startIndex, stopIndex + 1), function(i) {
@@ -282,6 +294,7 @@ define([
countries: testCountries,
topicUrl: '/api/team/v0/topics/topic_id,' + testCourseID,
teamsUrl: '/api/team/v0/teams/',
teamsAssignmentsUrl: '/api/team/v0/teams/team_id/assignments',
teamsDetailUrl: '/api/team/v0/teams/team_id',
teamMembershipsUrl: '/api/team/v0/team_memberships/',
teamMembershipDetailUrl: '/api/team/v0/team_membership/team_id,' + testUser,
@@ -327,6 +340,7 @@ define([
createMockTeamData: createMockTeamData,
createMockTeamsResponse: createMockTeamsResponse,
createMockTeams: createMockTeams,
createMockTeamAssignments: createMockTeamAssignments,
createMockUserInfo: createMockUserInfo,
createMockContext: createMockContext,
createMockTopic: createMockTopic,

View File

@@ -12,10 +12,11 @@
'common/js/components/utils/view_utils',
'teams/js/views/team_utils',
'text!teams/templates/team-profile.underscore',
'text!teams/templates/team-member.underscore'
'text!teams/templates/team-member.underscore',
'text!teams/templates/team-assignment.underscore'
],
function(Backbone, _, gettext, HtmlUtils, TeamDiscussionView, ViewUtils, TeamUtils,
teamTemplate, teamMemberTemplate) {
teamTemplate, teamMemberTemplate, teamAssignmentTemplate) {
var TeamProfileView = Backbone.View.extend({
errorMessage: gettext('An error occurred. Try again.'),
@@ -44,6 +45,10 @@
isInstructorManagedTopic = TeamUtils.isInstructorManagedTopic(this.topic.attributes.type),
maxTeamSize = this.topic.getMaxTeamSize(this.context.courseMaxTeamSize);
// Assignments URL isn't provided if team assignments shouldn't be shown
// so we can treat it like a toggle
var showAssignments = !!this.context.teamsAssignmentsUrl;
var showLeaveLink = isMember && (isAdminOrStaff || !isInstructorManagedTopic);
HtmlUtils.setHtml(
@@ -56,7 +61,9 @@
language: this.languages[this.model.get('language')],
membershipText: TeamUtils.teamCapacityText(memberships.length, maxTeamSize),
isMember: isMember,
isAdminOrStaff: isAdminOrStaff,
showLeaveLink: showLeaveLink,
showAssignments: showAssignments,
hasCapacity: maxTeamSize && (memberships.length < maxTeamSize),
hasMembers: memberships.length >= 1
})
@@ -67,12 +74,48 @@
});
this.discussionView.render();
if (showAssignments) {
this.getTeamAssignments();
}
this.renderTeamMembers();
this.setFocusToHeaderFunc();
return this;
},
getTeamAssignments: function() {
var view = this;
$.ajax({
type: 'GET',
url: view.context.teamsAssignmentsUrl.replace('team_id', view.model.get('id'))
}).done(function(data) {
view.renderTeamAssignments(data);
}).fail(function(data) {
TeamUtils.parseAndShowMessage(data, view.errorMessage);
});
},
renderTeamAssignments: function(assignments) {
var view = this;
if (!assignments || !assignments.length) {
view.$('#assignments').text(gettext('No assignments for team'));
return;
}
_.each(assignments, function(assignment) {
HtmlUtils.append(
view.$('#assignments'),
HtmlUtils.template(teamAssignmentTemplate)({
displayName: assignment.display_name,
linkLocation: assignment.location
})
);
});
},
renderTeamMembers: function() {
var view = this;
_.each(this.model.get('membership'), function(membership) {

View File

@@ -0,0 +1,3 @@
<li class="team-assignment">
<a href="<%- linkLocation %>"><%- displayName %></a>
</li>

View File

@@ -1,5 +1,12 @@
<div class="team-profile">
<div class="page-content-main">
<% if (showAssignments && (isMember || isAdminOrStaff)) { %>
<div class="team-assignments">
<h3><%- gettext("Team Assignments") %></h3>
<ul id="assignments"></ul>
</div>
<hr></hr>
<% } %>
<div class="discussion-module" data-course-id="<%- courseID %>" data-discussion-id="<%- discussionTopicID %>"
data-read-only="<%- readOnly %>"
data-user-create-comment="<%- !readOnly %>"

View File

@@ -50,6 +50,9 @@ from openedx.core.djangolib.js_utils import (
topicUrl: '${topic_url | n, js_escaped_string}',
topicsUrl: '${topics_url | n, js_escaped_string}',
teamsUrl: '${teams_url | n, js_escaped_string}',
% if teams_assignments_url:
teamsAssignmentsUrl: '${teams_assignments_url | n, js_escaped_string}',
% endif
teamsDetailUrl: '${teams_detail_url | n, js_escaped_string}',
teamMembershipsUrl: '${team_memberships_url | n, js_escaped_string}',
teamMembershipDetailUrl: '${team_membership_detail_url | n, js_escaped_string}',

View File

@@ -34,7 +34,7 @@ from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollment
from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from ..models import CourseTeamMembership
from ..search_indexes import CourseTeam, CourseTeamIndexer, course_team_post_save_callback
@@ -648,6 +648,14 @@ class TeamAPITestCase(APITestCase, SharedModuleStoreTestCase):
**kwargs
)
def get_team_assignments(self, team_id, expected_status=200, **kwargs):
""" Get the open response assessments assigned to a team """
return self.make_call(
reverse('teams_assignments_list', args=[team_id]),
expected_status,
**kwargs
)
def get_topics_list(self, expected_status=200, data=None, **kwargs):
"""Gets the list of topics, passing data as query params. Verifies expected_status."""
return self.make_call(reverse('topics_list'), expected_status, 'get', data, **kwargs)
@@ -1570,6 +1578,100 @@ class TestUpdateTeamAPI(EventTestMixin, TeamAPITestCase):
self.assertEqual(team['name'], 'foo')
@patch.dict(settings.FEATURES, {'ENABLE_ORA_TEAM_SUBMISSIONS': True})
@ddt.ddt
class TestTeamAssignmentsView(TeamAPITestCase):
""" Tests for the TeamAssignmentsView """
@classmethod
def setUpClass(cls):
""" Create an openassessment block for testing """
super().setUpClass()
course = cls.test_course_1
teamset_id = cls.solar_team.topic_id
other_teamset_id = cls.wind_team.topic_id
section = ItemFactory.create(
parent=course,
category='chapter',
display_name='Test Section'
)
subsection = ItemFactory.create(
parent=section,
category="sequential"
)
unit_1 = ItemFactory.create(
parent=subsection,
category="vertical"
)
open_assessment = ItemFactory.create(
parent=unit_1,
category="openassessment",
teams_enabled=True,
selected_teamset_id=teamset_id
)
unit_2 = ItemFactory.create(
parent=subsection,
category="vertical"
)
off_team_open_assessment = ItemFactory.create( # pylint: disable=unused-variable
parent=unit_2,
category="openassessment",
teams_enabled=True,
selected_teamset_id=other_teamset_id
)
cls.team_assignments = [open_assessment]
@ddt.unpack
@ddt.data(
(None, 401),
('student_inactive', 401),
('student_unenrolled', 403),
('student_on_team_2_private_set_1', 404),
('student_enrolled', 200),
('staff', 200),
('course_staff', 200),
('community_ta', 200),
)
def test_get_assignments(self, user, expected_status):
# Given a course with team-enabled open responses
team_id = self.solar_team.team_id
# When I get the assignments for a team
assignments = self.get_team_assignments(team_id, expected_status, user=user)
if expected_status == 200:
# I successful, I get back the assignments for a team
self.assertEqual(len(assignments), len(self.team_assignments))
# ... with the right data structure
for assignment in assignments:
self.assertIn('display_name', assignment.keys())
self.assertIn('location', assignment.keys())
def test_get_assignments_bad_team(self):
# Given a bad team is supplied
user = 'student_enrolled'
team_id = 'bogus-team'
# When I run the query, I get back a 404 error
expected_status = 404
self.get_team_assignments(team_id, expected_status, user=user)
@patch.dict(settings.FEATURES, {'ENABLE_ORA_TEAM_SUBMISSIONS': False})
def test_get_assignments_feature_not_enabled(self):
# Given the team submissions feature is not enabled
user = 'student_enrolled'
team_id = self.solar_team.team_id
# When I try to get assignments
# Then I get back a 503 error
expected_status = 503
self.get_team_assignments(team_id, expected_status, user=user)
@ddt.ddt
class TestListTopicsAPI(TeamAPITestCase):
"""Test cases for the topic listing endpoint."""

View File

@@ -13,7 +13,7 @@ from django.core.exceptions import PermissionDenied
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render_to_response
from django.shortcuts import get_object_or_404, render
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
@@ -51,6 +51,7 @@ from .api import (
add_team_count,
can_user_modify_team,
can_user_create_team_in_topic,
get_assignments_for_team,
has_course_staff_privileges,
has_specific_team_access,
has_specific_teamset_access,
@@ -68,6 +69,7 @@ from .serializers import (
TopicSerializer
)
from .utils import emit_team_event
from .waffle import are_team_submissions_enabled
TEAM_MEMBERSHIPS_PER_PAGE = 5
TOPICS_PER_PAGE = 12
@@ -210,7 +212,12 @@ class TeamsDashboardView(GenericAPIView):
"disable_courseware_js": True,
"teams_base_url": reverse('teams_dashboard', request=request, kwargs={'course_id': course_id}),
}
return render_to_response("teams/teams.html", context)
# Assignments are feature-flagged
if are_team_submissions_enabled(course_key):
context["teams_assignments_url"] = reverse('teams_assignments_list', args=['team_id'])
return render(request, "teams/teams.html", context)
def _serialize_and_paginate(self, pagination_cls, queryset, request, serializer_cls, serializer_ctx):
"""
@@ -823,6 +830,87 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class TeamsAssignmentsView(GenericAPIView):
"""
**Use Cases**
Get a team's assignments
**Example Requests**:
GET /api/team/v0/teams/{team_id}/assignments
**Response Values for GET**
If the user is logged in, the response is an array of the following data strcuture:
* display_name: The name of the assignment to display (currently the Unit title)
* location: The jump link to a specific assignments
For all text fields, clients rendering the values should take care
to HTML escape them to avoid script injections, as the data is
stored exactly as specified. The intention is that plain text is
supported, not HTML.
If team assignments are not enabled for course, a 503 is returned.
If the user is not logged in, a 401 error is returned.
If the user is unenrolled or does not have API access, a 403 error is returned.
If the supplied course/team is bad or the user is not permitted to
search in a protected team, a 404 error is returned as if the team does not exist.
"""
authentication_classes = (BearerAuthentication, SessionAuthentication)
permission_classes = (
permissions.IsAuthenticated,
IsEnrolledOrIsStaff,
HasSpecificTeamAccess,
IsStaffOrPrivilegedOrReadOnly,
)
def get(self, request, team_id):
"""GET v0/teams/{team_id_pattern}/assignments"""
course_team = get_object_or_404(CourseTeam, team_id=team_id)
user = request.user
course_id = course_team.course_id
if not are_team_submissions_enabled(course_id):
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE)
if not has_team_api_access(request.user, course_id):
return Response(status=status.HTTP_403_FORBIDDEN)
if not has_specific_team_access(user, course_team):
return Response(status=status.HTTP_404_NOT_FOUND)
teamset_ora_blocks = get_assignments_for_team(user, course_team)
# Serialize info for display
assignments = [{
'display_name': self._display_name_for_ora_block(block),
'location': self._jump_location_for_block(course_id, block.location)
} for block in teamset_ora_blocks]
return Response(assignments)
def _display_name_for_ora_block(self, block):
""" Get the unit name where the ORA is located for better display naming """
unit = modulestore().get_item(block.parent)
section = modulestore().get_item(unit.parent)
return "{section}: {unit}".format(
section=section.display_name,
unit=unit.display_name
)
def _jump_location_for_block(self, course_id, location):
""" Get the URL for jumping to a designated XBlock in a course """
return reverse('jump_to', kwargs={'course_id': str(course_id), 'location': str(location)})
class TopicListView(GenericAPIView):
"""
**Use Cases**

View File

@@ -0,0 +1,26 @@
"""
Togglable settings for Teams behavior
"""
from django.conf import settings
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
# Course Waffle inherited from edx/edx-ora2
WAFFLE_NAMESPACE = 'openresponseassessment'
TEAM_SUBMISSIONS_FLAG = 'team_submissions'
# edx/edx-platform feature
TEAM_SUBMISISONS_FEATURE = 'ENABLE_ORA_TEAM_SUBMISSIONS'
def are_team_submissions_enabled(course_key):
"""
Checks to see if the CourseWaffleFlag or Django setting for team submissions is enabled
"""
if CourseWaffleFlag(WAFFLE_NAMESPACE, TEAM_SUBMISSIONS_FLAG).is_enabled(course_key):
return True
if settings.FEATURES.get(TEAM_SUBMISISONS_FEATURE, False):
return True
return False