group leaders

fixes CNVS-11833

test plan
- regression test teacher view of groups page

a group can only have one group leader, and that user must be in
the group. anything that can be done to remove a user from a
group should revoke their leadership if they have it. a user's
leadership should not be revoked unless the teacher revokes it,
a different leader is chosen, or that user leaves the group. a
leader's user should have a user icon on it and their name should
appear next to the group's name

a few test cases:
- set group leader using gear menu on user in group
- revoke the leadership of the user using the gear menu
- ensure that the group is now leaderless

- set a group leader
- set a different user as leader
- ensure that a screenreader identifies the group leader link as "Group
  Leader"
- ensure that the first user is no longer the leader

- set a group leader
- remove that user from the group by dragging and dropping
- ensure that the user is no longer the group leader

- set a group leader
- move the user to another group using the option on the gear
 menu
- ensure that the user is not the leader of their original or new
 group

- in a large-roster course, add users to a group user the plus
 button next to the gear menu
- set a group leader using the gear menu next to a user
- fill a group up to it's maximum limit of students
- ensure that the "full" label shows up for that group
- ensure that when you narrow the browser size, you can
still tell that the group is full

Change-Id: I8bb1b62e0f36a37a24e050878c945f822fe9f66c
Reviewed-on: https://gerrit.instructure.com/34360
Reviewed-by: Ethan Vizitei <evizitei@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Trevor deHaan <tdehaan@instructure.com>
Product-Review: Ethan Vizitei <evizitei@instructure.com>
This commit is contained in:
Joel Hough 2014-05-13 19:13:18 -06:00 committed by Ethan Vizitei
parent a99e904d11
commit 0d186e3c1a
26 changed files with 246 additions and 85 deletions

View File

