Display roles that can edit proficencies

closes OUT-3945

flag=account_level_mastery_scales

test plan:
  - enable account levels mastery scales flag
  - create custom roles that have different
    outcome proficiency permissions
    (scales & calculation methods)
  - confirm the mastery and calculation tabs
    on an account outcomes page display the
    roles that have the respective permissions

Change-Id: Ic677ca651c1825629609f00532598532313e002a
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/247692
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Brian Watson <bwatson@instructure.com>
Product-Review: Jody Sailor
Reviewed-by: Michael Brewer-Davis <mbd@instructure.com>
This commit is contained in:
Augusto Callejas 2020-09-11 15:57:07 -10:00
parent 0ab3fbcb7f
commit b4bf5427e2
11 changed files with 245 additions and 11 deletions

View File

@ -18,6 +18,7 @@
class OutcomesController < ApplicationController
include Api::V1::Outcome
include Api::V1::Role
before_action :require_context, :except => [:build_outcomes]
add_crumb(proc { t "#crumbs.outcomes", "Outcomes" }, :except => [:destroy, :build_outcomes]) { |c| c.send :named_context_url, c.instance_variable_get("@context"), :context_outcomes_path }
before_action { |c| c.active_tab = "outcomes" }
@ -50,6 +51,7 @@ class OutcomesController < ApplicationController
set_tutorial_js_env
mastery_scales_js_env
proficiency_roles_js_env
end
def show
@ -311,4 +313,23 @@ class OutcomesController < ApplicationController
def learning_outcome_params
params.require(:learning_outcome).permit(:description, :short_description, :title, :display_name, :vendor_guid)
end
private
def proficiency_roles_js_env
if @context.is_a?(Account) && @context.root_account.feature_enabled?(:account_level_mastery_scales)
proficiency_calculation_roles = []
@context.roles_with_enabled_permission(:manage_proficiency_calculations).each do |role|
proficiency_calculation_roles << role_json(@context, role, @current_user, session, skip_permissions: true)
end if @context.grants_right? @current_user, :manage_proficiency_calculations
proficiency_scales_roles = []
@context.roles_with_enabled_permission(:manage_proficiency_scales).each do |role|
proficiency_scales_roles << role_json(@context, role, @current_user, session, skip_permissions: true)
end if @context.grants_right? @current_user, :manage_proficiency_scales
js_env(
PROFICIENCY_CALCULATION_METHOD_ENABLED_ROLES: proficiency_calculation_roles,
PROFICIENCY_SCALES_ENABLED_ROLES: proficiency_scales_roles
)
end
end
end

View File

