move N.Q to Quizzes Page

(flag=newquizzes_on_quiz_page)

- populate N.Q quizzes to assignment quizzes list
- display kebab menu based on quiz types (old quizze and new quizzes)
- items in kebab menu are functional

closes QUIZ-6790, QUIZ-6792, QUIZ-6789, QUIZ-6786

test plan:
- With the newquizzes_on_quiz_page flag disabled
  everything should behave like in production
- With the newquizzes_on_quiz_page flag enabled
  1) N.Q quizzes show up on Quizzes Page
  2) N.Q quiz shells have correct kebab menu
  3) each menu items (delete, duplicate) should work

Change-Id: Ie4a78bb0f0a69f4d6e248135d1c486f1ca0ffe7f
Reviewed-on: https://gerrit.instructure.com/209993
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
Reviewed-by: Stephen Kacsmark <skacsmark@instructure.com>
Reviewed-by: Jon Willesen <jonw+gerrit@instructure.com>
QA-Review: Robin Kuss <rkuss@instructure.com>
Product-Review: Kevin Dougherty III <jdougherty@instructure.com>
This commit is contained in:
Han Yan 2019-09-18 08:21:31 -05:00
parent 8067ee5ce5
commit 6e65e2ef31
16 changed files with 923 additions and 54 deletions

View File

