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 . + +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 diff --git a/app/stylesheets/bundles/quizzes.scss b/app/stylesheets/bundles/quizzes.scss index ecbad40b56b..f78f3df3487 100644 --- a/app/stylesheets/bundles/quizzes.scss +++ b/app/stylesheets/bundles/quizzes.scss @@ -21,3 +21,4 @@ @import "pages/shared/message_students"; @import "components/ui.selectmenu"; @import "components/conditional_release"; +@import "components/spinner"; diff --git a/app/views/jst/quizzes/QuizItemView.handlebars b/app/views/jst/quizzes/QuizItemView.handlebars index d9200ddb13c..ef743f7fc52 100644 --- a/app/views/jst/quizzes/QuizItemView.handlebars +++ b/app/views/jst/quizzes/QuizItemView.handlebars @@ -9,6 +9,35 @@ class="ig-row" {{/if}} > +{{#if isDuplicating}} +
+ {{>spinner}} {{#t}}Making a copy of "{{original_assignment_name}}"{{/t}} +
+{{else}} {{#if failedToDuplicate}} + {{#t}}Oops! Something went wrong with making a copy of "{{original_assignment_name}}"{{/t}} +
+ + +
+{{else}} {{#if isImporting}} +
+ {{>spinner}} {{#t}}Importing "{{name}}"{{/t}} +
+{{else}} {{#if failedToImport}} + {{#t}}Oops! Something went wrong importing "{{name}}"{{/t}} +
+ +
+{{else}}
@@ -60,6 +89,16 @@
  • {{#t}}Edit{{/t}}
  • + {{#if canDuplicate}} +
  • + {{#t}}Duplicate{{/t}} +
  • + {{/if}} {{#if cyoe.isCyoeAble}}
  • {{#t}}Mastery Paths{{/t}} @@ -94,4 +133,5 @@ {{/if}}
  • +{{/if}}{{/if}}{{/if}}{{/if}}
    diff --git a/config/routes.rb b/config/routes.rb index 73251d550da..8235b144f2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/lib/api/v1/quizzes_next/quiz.rb b/lib/api/v1/quizzes_next/quiz.rb new file mode 100644 index 00000000000..75d1c5fdbbc --- /dev/null +++ b/lib/api/v1/quizzes_next/quiz.rb @@ -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 . +# +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 diff --git a/spec/apis/v1/assignments_api_spec.rb b/spec/apis/v1/assignments_api_spec.rb index b6636e3d954..c52e843cf3b 100644 --- a/spec/apis/v1/assignments_api_spec.rb +++ b/spec/apis/v1/assignments_api_spec.rb @@ -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 diff --git a/spec/coffeescripts/models/QuizSpec.js b/spec/coffeescripts/models/QuizSpec.js index e12dd174bb7..baf3c5706b3 100644 --- a/spec/coffeescripts/models/QuizSpec.js +++ b/spec/coffeescripts/models/QuizSpec.js @@ -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) +}) diff --git a/spec/coffeescripts/views/quizzes/QuizItemViewSpec.js b/spec/coffeescripts/views/quizzes/QuizItemViewSpec.js index eeb36e8799a..1a7ae55c313 100644 --- a/spec/coffeescripts/views/quizzes/QuizItemViewSpec.js +++ b/spec/coffeescripts/views/quizzes/QuizItemViewSpec.js @@ -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"')) +}) diff --git a/spec/controllers/assignments_controller_spec.rb b/spec/controllers/assignments_controller_spec.rb index 955245e9c67..58a9a950030 100644 --- a/spec/controllers/assignments_controller_spec.rb +++ b/spec/controllers/assignments_controller_spec.rb @@ -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) diff --git a/spec/controllers/quizzes/quizzes_controller_spec.rb b/spec/controllers/quizzes/quizzes_controller_spec.rb index 56b555a9bc2..9bab0a4ad5f 100644 --- a/spec/controllers/quizzes/quizzes_controller_spec.rb +++ b/spec/controllers/quizzes/quizzes_controller_spec.rb @@ -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 diff --git a/spec/serializers/quizzes_next/quiz_serializer_spec.rb b/spec/serializers/quizzes_next/quiz_serializer_spec.rb new file mode 100644 index 00000000000..a1d2f114803 --- /dev/null +++ b/spec/serializers/quizzes_next/quiz_serializer_spec.rb @@ -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 . + +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