@ -19,8 +19,8 @@ define [
initialize: (models) ->
super
@loaded = @loadedAll = models?
@on 'change:groupId', @onChangeGroupId
@model = GroupUser.extend defaults: {groupId: @group.id, @category}
@on 'change:group', @onChangeGroup
@model = GroupUser.extend defaults: {group: @group, @category}
load: (target = 'all') ->
@loadAll = target is 'all'
@ -28,9 +28,9 @@ define [
@fetch() if target isnt 'none'
@load = ->
onChangeGroupId: (model, groupId) =>
onChangeGroup: (model, group) =>
@removeUser model
@groupUsersFor(groupId)?.addUser model
@groupUsersFor(group)?.addUser model
membershipsLocked: ->
false
@ -64,11 +64,11 @@ define [
removeUser: (user) ->
return if @membershipsLocked()
@increment -1
@group.set('leader', null) if @group?.get('leader')?.id == user.id
@remove user if @loaded
increment: (amount) ->
@group.increment 'members_count', amount
groupUsersFor: (id) ->
@category?.groupUsersFor(id)
groupUsersFor: (group) ->
@category?.groupUsersFor(group)

View File

@ -5,10 +5,6 @@ define [
class UnassignedGroupUserCollection extends GroupUserCollection
defaults:
group:
id: null
url: ->
_url = "/api/v1/group_categories/#{@category.id}/users?per_page=50"
_url += "&unassigned=true" unless @category.get('allows_multiple_memberships')

View File

@ -44,24 +44,24 @@ define [
users = group.users()
if users.loadedAll
models = users.models.slice()
user.set 'groupId', null for user in models
user.set 'group', null for user in models
else if not @get('allows_multiple_memberships')
@_unassignedUsers.increment group.usersCount()
if not @get('allows_multiple_memberships') and (not users.loadedAll or not @_unassignedUsers.loadedAll)
@_unassignedUsers.fetch()
reassignUser: (user, newGroupId) ->
oldGroupId = user.get('groupId')
return if oldGroupId is newGroupId
reassignUser: (user, newGroup) ->
oldGroup = user.get('group')
return if oldGroup is newGroup
# if user is in _unassignedUsers and we allow multiple memberships,
# don't actually move the user, move a copy instead
if not oldGroupId? and @get('allows_multiple_memberships')
if not oldGroup? and @get('allows_multiple_memberships')
user = user.clone()
user.once 'change:groupId', => @groupUsersFor(newGroupId).addUser user
user.once 'change:group', => @groupUsersFor(newGroup).addUser user
user.save groupId: newGroupId
user.save group: newGroup
groupsCount: ->
if @_groups?.loadedAll
@ -69,9 +69,9 @@ define [
else
@get('groups_count')
groupUsersFor: (id) ->
if id?
@_groups?.get(id)?._users
groupUsersFor: (group) ->
if group?
group._users
else
@_unassignedUsers

View File

@ -9,26 +9,26 @@ define [
##
# janky sync override cuz we don't have the luxury of (ember data || backbone-relational)
sync: (method, model, options) =>
groupId = @get('groupId')
previousGroupId = @previous('groupId')
# return unless changing groupId
return if groupId is previousGroupId
group = @get('group')
previousGroup = @previous('group')
# return unless changing group
return if group is previousGroup
# if the user is joining another group
if groupId?
@joinGroup(groupId)
if group?
@joinGroup(group)
# if the user is being removed from a group, or is being moved to
# another group AND the category allows multiple memberships (in
# which case rails won't delete the old membership, so we have to)
if previousGroupId and (not groupId? or @get('category').get('allows_multiple_memberships'))
@leaveGroup(previousGroupId)
if previousGroup and (not group? or @get('category').get('allows_multiple_memberships'))
@leaveGroup(previousGroup)
# creating membership will delete pre-existing membership in same group category
joinGroup: (groupId) ->
$.ajaxJSON "/api/v1/groups/#{groupId}/memberships", 'POST', {user_id: @get('id')},
joinGroup: (group) ->
$.ajaxJSON "/api/v1/groups/#{group.id}/memberships", 'POST', {user_id: @get('id')},
(data) => @trigger('ajaxJoinGroupSuccess', data)
leaveGroup: (groupId) ->
$.ajaxJSON "/api/v1/groups/#{groupId}/users/#{@get('id')}", 'DELETE'
leaveGroup: (group) ->
$.ajaxJSON "/api/v1/groups/#{group.id}/users/#{@get('id')}", 'DELETE'
# e.g. so the view can give the user an indication of what happened
# once everything is done

View File

@ -34,7 +34,7 @@ define [
e.stopPropagation()
$target = $(e.currentTarget)
user = @collection.get($target.data('user-id'))
user.save({'groupId': @groupId})
user.save({'group': @group})
@hide()
showBy: ($target, focus = false) ->

View File

@ -30,7 +30,7 @@ define [
e.preventDefault()
e.stopPropagation()
newGroupId = $(e.currentTarget).data('group-id')
@collection.category.reassignUser(@model, newGroupId)
@collection.category.reassignUser(@model, @collection.get(newGroupId))
@hide()
toJSON: ->

View File

@ -41,7 +41,7 @@ define [
e.preventDefault()
e.stopPropagation()
targetGroup = @$('option:selected').val()
if targetGroup then @group.collection.category.reassignUser(@model, targetGroup)
if targetGroup then @group.collection.category.reassignUser(@model, @group.collection.get(targetGroup))
@close()
# focus override to the user's new group heading if they're moved
$("[data-id='#{targetGroup}'] .group-heading")?.focus()
@ -54,7 +54,7 @@ define [
hasGroups = groupCollection.length > 0
{
allFull: hasGroups and groupCollection.models.every (g) -> g.isFull()
groupId: @group.get('id')
groupId: @group.id
userName: @model.get('name')
groups: groupCollection.toJSON()
}

View File

@ -58,6 +58,7 @@ define [
toJSON: ->
json = @model.toJSON()
json.leader = @model.get('leader')
json.canAssignUsers = ENV.IS_LARGE_ROSTER and not @model.isLocked()
json.canEdit = not @model.isLocked()
json.summary = @summary()

View File

@ -34,4 +34,7 @@ define [
, 1000
toJSON: ->
_.extend {}, this, super
_.extend {groupId: @model.get('group')?.id}, this, super
isLeader: ->
@model.get('group')?.get?('leader')?.id == @model.get('id')

View File

@ -38,6 +38,7 @@ define [
attach: ->
@model.on 'change:members_count', @render
@model.on 'change:leader', @render
@collection.on 'moved', @highlightUser
highlightUser: (user) ->
@ -49,13 +50,26 @@ define [
events:
'click .remove-from-group': 'removeUserFromGroup'
'click .remove-as-leader': 'removeLeader'
'click .set-as-leader': 'setLeader'
'click .edit-group-assignment': 'editGroupAssignment'
removeUserFromGroup: (e) ->
e.preventDefault()
e.stopPropagation()
$target = $(e.currentTarget)
@collection.get($target.data('user-id')).save 'groupId', null
@collection.get($target.data('user-id')).save 'group', null
removeLeader: (e) ->
e.preventDefault()
e.stopPropagation()
@model.save(leader: null)
setLeader: (e) ->
e.preventDefault()
e.stopPropagation()
$target = $(e.currentTarget)
@model.save(leader: {id: $target.data('user-id').toString()})
editGroupAssignment: (e) ->
e.preventDefault()

View File

@ -73,7 +73,7 @@ define [
e.preventDefault()
e.stopPropagation()
$target = $(e.currentTarget)
@addUnassignedMenu.groupId = @model.id
@addUnassignedMenu.group = @model
@addUnassignedMenu.showBy $target, e.type is 'click'
hideAddUser: (e) ->
@ -93,4 +93,4 @@ define [
user = ui.draggable.data('model')
newGroupId = $(e.currentTarget).data('id')
setTimeout =>
@model.collection.category.reassignUser(user, newGroupId)
@model.collection.category.reassignUser(user, @model.collection.get(newGroupId))

View File

@ -4,14 +4,13 @@ define [
'compiled/views/groups/manage/GroupView'
'compiled/views/groups/manage/GroupUsersView'
'compiled/views/groups/manage/GroupDetailView'
'compiled/views/groups/manage/Scrollable'
'compiled/views/Filterable'
'jst/groups/manage/groups'
], (_, PaginatedCollectionView, GroupView, GroupUsersView, GroupDetailView, Scrollable, Filterable, template) ->
], (_, PaginatedCollectionView, GroupView, GroupUsersView, GroupDetailView, Filterable, template) ->
class GroupsView extends PaginatedCollectionView
@mixin Filterable, Scrollable
@mixin Filterable
template: template

View File

@ -139,7 +139,7 @@ class GroupsController < ApplicationController
include Api::V1::Group
include Api::V1::GroupCategory
SETTABLE_GROUP_ATTRIBUTES = %w(name description join_level is_public group_category avatar_attachment storage_quota_mb max_membership)
SETTABLE_GROUP_ATTRIBUTES = %w(name description join_level is_public group_category avatar_attachment storage_quota_mb max_membership leader)
include TextHelper
@ -494,6 +494,14 @@ class GroupsController < ApplicationController
attrs[:avatar_attachment] = @group.active_images.find_by_id(avatar_id)
end
if attrs[:leader]
membership = @group.group_memberships.find_by_user_id(attrs[:leader][:id])
return render :json => {}, :status => :bad_request unless membership
attrs[:leader] = membership.user
else
attrs[:leader] = nil
end
if authorized_action(@group, @current_user, :update)
respond_to do |format|
if @group.update_attributes(attrs.slice(*SETTABLE_GROUP_ATTRIBUTES))

View File

@ -21,7 +21,7 @@ class Group < ActiveRecord::Base
include Workflow
include CustomValidations
attr_accessible :name, :context, :max_membership, :group_category, :join_level, :default_view, :description, :is_public, :avatar_attachment, :storage_quota_mb
attr_accessible :name, :context, :max_membership, :group_category, :join_level, :default_view, :description, :is_public, :avatar_attachment, :storage_quota_mb, :leader
validates_presence_of :context_id, :context_type, :account_id, :root_account_id, :workflow_state
validates_allowed_transitions :is_public, false => true
@ -61,6 +61,7 @@ class Group < ActiveRecord::Base
has_many :zip_file_imports, :as => :context
has_many :content_migrations, :as => :context
belongs_to :avatar_attachment, :class_name => "Attachment"
belongs_to :leader, :class_name => "User"
EXPORTABLE_ATTRIBUTES = [
:id, :name, :workflow_state, :created_at, :updated_at, :context_id, :context_type, :category, :max_membership, :hashtag, :show_public_context_messages, :is_public,
@ -432,6 +433,9 @@ class Group < ActiveRecord::Base
can :moderate_forum and
can :update
given { |user| user && self.leader == user }
can :update
given { |user| self.group_category.try(:communities?) }
can :create

View File

@ -36,9 +36,11 @@ class GroupMembership < ActiveRecord::Base
before_validation :verify_section_homogeneity_if_necessary
validate :validate_within_group_limit
after_save :revoke_leadership_if_necessary
after_save :ensure_mutually_exclusive_membership
after_save :touch_groups
after_save :update_cached_due_dates
after_destroy :revoke_leadership
after_destroy :touch_groups
has_a_broadcast_policy
@ -104,6 +106,21 @@ class GroupMembership < ActiveRecord::Base
end
protected :auto_join
def revoke_leadership
self.group.update_attribute(:leader, nil) if self.group && self.group.leader == self.user
end
protected :revoke_leadership
def revoke_leadership_if_necessary
if self.old_group_id # moved to new group
old_group = Group.find(self.old_group_id)
old_group.update_attribute(:leader, nil) if old_group && old_group.leader == self.user
elsif self.deleted? && self.group && self.group.leader == self.user # deleted
self.group.update_attribute(:leader, nil)
end
end
protected :revoke_leadership_if_necessary
def ensure_mutually_exclusive_membership
return unless self.group
return if self.deleted?

View File

@ -51,22 +51,43 @@
}
}
.group-leader {
color: #555;
a {
font-size: 0.85em;
}
}
.group-header {
.group-summary {
font-size: 0.85em;
}
}
.group .group-user {
float: left;
width: 31%;
margin: 0 2% 3px 0;
box-sizing: border-box;
.group-category-contents-condensed & {
width: 23.5%;
margin: 0 1% 3px 0;
}
box-sizing: border-box;
.group-user-name {
margin-right: 0;
}
.group-leader {
float: right;
line-height: 28px;
}
}
.group-user-name {
max-width: 85%;
max-width: 90%;
overflow: hidden;
white-space: nowrap;
margin-right: 10px;

View File

@ -1,14 +1,24 @@
<div class="row-fluid">
<div class="{{#if canAssignUsers}}span7{{else}}span6{{/if}} ellipsis">
<div class="row-fluid group-header">
<div class="{{#if leader}}span5{{else}}span8{{/if}} ellipsis">
<a href="#" class="toggle-group group-heading" aria-label="{{#t "show_group_details"}}Show details for group {{name}}{{/t}}" title="{{name}}" aria-expanded="false">
<i class="group-collapsed-item icon-mini-arrow-right"></i>
<i class="group-expanded-item icon-mini-arrow-down"></i>
<span class="group-name">{{name}}</span>
</a>
</div>
<div class="{{#if canAssignUsers}}span3{{else}}span4{{/if}} ellipsis">
<span class="toggle-group group-summary">{{summary}}</span>
{{#if leader}}
<div class="span3 ellipsis group-leader">
<i class="icon-user"></i>
<a href="{{leader.html_url}}">
<span class="screenreader-only">{{#t "group_leader"}}Group Leader{{/t}}</span>
{{leader.display_name}}
</a>
</div>
{{/if}}
<div class="span2 ellipsis">
<span class="label label-info show-group-full">{{#t "group_full"}}Full{{/t}}</span>
<br class="show-group-full"/>
<span class="toggle-group group-summary">{{summary}}</span>
</div>
<div class="span2 group-actions">
{{#if canAssignUsers}}

View File

@ -1,4 +1,4 @@
<div class="group-user-name ellipsis" title="{{name}}"><i class="icon-drag-handle"></i>{{name}}</div>
<div class="group-user-name ellipsis" title="{{name}}"><i class="icon-drag-handle"></i>{{#if isLeader}}<i class="icon-user group-leader"></i>{{/if}}{{name}}</div>
{{#if canAssignToGroup}}
<a href="#"
data-user-id="{{id}}"
@ -9,7 +9,7 @@
</a>
{{/if}}
{{#if canEditGroupAssignment}}
<div class="inline-block" role="application">
<div role="application">
<a href="#"
id='group-{{groupId}}-user-{{id}}-actions'
data-user-id="{{id}}"
@ -18,18 +18,37 @@
role="button">
<i class="icon-settings"></i><i class="icon-mini-arrow-down"></i>
<span class="screenreader-only">
{{#t "edit_user_group_assignment"}}Remove or move {{name}} from group{{/t}}
{{#t "edit_group_membership"}}Edit {{name}}'s membership{{/t}}
</span>
</a>
<ul class="al-options">
<li>
<a href="#"
data-user-id="{{id}}"
aria-label="{{#t "remove_from_group"}}Remove {{name}} from group{{/t}}"
class="icon-trash remove-from-group">
{{#t "remove"}}Remove{{/t}}
</a>
</li>
{{#if isLeader}}
<li>
<a href="#"
data-user-id="{{id}}"
aria-label="{{#t "remove_user_as_leader"}}Remove {{name}} as leader{{/t}}"
class="icon-user remove-as-leader">
{{#t "remove_as_leader"}}Remove as Leader{{/t}}
</a>
</li>
{{else}}
<li>
<a href="#"
data-user-id="{{id}}"
aria-label="{{#t "remove_from_group"}}Remove {{name}} from group{{/t}}"
class="icon-trash remove-from-group">
{{#t "remove"}}Remove{{/t}}
aria-label="{{#t "set_user_as_leader"}}Set {{name}} as leader{{/t}}"
class="icon-user set-as-leader">
{{#t "set_as_leader"}}Set as Leader{{/t}}
</a>
</li>
{{/if}}
<li>
<a href="#"
data-focus-returns-to='group-{{groupId}}-user-{{id}}-actions'

View File

@ -0,0 +1,13 @@
class AddGroupLeaderIdToGroups < ActiveRecord::Migration
tag :predeploy
def self.up
add_column :groups, :leader_id, :integer, :limit => 8
add_foreign_key :groups, :users, column: :leader_id
end
def self.down
remove_foreign_key :groups, column: :leader_id
remove_column :groups, :leader_id
end
end

View File

@ -41,6 +41,8 @@ module Api::V1::Group
image = group.avatar_attachment
hash['avatar_url'] = image && thumbnail_image_url(image, image.uuid)
hash['role'] = group.group_category.role if group.group_category
#hash['leader_id'] = group.leader_id
hash['leader'] = group.leader ? user_display_json(group.leader, group) : nil
if includes.include?('users')
# TODO: this should be switched to user_display_json

View File

@ -34,7 +34,8 @@ describe "Groups API", type: :request do
"#{group.context_type.downcase}_id" => group.context_id,
'role' => group.group_category.role,
'group_category_id' => group.group_category_id,
'storage_quota_mb' => group.storage_quota_mb
'storage_quota_mb' => group.storage_quota_mb,
'leader' => group.leader
}
if group.context_type == 'Account' && is_admin == true
json['sis_import_id'] = group.sis_batch_id

View File

@ -10,6 +10,7 @@ define [
source = null
target = null
users = null
group = null
module 'GroupUserCollection',
setup: ->
@ -17,8 +18,8 @@ define [
category = new GroupCategory()
category._groups = new Collection([group])
users = [
new GroupUser(id: 1, name: "bob", sortable_name: "bob", groupId: null),
new GroupUser(id: 2, name: "joe", sortable_name: "joe", groupId: null)
new GroupUser(id: 1, name: "bob", sortable_name: "bob", group: null),
new GroupUser(id: 2, name: "joe", sortable_name: "joe", group: null)
]
source = new UnassignedGroupUserCollection users, {category}
category._unassignedUsers = source
@ -26,13 +27,12 @@ define [
target.loaded = true
group._users = target
test "moves user to target group's collection when groupId changes", ->
users[0].set('groupId', 1)
test "moves user to target group's collection when group changes", ->
users[0].set('group', group)
equal source.length, 1
equal target.length, 1
test "removes user when target group's collection is not yet loaded", ->
users[0].set('groupId', 2) # not the target
users[0].set('group', new Group(id: 2)) # not the target
equal source.length, 1
equal target.length, 0

View File

@ -1,9 +1,10 @@
define [
'Backbone'
'compiled/models/Group'
'compiled/models/GroupUser'
'compiled/models/GroupCategory'
'jquery'
], (Backbone, GroupUser, GroupCategory, $) ->
], (Backbone, Group, GroupUser, GroupCategory, $) ->
module 'GroupUser',
setup: ->
@ -15,19 +16,21 @@ define [
@leaveGroupStub.restore()
@joinGroupStub.restore()
test "updates groupId correctly upon save and fires joinGroup and leaveGroup appropriately", ->
@groupUser.save({'groupId': 777})
equal @groupUser.get('groupId'), 777
test "updates group correctly upon save and fires joinGroup and leaveGroup appropriately", ->
group1 = new Group(id: 777)
@groupUser.save({'group': group1})
equal @groupUser.get('group'), group1
equal @joinGroupStub.callCount, 1
ok @joinGroupStub.calledWith 777
ok @joinGroupStub.calledWith group1
equal @leaveGroupStub.callCount, 0
@groupUser.save({'groupId': 123})
equal @groupUser.get('groupId'), 123
group2 = new Group(id: 123)
@groupUser.save({'group': group2})
equal @groupUser.get('group'), group2
equal @joinGroupStub.callCount, 2
ok @joinGroupStub.calledWith 123
ok @joinGroupStub.calledWith group2
@groupUser.save({'groupId': null})
equal @groupUser.get('groupId'), null
@groupUser.save({'group': null})
equal @groupUser.get('group'), null
equal @joinGroupStub.callCount, 2
equal @leaveGroupStub.callCount, 1

View File

@ -38,7 +38,7 @@ define [
users.loaded = true
view = new AddUnassignedMenu
collection: users
view.groupId = 777
view.group = new Group(id: 777)
users.reset([
new GroupUser(id: 1, name: "Frank Herbert", sortable_name: "Herbert, Frank"),
new GroupUser(id: 2, name: "Neal Stephenson", sortable_name: "Stephenson, Neal"),
@ -53,14 +53,14 @@ define [
server.restore()
view.remove()
test "updates the user's groupId and removes from unassigned collection", ->
equal waldo.get('groupId'), null
test "updates the user's group and removes from unassigned collection", ->
equal waldo.get('group'), null
$links = view.$('.assign-user-to-group')
equal $links.length, 4
$waldoLink = $links.last()
$waldoLink.click()
sendResponse 'POST',"/api/v1/groups/777/memberships", {}
equal waldo.get('groupId'), 777
equal waldo.get('group'), view.group
ok not users.contains(waldo)

View File

@ -13,7 +13,7 @@ define [
module 'AssignToGroupMenu',
setup: ->
groupCategory = new GroupCategory
user = new GroupUser(id: 1, name: "bob", groupId: null, category: groupCategory)
user = new GroupUser(id: 1, name: "bob", group: null, category: groupCategory)
groups = new GroupCollection [
new Group id: 1, name: "a group"
], {category: groupCategory}
@ -26,11 +26,9 @@ define [
teardown: ->
view.remove()
test "updates the user's groupId", ->
equal user.get('groupId'), null
test "updates the user's group", ->
equal user.get('group'), null
$link = view.$('.set-group')
equal $link.length, 1
$link.click()
equal user.get('groupId'), 1
equal user.get('group').id, 1

View File

@ -249,6 +249,58 @@ describe GroupMembership do
end
end
describe "group leadership revocation" do
before(:each) do
course
@category = @course.group_categories.build(:name => "category 1")
@category.save!
@group = @category.groups.create!(:context => @course)
@leader = user_model
@leader_membership = @group.group_memberships.create!(:user => @leader, :workflow_state => 'accepted')
@group.leader = @leader
@group.save!
@membership = @group.group_memberships.create!(:user => user_model, :workflow_state => 'accepted')
@group.reload
@leader_membership.reload
end
context "leader membership" do
it "should revoke when deleted" do
@group.leader.should_not be_nil
@leader_membership.destroy!
@group.reload.leader.should be_nil
end
it "should revoke when soft deleted" do
@group.leader.should_not be_nil
@leader_membership.destroy
@group.reload.leader.should be_nil
end
it "should revoke when group is changed" do
@group.leader.should_not be_nil
group2 = @category.groups.create!(:context => @course)
@leader_membership.update_attribute(:group_id, group2.id)
@group.reload.leader.should be_nil
end
end
context "non-leader membership" do
it "should not revoke when deleted" do
@group.leader.should_not be_nil
@membership.destroy!
@group.reload.leader.should_not be_nil
end
it "should not revoke when group is changed" do
@group.leader.should_not be_nil
group2 = @category.groups.create!(:context => @course)
@membership.update_attribute(:group_id, group2.id)
@group.reload.leader.should_not be_nil
end
end
end
describe "updating cached due dates" do
before do
course