@ -25,9 +25,10 @@ import DateGroupCollection from '../collections/DateGroupCollection'
import I18n from 'i18n!modelsQuiz'
import 'jquery.ajaxJSON'
import 'jquery.instructure_misc_helpers'
import PandaPubPoller from '../util/PandaPubPoller'
export default class Quiz extends Backbone.Model {
initialize(attributes, options = {}) {
initialize() {
this.publish = this.publish.bind(this)
this.unpublish = this.unpublish.bind(this)
this.dueAt = this.dueAt.bind(this)
@ -51,6 +52,7 @@ export default class Quiz extends Backbone.Model {
this.objectType = this.objectType.bind(this)
super.initialize(...arguments)
this.initId()
this.initAssignment()
this.initAssignmentOverrides()
this.initUrls()
@ -62,6 +64,10 @@ export default class Quiz extends Backbone.Model {
}
// initialize attributes
initId() {
this.id = this.isQuizzesNext() ? `assignment_${this.get('id')}` : this.get('id')
}
initAssignment() {
if (this.attributes.assignment) {
this.set('assignment', new Assignment(this.attributes.assignment))
@ -78,12 +84,11 @@ export default class Quiz extends Backbone.Model {
initUrls() {
if (this.get('html_url')) {
this.set('base_url', this.get('html_url').replace(/quizzes\/\d+/, 'quizzes'))
this.set('base_url', this.get('html_url').replace(/(quizzes|assignments)\/\d+/, '$1'))
this.set('url', `${this.get('base_url')}/${this.get('id')}`)
this.set('edit_url', `${this.get('base_url')}/${this.get('id')}/edit`)
this.set('publish_url', `${this.get('base_url')}/publish`)
this.set('unpublish_url', `${this.get('base_url')}/unpublish`)
this.set('publish_url', this.publish_url())
this.set('unpublish_url', this.unpublish_url())
this.set(
'toggle_post_to_sis_url',
`${this.get('base_url')}/${this.get('id')}/toggle_post_to_sis`
@ -103,7 +108,9 @@ export default class Quiz extends Backbone.Model {
initQuestionsCount() {
const cnt = this.get('question_count')
return this.set('question_count_label', I18n.t('question_count', 'Question', {count: cnt}))
if (cnt) {
this.set('question_count_label', I18n.t('question_count', 'Question', {count: cnt}))
}
}
initPointsCount() {
@ -115,10 +122,28 @@ export default class Quiz extends Backbone.Model {
return this.set('possible_points_label', text)
}
isQuizzesNext() {
return this.get('quiz_type') === 'quizzes.next'
}
isUngradedSurvey() {
return this.get('quiz_type') === 'survey'
}
publish_url() {
if (this.isQuizzesNext()) {
return `${this.get('base_url')}/publish/quiz`
}
return `${this.get('base_url')}/publish`
}
unpublish_url() {
if (this.isQuizzesNext()) {
return `${this.get('base_url')}/unpublish/quiz`
}
return `${this.get('base_url')}/unpublish`
}
initAllDates() {
let allDates
if ((allDates = this.get('all_dates')) != null) {
@ -127,7 +152,6 @@ export default class Quiz extends Backbone.Model {
}
// publishing
publish() {
this.set('published', true)
return $.ajaxJSON(this.get('publish_url'), 'POST', {quizzes: [this.get('id')]})
@ -162,6 +186,10 @@ export default class Quiz extends Backbone.Model {
return this.set('lock_at', date)
}
isDuplicating() {
return this.get('workflow_state') === 'duplicating'
}
name(newName) {
if (!(arguments.length > 0)) return this.get('title')
return this.set('title', newName)
@ -171,13 +199,73 @@ export default class Quiz extends Backbone.Model {
return this.get('url')
}
destroy(options) {
const opts = {
url: this.htmlUrl(),
...options
}
Backbone.Model.prototype.destroy.call(this, opts)
}
defaultDates() {
let group
return (group = new DateGroup({
return new DateGroup({
due_at: this.get('due_at'),
unlock_at: this.get('unlock_at'),
lock_at: this.get('lock_at')
}))
})
}
// caller is original assignments
duplicate(callback) {
const course_id = this.get('course_id')
const assignment_id = this.get('id')
return $.ajaxJSON(
`/api/v1/courses/${course_id}/assignments/${assignment_id}/duplicate`,
'POST',
{quizzes: [assignment_id], result_type: 'Quiz'},
callback
)
}
// caller is failed assignments
duplicate_failed(callback) {
const target_course_id = this.get('course_id')
const target_assignment_id = this.get('id')
const original_course_id = this.get('original_course_id')
const original_assignment_id = this.get('original_assignment_id')
let query_string = `?target_assignment_id=${target_assignment_id}`
if (original_course_id !== target_course_id) {
// when it's a course copy failure
query_string += `&target_course_id=${target_course_id}`
}
$.ajaxJSON(
`/api/v1/courses/${original_course_id}/assignments/${original_assignment_id}/duplicate${query_string}`,
'POST',
{},
callback
)
}
pollUntilFinishedLoading(interval) {
if (this.isDuplicating()) {
this.pollUntilFinished(interval)
}
}
pollUntilFinished(interval) {
const course_id = this.get('course_id')
const id = this.get('id')
const poller = new PandaPubPoller(interval, interval * 5, done => {
this.fetch({url: `/api/v1/courses/${course_id}/assignments/${id}?result_type=Quiz`}).always(
() => {
done()
if (!this.isDuplicating()) {
return poller.stop()
}
}
)
})
poller.start()
}
multipleDueDates() {
@ -193,10 +281,9 @@ export default class Quiz extends Backbone.Model {
}
allDates() {
let result
const groups = this.get('all_dates')
const models = (groups && groups.models) || []
return (result = _.map(models, group => group.toJSON()))
return _.map(models, group => group.toJSON())
}
singleSectionDueDate() {

View File

@ -28,6 +28,7 @@ import DateAvailableColumnView from '../assignments/DateAvailableColumnView'
import SisButtonView from '../SisButtonView'
import template from 'jst/quizzes/QuizItemView'
import 'jquery.disableWhileLoading'
import Quiz from '../../models/Quiz'
export default class ItemView extends Backbone.View {
static initClass() {
@ -47,7 +48,11 @@ export default class ItemView extends Backbone.View {
'click .delete-item': 'onDelete',
'click .migrate': 'migrateQuiz',
'click .quiz-copy-to': 'copyQuizTo',
'click .quiz-send-to': 'sendQuizTo'
'click .quiz-send-to': 'sendQuizTo',
'click .duplicate_assignment': 'onDuplicate',
'click .duplicate-failed-retry': 'onDuplicateFailedRetry',
'click .duplicate-failed-cancel': 'onDuplicateOrImportFailedCancel',
'click .import-failed-cancel': 'onDuplicateOrImportFailedCancel'
}
this.prototype.messages = {
@ -61,6 +66,7 @@ export default class ItemView extends Backbone.View {
initialize(options) {
this.initializeChildViews()
this.observeModel()
this.model.pollUntilFinishedLoading(3000)
return super.initialize(...arguments)
}
@ -121,7 +127,8 @@ export default class ItemView extends Backbone.View {
}
migrateQuizEnabled() {
return ENV.FLAGS && ENV.FLAGS.migrate_quiz_enabled
const isOldQuiz = this.model.get('quiz_type') !== 'quizzes.next'
return ENV.FLAGS && ENV.FLAGS.migrate_quiz_enabled && isOldQuiz
}
migrateQuiz(e) {
@ -152,12 +159,14 @@ export default class ItemView extends Backbone.View {
}
// delete quiz item
delete() {
delete(opts) {
this.$el.hide()
return this.model.destroy({
success: () => {
this.$el.remove()
return $.flashMessage(this.messages.deleteSuccessful)
if (opts.silent !== true) {
$.flashMessage(this.messages.deleteSuccessful)
}
},
error: () => {
this.$el.show()
@ -178,7 +187,8 @@ export default class ItemView extends Backbone.View {
observeModel() {
this.model.on('change:published', this.updatePublishState, this)
return this.model.on('change:loadingOverrides', this.render, this)
this.model.on('change:loadingOverrides', this.render, this)
this.model.on('change:workflow_state', this.render, this)
}
updatePublishState() {
@ -189,6 +199,55 @@ export default class ItemView extends Backbone.View {
return ENV.PERMISSIONS.manage
}
canDuplicate() {
const userIsAdmin = _.includes(ENV.current_user_roles, 'admin')
const canManage = this.canManage()
const canDuplicate = this.model.get('can_duplicate')
return (userIsAdmin || canManage) && canDuplicate
}
onDuplicate(e) {
if (!this.canDuplicate()) return
e.preventDefault()
this.model.duplicate(this.addQuizToList.bind(this))
}
addQuizToList(response) {
if (!response) return
const quiz = new Quiz(response)
if (ENV.PERMISSIONS.by_assignment_id) {
ENV.PERMISSIONS.by_assignment_id[quiz.id] =
ENV.PERMISSIONS.by_assignment_id[quiz.originalAssignmentID()]
}
this.model.collection.add(quiz)
this.focusOnQuiz(response)
}
focusOnQuiz(quiz) {
$(`#assignment_${quiz.id}`)
.attr('tabindex', -1)
.focus()
}
onDuplicateOrImportFailedCancel(e) {
e.preventDefault()
this.delete({silent: true})
}
onDuplicateFailedRetry(e) {
e.preventDefault()
const button = $(e.target)
button.prop('disabled', true)
this.model
.duplicate_failed(response => {
this.addQuizToList(response)
this.delete({silent: true})
})
.always(() => {
button.prop('disabled', false)
})
}
toJSON() {
const base = _.extend(this.model.toJSON(), this.options)
base.quiz_menu_tools = ENV.quiz_menu_tools
@ -206,6 +265,9 @@ export default class ItemView extends Backbone.View {
}
base.migrateQuizEnabled = this.migrateQuizEnabled
base.canDuplicate = this.canDuplicate()
base.isDuplicating = this.model.get('workflow_state') === 'duplicating'
base.failedToDuplicate = this.model.get('workflow_state') === 'failed_to_duplicate'
base.showAvailability = this.model.multipleDueDates() || !this.model.defaultDates().available()
base.showDueDate = this.model.multipleDueDates() || this.model.singleSectionDueDate()

View File

@ -618,6 +618,7 @@ class AssignmentsApiController < ApplicationController
include Api::V1::Assignment
include Api::V1::Submission
include Api::V1::AssignmentOverride
include Api::V1::Quiz
# @API List assignments
# Returns the paginated list of assignments for the current course or assignment group.
@ -700,9 +701,16 @@ class AssignmentsApiController < ApplicationController
if assignment_topic&.pinned && !assignment_topic&.position.nil?
new_assignment.discussion_topic.insert_at(assignment_topic.position + 1)
end
# Include the updated positions in the response so the frontend can
# update them appropriately
result_json = assignment_json(new_assignment, @current_user, session)
# return assignment json based on requested result type
# Serializing an assignment into a quiz format is required by N.Q Quiz shells on Quizzes Page
result_json = if use_quiz_json?
quiz_json(new_assignment, @context, @current_user, session, {}, QuizzesNext::QuizSerializer)
else
# Include the updated positions in the response so the frontend can
# update them appropriately
assignment_json(new_assignment, @current_user, session)
end
result_json['new_positions'] = positions_hash
render :json => result_json
else
@ -851,13 +859,22 @@ class AssignmentsApiController < ApplicationController
@assignment.context_module_action(@current_user, :read) unless locked && !locked[:can_view]
log_api_asset_access(@assignment, "assignments", @assignment.assignment_group)
render :json => assignment_json(@assignment, @current_user, session,
submission: submissions,
override_dates: override_dates,
include_visibility: include_visibility,
needs_grading_count_by_section: needs_grading_count_by_section,
include_all_dates: include_all_dates,
include_overrides: include_override_objects)
options = {
submission: submissions,
override_dates: override_dates,
include_visibility: include_visibility,
needs_grading_count_by_section: needs_grading_count_by_section,
include_all_dates: include_all_dates,
include_overrides: include_override_objects
}
result_json = if use_quiz_json?
quiz_json(@assignment, @context, @current_user, session, {}, QuizzesNext::QuizSerializer)
else
assignment_json(@assignment, @current_user, session, options)
end
render :json => result_json
end
end
@ -1323,4 +1340,8 @@ class AssignmentsApiController < ApplicationController
def course_copy_retry?
target_course_for_duplicate != @context
end
def use_quiz_json?
params[:result_type] == 'Quiz' && @context.root_account.feature_enabled?(:newquizzes_on_quiz_page)
end
end

View File

@ -681,6 +681,42 @@ class AssignmentsController < ApplicationController
end
end
# pulish a N.Q assignment from Quizzes Page
def publish_quizzes
if authorized_action(@context, @current_user, :manage_assignments)
@assignments = @context.assignments.active.where(id: params[:quizzes])
@assignments.each(&:publish!)
flash[:notice] = t('notices.quizzes_published',
{ :one => "1 quiz successfully published!",
:other => "%{count} quizzes successfully published!" },
:count => @assignments.length)
respond_to do |format|
format.html { redirect_to named_context_url(@context, :context_quizzes_url) }
format.json { render :json => {}, :status => :ok }
end
end
end
# unpulish a N.Q assignment from Quizzes Page
def unpublish_quizzes
if authorized_action(@context, @current_user, :manage_assignments)
@assignments = @context.assignments.active.where(id: params[:quizzes], workflow_state: 'published')
@assignments.each(&:unpublish!)
flash[:notice] = t('notices.quizzes_unpublished',
{ :one => "1 quiz successfully unpublished!",
:other => "%{count} quizzes successfully unpublished!" },
:count => @assignments.length)
respond_to do |format|
format.html { redirect_to named_context_url(@context, :context_quizzes_url) }
format.json { render :json => {}, :status => :ok }
end
end
end
protected
def set_default_tool_env!(context, hash)

View File

@ -18,6 +18,7 @@
class Quizzes::QuizzesController < ApplicationController
include Api::V1::Quiz
include Api::V1::QuizzesNext::Quiz
include Api::V1::AssignmentOverride
include KalturaHelper
include ::Filters::Quizzes
@ -65,31 +66,17 @@ class Quizzes::QuizzesController < ApplicationController
can_manage = @context.grants_right?(@current_user, session, :manage_assignments)
scope = @context.quizzes.active.preload(:assignment)
# students only get to see published quizzes, and they will fetch the
# overrides later using the API:
scope = scope.available unless @context.grants_right?(@current_user, session, :read_as_admin)
scope = DifferentiableAssignment.scope_filter(scope, @current_user, @context)
quizzes = scope.sort_by do |quiz|
due_date = quiz.assignment ? quiz.assignment.due_at : quiz.lock_at
quiz_options = Rails.cache.fetch(
[
due_date || CanvasSort::Last,
Canvas::ICU.collation_key(quiz.title || CanvasSort::First)
]
end
quiz_options = Rails.cache.fetch([
'quiz_user_permissions', @context.id, @current_user,
quizzes.map(&:id), # invalidate on add/delete of quizzes
quizzes.map(&:updated_at).sort.last # invalidate on modifications
].cache_key) do
'quiz_user_permissions', @context.id, @current_user,
scoped_quizzes.map(&:id), # invalidate on add/delete of quizzes
scoped_quizzes.map(&:updated_at).sort.last # invalidate on modifications
].cache_key
) do
if can_manage
Quizzes::Quiz.preload_can_unpublish(quizzes)
Quizzes::Quiz.preload_can_unpublish(scoped_quizzes)
end
quizzes.each_with_object({}) do |quiz, quiz_user_permissions|
scoped_quizzes.each_with_object({}) do |quiz, quiz_user_permissions|
quiz_user_permissions[quiz.id] = {
can_update: can_manage,
can_unpublish: can_manage && quiz.can_unpublish?
@ -97,9 +84,8 @@ class Quizzes::QuizzesController < ApplicationController
end
end
assignment_quizzes = quizzes.select{ |q| q.quiz_type == QUIZ_TYPE_ASSIGNMENT }
open_quizzes = quizzes.select{ |q| q.quiz_type == QUIZ_TYPE_PRACTICE }
surveys = quizzes.select{ |q| QUIZ_TYPE_SURVEYS.include?(q.quiz_type) }
practice_quizzes = scoped_quizzes.select{ |q| q.quiz_type == QUIZ_TYPE_PRACTICE }
surveys = scoped_quizzes.select{ |q| QUIZ_TYPE_SURVEYS.include?(q.quiz_type) }
serializer_options = [@context, @current_user, session, {
permissions: quiz_options,
skip_date_overrides: true,
@ -113,8 +99,8 @@ class Quizzes::QuizzesController < ApplicationController
hash = {
:QUIZZES => {
assignment: quizzes_json(assignment_quizzes, *serializer_options),
open: quizzes_json(open_quizzes, *serializer_options),
assignment: assignment_quizzes_json(serializer_options),
open: quizzes_json(practice_quizzes, *serializer_options),
surveys: quizzes_json(surveys, *serializer_options),
options: quiz_options
},
@ -1041,6 +1027,18 @@ class Quizzes::QuizzesController < ApplicationController
quiz_lti_tool.url != 'http://void.url.inseng.net'
end
def assignment_quizzes_json(serializer_options)
old_quizzes = scoped_quizzes.select{ |q| q.quiz_type == QUIZ_TYPE_ASSIGNMENT }
unless @context.root_account.feature_enabled?(:newquizzes_on_quiz_page)
return quizzes_json(old_quizzes, *serializer_options)
end
new_quizzes = Assignments::ScopedToUser.new(@context, @current_user).scope.preload(:duplicate_of).select(&:quiz_lti?)
quizzes_next_json(
(old_quizzes + new_quizzes).sort_by(&:created_at),
*serializer_options
)
end
protected
def get_quiz_params
@ -1057,4 +1055,23 @@ class Quizzes::QuizzesController < ApplicationController
def hide_quiz?
!@submission.posted?
end
def scoped_quizzes
return @_quizzes if @_quizzes
scope = @context.quizzes.active.preload(:assignment)
# students only get to see published quizzes, and they will fetch the
# overrides later using the API:
scope = scope.available unless @context.grants_right?(@current_user, session, :read_as_admin)
scope = DifferentiableAssignment.scope_filter(scope, @current_user, @context)
@_quizzes = scope.sort_by do |quiz|
due_date = quiz.assignment ? quiz.assignment.due_at : quiz.lock_at
[
due_date || CanvasSort::Last,
Canvas::ICU.collation_key(quiz.title || CanvasSort::First)
]
end
end
end

View File

@ -0,0 +1,82 @@
#
# Copyright (C) 2019 - 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/>.
module QuizzesNext
class QuizSerializer < Canvas::APISerializer
include PermissionsSerializer
root :quiz
attributes :id, :title, :description, :quiz_type, :due_at, :lock_at, :unlock_at,
:published, :points_possible, :can_update,
:assignment_id, :assignment_group_id, :migration_id, :only_visible_to_overrides,
:post_to_sis, :allowed_attempts, :permissions,
:html_url, :mobile_url, :can_duplicate,
:course_id, :original_course_id, :original_assignment_id,
:workflow_state, :original_assignment_name
def_delegators :@controller
def quiz_type
'quizzes.next'
end
def published
object.published?
end
def assignment_id
object.id
end
def can_update
object.grants_right?(current_user, :update)
end
def html_url
controller.send(:course_assignment_url, context, object)
end
def mobile_url
controller.send(:course_assignment_url, context, quiz, persist_headless: 1, force_user: 1)
end
def can_duplicate
object.can_duplicate?
end
def course_id
object.context.id
end
def original_course_id
object.duplicate_of&.context_id
end
def original_assignment_id
object.duplicate_of&.id
end
def original_assignment_name
object.duplicate_of&.title
end
def stringify_ids?
!!(accepts_jsonapi? || stringify_json_ids?)
end
end
end

View File

@ -21,3 +21,4 @@
@import "pages/shared/message_students";
@import "components/ui.selectmenu";
@import "components/conditional_release";
@import "components/spinner";

View File

@ -9,6 +9,35 @@
class="ig-row"
{{/if}}
>
{{#if isDuplicating}}
<div class="ig-row__layout">
{{>spinner}} {{#t}}Making a copy of "{{original_assignment_name}}"{{/t}}
</div>
{{else}} {{#if failedToDuplicate}}
<span aria-live="polite" role="alert" aria-atomic="true">{{#t}}Oops! Something went wrong with making a copy of "{{original_assignment_name}}"{{/t}}</span>
<div class="duplicate-failed-actions">
<button class="duplicate-failed-retry btn btn-primary">
<span class="screenreader-only">{{#t}}Retry duplicating "{{original_assignment_name}}"{{/t}}</span>
<span aria-hidden="true">{{#t}}Retry{{/t}}</span>
</button>
<button class="duplicate-failed-cancel btn">
<span class="screenreader-only">{{#t}}Cancel duplicating "{{original_assignment_name}}"{{/t}}</span>
<span aria-hidden="true">{{#t}}Cancel{{/t}}</span>
</button>
</div>
{{else}} {{#if isImporting}}
<div class="ig-row__layout">
{{>spinner}} {{#t}}Importing "{{name}}"{{/t}}
</div>
{{else}} {{#if failedToImport}}
<span aria-live="polite" role="alert" aria-atomic="true">{{#t}}Oops! Something went wrong importing "{{name}}"{{/t}}</span>
<div class="import-failed-actions">
<button class="import-failed-cancel btn">
<span class="screenreader-only">{{#t}}Cancel importing "{{name}}"{{/t}}</span>
<span aria-hidden="true">{{#t}}Cancel{{/t}}</span>
</button>
</div>
{{else}}
<div class="ig-row__layout">
<div class="ig-type-icon">
@ -60,6 +89,16 @@
<li role="presentation">
<a href="{{edit_url}}" id="ui-id-{{id}}-2" class="icon-edit" tabindex="-1" role="menuitem" title='{{#t}}Edit Quiz{{/t}}'>{{#t}}Edit{{/t}}</a>
</li>
{{#if canDuplicate}}
<li>
<a
class="duplicate_assignment icon-copy-course"
id="assignment_{{id}}_settings_duplicate_item"
aria-label="{{#t}}Duplicate Quiz {{name}}{{/t}}"
data-focus-returns-to"assign_{{id}}_manage_link"
>{{#t}}Duplicate{{/t}}</a>
</li>
{{/if}}
{{#if cyoe.isCyoeAble}}
<li role="presentation">
<a href="{{edit_url}}?return_to={{return_to}}#mastery-paths-editor" class="icon-mastery-path" tabindex="-1" role="menuitem" title="{{#t}}Edit Mastery Paths for {{title_label}}{{/t}}">{{#t}}Mastery Paths{{/t}}</a>
@ -94,4 +133,5 @@
{{/if}}
</div>
{{/if}}{{/if}}{{/if}}{{/if}}
</div>

View File

@ -369,6 +369,9 @@ CanvasRails::Application.routes.draw do
post 'quizzes/unpublish' => 'quizzes/quizzes#unpublish'
post 'quizzes/:id/toggle_post_to_sis' => "quizzes/quizzes#toggle_post_to_sis"
post 'assignments/publish/quiz' => 'assignments#publish_quizzes'
post 'assignments/unpublish/quiz' => 'assignments#unpublish_quizzes'
post 'quizzes/new' => 'quizzes/quizzes#new' # use POST instead of GET (not idempotent)
resources :quizzes, controller: 'quizzes/quizzes', except: :new do
get :managed_quiz_data

View File

@ -0,0 +1,36 @@
#
# Copyright (C) 2011 - 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/>.
#
module Api::V1::QuizzesNext::Quiz
extend Api::V1::Quiz
def quizzes_next_json(quizzes, context, user, session, options={})
quizzes.map do |quiz|
quiz_json(quiz, context, user, session, options, klass(quiz))
end
end
private
def klass(quiz)
return QuizzesNext::QuizSerializer if quiz.is_a?(Assignment)
return Quizzes::QuizSerializer if quiz.is_a?(Quizzes::Quiz)
raise ArgumentError, 'An invalid quiz object is passed to quizzes_next_json'
end
end

View File

@ -1354,6 +1354,34 @@ describe AssignmentsApiController, type: :request do
new_assignment = duplicated_assignments.where.not(id: failed_assignment.id).first
expect(new_assignment.workflow_state).to eq('duplicating')
end
context "when result_type is specified (Quizzes.Next serialization)" do
before do
@course.root_account.enable_feature!(:newquizzes_on_quiz_page)
end
it "outputs quiz shell json using quizzes.next serializer" do
url = "/api/v1/courses/#{@course.id}/assignments/#{assignment.id}/duplicate.json" \
"?target_assignment_id=#{failed_assignment.id}&target_course_id=#{course_copied.id}" \
"&result_type=Quiz"
json = api_call_as_user(
@teacher, :post,
url,
{
controller: "assignments_api",
action: "duplicate",
format: "json",
course_id: @course.id.to_s,
assignment_id: assignment.id.to_s,
target_assignment_id: failed_assignment.id,
target_course_id: course_copied.id,
result_type: 'Quiz'
}
)
expect(json['quiz_type']).to eq('quizzes.next')
end
end
end
end
@ -4504,6 +4532,25 @@ describe AssignmentsApiController, type: :request do
expect(uri.query).to include('assignment_id=')
end
end
context "when result_type is specified (Quizzes.Next serialization)" do
before do
@course.root_account.enable_feature!(:newquizzes_on_quiz_page)
end
it "outputs quiz shell json using quizzes.next serializer" do
@assignment = @course.assignments.create!(:title => "Test Assignment",:description => "foo")
json = api_call(:get,
"/api/v1/courses/#{@course.id}/assignments/#{@assignment.id}.json",
{ :controller => "assignments_api", :action => "show",
:format => "json", :course_id => @course.id.to_s,
:id => @assignment.id.to_s,
:all_dates => true,
result_type: 'Quiz'},
{:override_assignment_dates => 'false'})
expect(json['quiz_type']).to eq('quizzes.next')
end
end
end
context "draft state" do

View File

@ -177,6 +177,42 @@ test('checks for no multiple due dates from quiz overrides', () => {
ok(!quiz.multipleDueDates())
})
QUnit.module('Quiz.Next', {
setup() {
this.quiz = new Quiz({
id: 7,
html_url: 'http://localhost:3000/courses/1/assignments/7',
assignment_id: 7,
quiz_type: 'quizzes.next'
})
this.ajaxStub = sandbox.stub($, 'ajaxJSON')
},
teardown() {}
})
test('#initialize model record id', function() {
equal(this.quiz.id, 'assignment_7')
})
test('#initialize should set url from html url', function() {
equal(this.quiz.get('url'), 'http://localhost:3000/courses/1/assignments/7')
})
test('#initialize should set edit_url from html url', function() {
equal(this.quiz.get('edit_url'), 'http://localhost:3000/courses/1/assignments/7/edit')
})
test('#initialize should set publish_url from html url', function() {
equal(this.quiz.get('publish_url'), 'http://localhost:3000/courses/1/assignments/publish/quiz')
})
test('#initialize should set unpublish_url from html url', function() {
equal(
this.quiz.get('unpublish_url'),
'http://localhost:3000/courses/1/assignments/unpublish/quiz'
)
})
QUnit.module('Quiz#allDates')
test('gets the due dates from the assignment overrides', () => {
@ -290,3 +326,69 @@ test('includes singleSectionDueDate', () => {
const json = quiz.toView()
equal(json.singleSectionDueDate, dueAt.toISOString())
})
QUnit.module('Quiz#duplicate')
test('make ajax call with right url when duplicate is called', () => {
const assignmentID = '200'
const courseID = '123'
const quiz = new Quiz({
name: 'foo',
id: assignmentID,
course_id: courseID
})
const spy = sandbox.spy($, 'ajaxJSON')
quiz.duplicate()
ok(spy.withArgs(`/api/v1/courses/${courseID}/assignments/${assignmentID}/duplicate`).calledOnce)
})
QUnit.module('Quiz#duplicate_failed')
test('make ajax call with right url when duplicate_failed is called', () => {
const assignmentID = '200'
const originalAssignmentID = '42'
const courseID = '123'
const originalCourseID = '234'
const quiz = new Quiz({
name: 'foo',
id: assignmentID,
original_assignment_id: originalAssignmentID,
course_id: courseID,
original_course_id: originalCourseID
})
const spy = sandbox.spy($, 'ajaxJSON')
quiz.duplicate_failed()
ok(
spy.withArgs(
`/api/v1/courses/${originalCourseID}/assignments/${originalAssignmentID}/duplicate?target_assignment_id=${assignmentID}&target_course_id=${courseID}`
).calledOnce
)
})
QUnit.module('Assignment#pollUntilFinishedLoading', {
setup() {
this.clock = sinon.useFakeTimers()
this.quiz = new Quiz({workflow_state: 'duplicating'})
sandbox.stub(this.quiz, 'fetch').returns($.Deferred().resolve())
},
teardown() {
this.clock.restore()
}
})
test('polls for updates', function() {
this.quiz.pollUntilFinishedLoading(4000)
this.clock.tick(2000)
notOk(this.quiz.fetch.called)
this.clock.tick(3000)
ok(this.quiz.fetch.called)
})
test('stops polling when the quiz has finished duplicating', function() {
this.quiz.pollUntilFinishedLoading(3000)
this.quiz.set({workflow_state: 'unpublished'})
this.clock.tick(3000)
ok(this.quiz.fetch.calledOnce)
this.clock.tick(3000)
ok(this.quiz.fetch.calledOnce)
})

View File

@ -432,3 +432,94 @@ test('renders direct share menu items when DIRECT_SHARE_ENABLED', () => {
equal(view.$('.quiz-copy-to').length, 1)
equal(view.$('.quiz-send-to').length, 1)
})
test('can duplicate when a quiz can be duplicated', () => {
const quiz = createQuiz({
id: 1,
title: 'Foo',
can_duplicate: true,
can_update: true
})
Object.assign(window.ENV, {current_user_roles: ['admin']})
const view = createView(quiz, {})
const json = view.toJSON()
ok(json.canDuplicate)
equal(view.$('.duplicate_assignment').length, 1)
})
test('duplicate option is not available when a quiz can not be duplicated (old quizzes)', () => {
const quiz = createQuiz({
id: 1,
title: 'Foo',
can_update: true
})
Object.assign(window.ENV, {current_user_roles: ['admin']})
const view = createView(quiz, {})
const json = view.toJSON()
ok(!json.canDuplicate)
equal(view.$('.duplicate_assignment').length, 0)
})
test('clicks on Retry button to trigger another duplicating request', () => {
const quiz = createQuiz({
id: 2,
title: 'Foo Copy',
original_assignment_name: 'Foo',
workflow_state: 'failed_to_duplicate'
})
const dfd = $.Deferred()
const view = createView(quiz)
sandbox.stub(quiz, 'duplicate_failed').returns(dfd)
view.$(`.duplicate-failed-retry`).simulate('click')
ok(quiz.duplicate_failed.called)
})
test('can duplicate when a user has permissons to manage assignments', () => {
const quiz = createQuiz({
id: 1,
title: 'Foo',
can_duplicate: true,
can_update: true
})
Object.assign(window.ENV, {current_user_roles: ['teacher']})
const view = createView(quiz, {canManage: true})
const json = view.toJSON()
ok(json.canDuplicate)
equal(view.$('.duplicate_assignment').length, 1)
})
test('cannot duplicate when user is not admin', () => {
const quiz = createQuiz({
id: 1,
title: 'Foo',
can_duplicate: true,
can_update: true
})
Object.assign(window.ENV, {current_user_roles: ['user']})
const view = createView(quiz, {})
const json = view.toJSON()
ok(!json.canDuplicate)
equal(view.$('.duplicate_assignment').length, 0)
})
test('displays duplicating message when assignment is duplicating', () => {
const quiz = createQuiz({
id: 2,
title: 'Foo Copy',
original_assignment_name: 'Foo',
workflow_state: 'duplicating'
})
const view = createView(quiz)
ok(view.$el.text().includes('Making a copy of "Foo"'))
})
test('displays failed to duplicate message when assignment failed to duplicate', () => {
const quiz = createQuiz({
id: 2,
title: 'Foo Copy',
original_assignment_name: 'Foo',
workflow_state: 'failed_to_duplicate'
})
const view = createView(quiz)
ok(view.$el.text().includes('Something went wrong with making a copy of "Foo"'))
})

View File

@ -1559,6 +1559,41 @@ describe AssignmentsController do
end
end
describe "POST 'publish'" do
it "should require authorization" do
post 'publish_quizzes', params: { course_id: @course.id, quizzes: [@assignment.id] }
assert_unauthorized
end
it "should publish unpublished assignments" do
user_session(@teacher)
@assignment = @course.assignments.build(title: 'New quiz!', workflow_state: 'unpublished')
@assignment.save!
expect(@assignment).not_to be_published
post 'publish_quizzes', params: { course_id: @course.id, quizzes: [@assignment.id] }
expect(@assignment.reload).to be_published
end
end
describe "POST 'unpublish'" do
it "should require authorization" do
post 'unpublish_quizzes', params: { course_id: @course.id, quizzes: [@assignment.id] }
assert_unauthorized
end
it "should unpublish published quizzes" do
user_session(@teacher)
@assignment = @course.assignments.create(title: 'New quiz!', workflow_state: 'published')
expect(@assignment).to be_published
post 'unpublish_quizzes', params: { course_id: @course.id, quizzes: [@assignment.id] }
expect(@assignment.reload).not_to be_published
end
end
describe "GET list_google_docs" do
it "passes errors through to Canvas::Errors" do
user_session(@teacher)

View File

@ -272,6 +272,79 @@ describe Quizzes::QuizzesController do
expect(assigns[:js_env][:FLAGS][:DIRECT_SHARE_ENABLED]).to eq(false)
end
end
context 'when newquizzes_on_quiz_page FF is enabled' do
let_once(:course_assignments) do
group = @course.assignment_groups.create(:name => "some group")
(0..3).map do |i|
@course.assignments.create(
title: "some assignment #{i}",
assignment_group: group,
due_at: Time.zone.now + 1.week,
external_tool_tag_attributes: { content: tool },
workflow_state: workflow_states[i]
)
end
end
let_once(:course_quizzes) do
[course_quiz, course_quiz(true)]
end
let_once(:workflow_states) do
[:unpublished, :published, :unpublished, :published]
end
let_once(:tool) do
@course.context_external_tools.create!(
name: 'Quizzes.Next',
consumer_key: 'test_key',
shared_secret: 'test_secret',
tool_id: 'Quizzes 2',
url: 'http://example.com/launch'
)
end
before :once do
@course.root_account.settings[:provision] = {'lti' => 'lti url'}
@course.root_account.save!
@course.root_account.enable_feature! :quizzes_next
@course.enable_feature! :quizzes_next
@course.root_account.enable_feature! :newquizzes_on_quiz_page
# make the last two of course_assignments to be quiz_lti assignment
(2..3).each { |i| course_assignments[i].quiz_lti! && course_assignments[i].save! }
course_quizzes
end
context "teacher interface" do
it "includes all old quizzes and new quizzes" do
user_session(@teacher)
get 'index', params: { course_id: @course.id }
expect(controller.js_env[:QUIZZES][:assignment]).not_to be_nil
expect(controller.js_env[:QUIZZES][:assignment].count).to eq(4)
expect(
controller.js_env[:QUIZZES][:assignment].map{ |x| x[:id] }
).to contain_exactly(
course_quizzes[0].id,
course_quizzes[1].id,
course_assignments[2].id,
course_assignments[3].id
)
end
end
context "student interface" do
it "includes published quizzes" do
user_session(@student)
get 'index', params: { course_id: @course.id }
expect(controller.js_env[:QUIZZES][:assignment]).not_to be_nil
expect(controller.js_env[:QUIZZES][:assignment].count).to eq(2)
expect(
controller.js_env[:QUIZZES][:assignment].map{ |x| x[:id] }
).to contain_exactly(course_quizzes[1].id, course_assignments[3].id)
end
end
end
end
describe "POST 'new'" do

View File

@ -0,0 +1,136 @@
#
# Copyright (C) 2019 - 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/>.
require 'spec_helper'
describe QuizzesNext::QuizSerializer do
subject { quiz_serializer.as_json }
let(:original_context) do
Account.default.courses.create
end
let(:original_assignment) do
group = original_context.assignment_groups.create(:name => "some group 1")
original_context.assignments.create(
title: 'some assignment 1',
assignment_group: group,
due_at: Time.zone.now + 1.week,
workflow_state: 'published'
)
end
let(:context) do
Account.default.courses.create
end
let(:assignment) do
group = context.assignment_groups.create(:name => "some group")
context.assignments.create(
title: 'some assignment',
assignment_group: group,
due_at: Time.zone.now + 1.week,
workflow_state: 'published',
duplicate_of: original_assignment
)
end
let(:user) { User.create }
let(:session) { double(:[] => nil) }
let(:controller) do
ActiveModel::FakeController.new(accepts_jsonapi: false, stringify_json_ids: false)
end
let(:quiz_serializer) do
QuizzesNext::QuizSerializer.new(assignment, {
controller: controller,
scope: user,
session: session,
root: false
})
end
before do
allow(controller).to receive(:session).and_return session
allow(controller).to receive(:context).and_return context
allow(assignment).to receive(:grants_right?).at_least(:once).and_return true
allow(context).to receive(:grants_right?).at_least(:once).and_return true
end
[
:id, :title, :description, :due_at, :lock_at, :unlock_at,
:points_possible,
:assignment_group_id, :migration_id, :only_visible_to_overrides,
:post_to_sis, :allowed_attempts,
:workflow_state
].each do |attribute|
it "serializes #{attribute}" do
expect(subject[attribute]).to eq assignment.send(attribute)
end
end
describe "#quiz_type" do
it "serializes quiz_type" do
expect(subject[:quiz_type]).to eq('quizzes.next')
end
end
describe "#published" do
it "serializes published" do
expect(subject[:published]).to be(true)
end
end
describe "#course_id" do
it "serializes course_id" do
expect(subject[:course_id]).to eq(context.id)
end
end
describe "#assignment_id" do
it "serializes assignment_id" do
expect(subject[:assignment_id]).to eq(assignment.id)
end
end
describe "#original_course_id" do
it "serializes original_course_id" do
expect(subject[:original_course_id]).to eq(original_context.id)
end
end
describe "#original_assignment_id" do
it "serializes original_assignment_id" do
expect(subject[:original_assignment_id]).to eq(original_assignment.id)
end
end
describe "#original_assignment_name" do
it "serializes original_assignment_name" do
expect(subject[:original_assignment_name]).to eq('some assignment 1')
end
end
describe "permissions" do
it "serializes permissions" do
expect(subject[:permissions]).to include({
read: true,
create: true,
update: true,
delete: true
})
end
end
end