diff --git a/app/coffeescripts/models/Quiz.js b/app/coffeescripts/models/Quiz.js
index 1416f08cc54..6a1059ff6b9 100644
--- a/app/coffeescripts/models/Quiz.js
+++ b/app/coffeescripts/models/Quiz.js
@@ -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() {
diff --git a/app/coffeescripts/views/quizzes/QuizItemView.js b/app/coffeescripts/views/quizzes/QuizItemView.js
index b78d5bc9335..3cc4bb6ffb5 100644
--- a/app/coffeescripts/views/quizzes/QuizItemView.js
+++ b/app/coffeescripts/views/quizzes/QuizItemView.js
@@ -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()
diff --git a/app/controllers/assignments_api_controller.rb b/app/controllers/assignments_api_controller.rb
index 5a506ba80ed..181ecdab56d 100644
--- a/app/controllers/assignments_api_controller.rb
+++ b/app/controllers/assignments_api_controller.rb
@@ -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
diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb
index fdb764114f9..966d1cba5fb 100644
--- a/app/controllers/assignments_controller.rb
+++ b/app/controllers/assignments_controller.rb
@@ -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)
diff --git a/app/controllers/quizzes/quizzes_controller.rb b/app/controllers/quizzes/quizzes_controller.rb
index 3b8f3cce4c1..df519cc3ccd 100644
--- a/app/controllers/quizzes/quizzes_controller.rb
+++ b/app/controllers/quizzes/quizzes_controller.rb
@@ -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
diff --git a/app/serializers/quizzes_next/quiz_serializer.rb b/app/serializers/quizzes_next/quiz_serializer.rb
new file mode 100644
index 00000000000..ba1c8fb0651
--- /dev/null
+++ b/app/serializers/quizzes_next/quiz_serializer.rb
@@ -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