@ -23,6 +23,18 @@ import {OUTCOME_PROFICIENCY_QUERY, SET_OUTCOME_CALCULATION_METHOD} from '../api'
import MasteryCalculation from '../index'
describe('MasteryCalculation', () => {
beforeEach(() => {
window.ENV = {
PROFICIENCY_CALCULATION_METHOD_ENABLED_ROLES: [
{id: '1', role: 'AccountAdmin', label: 'Account Admin', base_role_type: 'AccountMembership'}
]
}
})
afterEach(() => {
window.ENV = null
})
const mocks = [
{
request: {
@ -61,6 +73,18 @@ describe('MasteryCalculation', () => {
expect(getByDisplayValue(/65/)).not.toEqual(null)
})
it('loads role list', async () => {
const {getByText} = render(
<MockedProvider mocks={mocks}>
<MasteryCalculation contextType="Account" contextId="11" />
</MockedProvider>
)
expect(getByText('Loading')).not.toEqual(null)
await wait()
expect(getByText(/Permission to change this mastery calculation/)).not.toEqual(null)
expect(getByText(/Account Admin/)).not.toEqual(null)
})
it('displays an error on failed request', async () => {
const {getByText} = render(
<MockedProvider mocks={[]}>

View File

@ -18,10 +18,10 @@
import React, {useCallback} from 'react'
import I18n from 'i18n!MasteryScale'
import {Heading} from '@instructure/ui-heading'
import {Spinner} from '@instructure/ui-spinner'
import {Text} from '@instructure/ui-text'
import ProficiencyCalculation from './ProficiencyCalculation'
import RoleList from '../RoleList'
import {OUTCOME_PROFICIENCY_QUERY, SET_OUTCOME_CALCULATION_METHOD} from './api'
import {useQuery, useMutation} from 'react-apollo'
@ -57,11 +57,15 @@ const MasteryCalculation = ({contextType, contextId}) => {
)
}
const {outcomeCalculationMethod} = data.account
const roles = ENV.PROFICIENCY_CALCULATION_METHOD_ENABLED_ROLES || []
return (
<>
<Heading level="h5" margin="medium 0">
{I18n.t('Set the mastery scale to be used for all courses within this account.')}
</Heading>
<RoleList
description={I18n.t(
'Permission to change this mastery calculation is enabled at the account level for:'
)}
roles={roles}
/>
<ProficiencyCalculation
contextType={contextType}
contextId={contextId}

View File

@ -24,6 +24,18 @@ import {OUTCOME_PROFICIENCY_QUERY} from '../api'
import MasteryScale from '../index'
describe('MasteryScale', () => {
beforeEach(() => {
window.ENV = {
PROFICIENCY_SCALES_ENABLED_ROLES: [
{id: '1', role: 'AccountAdmin', label: 'Account Admin', base_role_type: 'AccountMembership'}
]
}
})
afterEach(() => {
window.ENV = null
})
const mocks = [
{
request: {
@ -81,6 +93,18 @@ describe('MasteryScale', () => {
expect(getByDisplayValue(/Rating A/)).not.toEqual(null)
})
it('loads role list', async () => {
const {getByText} = render(
<MockedProvider mocks={mocks}>
<MasteryScale contextType="Account" contextId="11" />
</MockedProvider>
)
expect(getByText('Loading')).not.toEqual(null)
await wait()
expect(getByText(/Permission to change this mastery scale/)).not.toEqual(null)
expect(getByText(/Account Admin/)).not.toEqual(null)
})
it('displays an error on failed request', async () => {
const {getByText} = render(
<MockedProvider mocks={[]}>

View File

@ -18,13 +18,12 @@
import React, {useCallback, useState} from 'react'
import I18n from 'i18n!MasteryScale'
import {Heading} from '@instructure/ui-heading'
import {Spinner} from '@instructure/ui-spinner'
import {Text} from '@instructure/ui-text'
import {View} from '@instructure/ui-view'
import ProficiencyTable from './ProficiencyTable'
import RoleList from '../RoleList'
import {saveProficiency, OUTCOME_PROFICIENCY_QUERY} from './api'
import {useQuery, useMutation} from 'react-apollo'
import {useQuery} from 'react-apollo'
const MasteryScale = ({contextType, contextId}) => {
const {loading, error, data} = useQuery(OUTCOME_PROFICIENCY_QUERY, {
@ -72,11 +71,22 @@ const MasteryScale = ({contextType, contextId}) => {
)
}
const {outcomeProficiency} = data.account
const roles = ENV.PROFICIENCY_SCALES_ENABLED_ROLES || []
return (
<div>
<Heading level="h5" margin="medium 0">
{I18n.t('Set the mastery scale to be used for all courses within this account.')}
</Heading>
<p>
<Text>
{I18n.t(
'This mastery scale will be used as the default for all courses within your account.'
)}
</Text>
</p>
<RoleList
description={I18n.t(
'Permission to change this mastery scale is enabled at the account level for:'
)}
roles={roles}
/>
<ProficiencyTable
proficiency={outcomeProficiency || undefined} // send undefined when value is null
update={updateProficiencyRatings}

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {arrayOf, string, shape} from 'prop-types'
import {List} from '@instructure/ui-list'
import {Text} from '@instructure/ui-text'
import {getSortedRoles} from '../permissions/helper/utils'
const RoleList = ({description, roles}) => {
if (roles?.length > 0) {
const accountAdmin = roles.find(element => element.role === 'AccountAdmin') || {}
return (
<>
<p>
<Text>{description}</Text>
</p>
<List>
{getSortedRoles(roles, accountAdmin).map(role => (
<List.Item key={role.id}>{role.label}</List.Item>
))}
</List>
</>
)
} else {
return null
}
}
RoleList.propTypes = {
description: string.isRequired,
roles: arrayOf(
shape({
id: string.isRequired,
role: string.isRequired,
label: string.isRequired,
base_role_type: string.isRequired
})
).isRequired
}
export default RoleList

View File

@ -0,0 +1,50 @@
/*
* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {render, shallow} from 'enzyme'
import RoleList from '../RoleList'
const defaultProps = (props = {}) => ({
description: 'My RoleList',
roles: [
{id: '26', role: 'Custom Admin', label: 'Custom Admin', base_role_type: 'AccountMembership'},
{id: '1', role: 'AccountAdmin', label: 'Account Admin', base_role_type: 'AccountMembership'}
],
...props
})
it('renders the RoleList component', () => {
const list = shallow(<RoleList {...defaultProps()} />, {disableLifecycleMethods: true})
expect(list.exists()).toBe(true)
})
it('renders description', () => {
const list = render(<RoleList {...defaultProps()} />)
expect(list.text()).toContain('My RoleList')
})
it('renders roles with account admin first', () => {
const list = render(<RoleList {...defaultProps()} />)
expect(list.text()).toContain('Account AdminCustom Admin')
})
it('does not renders description without roles', () => {
const list = render(<RoleList {...defaultProps({roles: []})} />)
expect(list.text()).not.toContain('My RoleList')
})

View File

@ -2035,4 +2035,11 @@ class Account < ActiveRecord::Base
def self.ensure_dummy_root_account
Account.find_or_create_by!(id: 0) if Rails.env.test?
end
def roles_with_enabled_permission(permission)
roles = available_roles
roles.select do |role|
RoleOverride.permission_for(self, permission, role, self, true)[:enabled]
end
end
end

View File

@ -38,7 +38,7 @@ module Api::V1::Role
perm = RoleOverride.permission_for(account, permission, role, account, true)
next if permission == :manage_developer_keys && !account.root_account?
json[:permissions][permission] = permission_json(perm, current_user, session) if perm[:account_allows]
end
end unless opts[:skip_permissions]
json
end

View File

@ -82,6 +82,23 @@ describe OutcomesController do
expect(permissions.key?(permission)).to be_truthy
end
end
context 'account_level_mastery_scales feature flag enabled' do
before(:once) do
@account.root_account.enable_feature! :account_level_mastery_scales
end
it 'includes proficiency roles' do
user_session(@admin)
get 'index', params: {:account_id => @account.id}
%i[PROFICIENCY_CALCULATION_METHOD_ENABLED_ROLES PROFICIENCY_SCALES_ENABLED_ROLES].each do |key|
roles = controller.js_env[key]
expect(roles.length).to eq 1
expect(roles.dig(0, :role)).to eq 'AccountAdmin'
end
end
end
end
describe "GET 'show'" do

View File

@ -2050,4 +2050,24 @@ describe Account do
expect(Account.find(sub_acc2.id).effective_brand_config).to eq config2
end
end
context '#roles_with_enabled_permission' do
let(:account) { account_model }
it 'returns expected roles with the given permission' do
role = account.roles.create :name => 'AssistantGrader'
role.base_role_type = 'TaEnrollment'
role.workflow_state = 'active'
role.save!
RoleOverride.create!(
context: account,
permission: 'change_course_state',
role: role,
enabled: true
)
expect(
account.roles_with_enabled_permission(:change_course_state).map(&:name).sort
).to eq %w[AccountAdmin AssistantGrader DesignerEnrollment TeacherEnrollment]
end
end
end