diff --git a/app/coffeescripts/bundles/epub_exports.coffee b/app/coffeescripts/bundles/epub_exports.coffee new file mode 100644 index 00000000000..ce6e17dc002 --- /dev/null +++ b/app/coffeescripts/bundles/epub_exports.coffee @@ -0,0 +1,9 @@ +require [ + 'jquery' + 'react' + 'jsx/epub_exports/App' +], ($, React, EpubExportsApp) -> + $('.course-epub-exports-app').each( (_i, element) -> + component = React.createElement(EpubExportsApp) + React.render(component, element) + ) diff --git a/app/controllers/content_exports_controller.rb b/app/controllers/content_exports_controller.rb index 7eda2dc3097..df8122b0cd2 100644 --- a/app/controllers/content_exports_controller.rb +++ b/app/controllers/content_exports_controller.rb @@ -27,16 +27,13 @@ class ContentExportsController < ApplicationController end def index - @exports = @context.content_exports_visible_to(@current_user).active.not_for_copy.order('created_at DESC') - - @current_export_id = nil - if export = @context.content_exports_visible_to(@current_user).running.first - @current_export_id = export.id - end + scope = @context.content_exports_visible_to(@current_user).without_epub + @exports = scope.active.not_for_copy.order('content_exports.created_at DESC') + @current_export_id = scope.running.first.try(:id) end def show - if params[:id].present? && export = @context.content_exports_visible_to(@current_user).where(id: params[:id]).first + if params[:id].present? && (export = @context.content_exports_visible_to(@current_user).where(id: params[:id]).first) render_export(export) else render :json => {:errors => {:base => t('errors.not_found', "Export does not exist")}}, :status => :not_found diff --git a/app/controllers/epub_exports_controller.rb b/app/controllers/epub_exports_controller.rb new file mode 100644 index 00000000000..9f9db4905b7 --- /dev/null +++ b/app/controllers/epub_exports_controller.rb @@ -0,0 +1,159 @@ +# +# Copyright (C) 2015 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 . +# + +# @API ePub Exports +# @beta +# +# API for exporting courses as an ePub +# +# @model CourseEpubExport +# { +# "id": "CourseEpubExport", +# "description": "Combination of a Course & EpubExport.", +# "properties": { +# "id": { +# "description": "the unique identifier for the course", +# "example": 101, +# "type": "integer" +# }, +# "name": { +# "description": "the name for the course", +# "example": "Maths 101", +# "type": "string" +# }, +# "epub_export": { +# "description": "ePub export API object", +# "$ref": "EpubExport" +# } +# } +# } +# +# @model EpubExport +# { +# "id": "EpubExport", +# "description": "", +# "properties": { +# "id": { +# "description": "the unique identifier for the export", +# "example": 101, +# "type": "integer" +# }, +# "created_at": { +# "description": "the date and time this export was requested", +# "example": "2014-01-01T00:00:00Z", +# "type": "datetime" +# }, +# "attachment": { +# "description": "attachment api object for the export ePub (not present until the export completes)", +# "example": "{\"url\"=>\"https://example.com/api/v1/attachments/789?download_frd=1&verifier=bG9sY2F0cyEh\"}", +# "$ref": "File" +# }, +# "progress_url": { +# "description": "The api endpoint for polling the current progress", +# "example": "https://example.com/api/v1/progress/4", +# "type": "string" +# }, +# "user_id": { +# "description": "The ID of the user who started the export", +# "example": 4, +# "type": "integer" +# }, +# "workflow_state": { +# "description": "Current state of the ePub export: created exporting exported generating generated failed", +# "example": "exported", +# "type": "string", +# "allowableValues": { +# "values": [ +# "created", +# "exporting", +# "exported", +# "generating", +# "generated", +# "failed" +# ] +# } +# } +# } +# } + +class EpubExportsController < ApplicationController + include Api::V1::EpubExport + + before_filter :require_user + before_filter :require_context, :only => [:create] + + # API List courses with their latest ePub export + # + # Lists all courses a user is actively participating in, + # and the latest ePub export associated with the user & course. + # + # @return [CourseEpubExport] + def index + @courses = @current_user.current_and_concluded_courses.preload(:epub_exports) + respond_to do |format| + format.html + format.json do + render json: { + courses: @courses.map { |course| course_epub_export_json(course) } + } + end + end + end + + # API Create ePub Export + # + # Begin an ePub export for a course. + # + # You can use the {api:ProgressController#show Progress API} to track the + # progress of the export. The export's progress is linked to with the + # _progress_url_ value. + # + # When the export completes, use the {api:EpubExportsController#show Show content export} endpoint + # to retrieve a download URL for the exported content. + # + # @returns EpubExport + def create + if authorized_action(EpubExport.new(course: @context), @current_user, :create) + @course = Course.find(params[:course_id]) + @epub_export_service = EpubExports::CreateService.new(@course, @current_user) + status = @epub_export_service.save ? 201 : 422 + respond_to do |format| + format.json do + render({ + status: status, json: course_epub_export_json(@course) + }) + end + end + end + end + + # @API Show ePub export + # + # Get information about a single ePub export. + # + # @returns EpubExport + def show + @course = Course.find(params[:course_id]) + @epub_export = @course.epub_exports.where(id: params[:id]).first + if authorized_action(@epub_export, @current_user, :read) + respond_to do |format| + format.json { render json: course_epub_export_json(@course) } + end + end + end +end diff --git a/app/jsx/epub_exports/App.jsx b/app/jsx/epub_exports/App.jsx new file mode 100644 index 00000000000..fb83bd7a929 --- /dev/null +++ b/app/jsx/epub_exports/App.jsx @@ -0,0 +1,47 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'jsx/epub_exports/CourseStore', + 'jsx/epub_exports/CourseList' +], function(React, CourseStore, CourseList){ + + var EpubExportApp = React.createClass({ + displayName: 'EpubExportApp', + + // + // Preparation + // + + getInitialState: function() { + return CourseStore.getState(); + }, + handleCourseStoreChange () { + this.setState(CourseStore.getState()); + }, + + // + // Lifecycle + // + + componentDidMount () { + CourseStore.addChangeListener(this.handleCourseStoreChange); + CourseStore.getAll(); + }, + componentWillUnmount () { + CourseStore.removeChangeListener(this.handleCourseStoreChange); + }, + + // + // Rendering + // + + render() { + return ( + + ); + } + }); + + return EpubExportApp; +}); diff --git a/app/jsx/epub_exports/CourseList.jsx b/app/jsx/epub_exports/CourseList.jsx new file mode 100644 index 00000000000..1267321b0f7 --- /dev/null +++ b/app/jsx/epub_exports/CourseList.jsx @@ -0,0 +1,31 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'underscore', + 'jsx/epub_exports/CourseListItem' +], function(React, _, CourseListItem){ + + var CourseList = React.createClass({ + displayName: 'CourseList', + propTypes: { + courses: React.PropTypes.object, + }, + + // + // Rendering + // + + render() { + return ( + + ); + } + }); + + return CourseList; +}); diff --git a/app/jsx/epub_exports/CourseListItem.jsx b/app/jsx/epub_exports/CourseListItem.jsx new file mode 100644 index 00000000000..1fb89276a95 --- /dev/null +++ b/app/jsx/epub_exports/CourseListItem.jsx @@ -0,0 +1,96 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'underscore', + 'jsx/epub_exports/GenerateLink', + 'jsx/epub_exports/DownloadLink', + 'jsx/shared/ApiProgressBar', + 'jsx/epub_exports/CourseStore', + 'i18n!epub_exports', + 'jsx/files/FriendlyDatetime' +], function(React, _, GenerateLink, DownloadLink, ApiProgressBar, CourseEpubExportStore, I18n, FriendlyDatetime) { + var CourseListItem = React.createClass({ + displayName: 'CourseListItem', + propTypes: { + course: React.PropTypes.object.isRequired + }, + + epubExport () { + return this.props.course.epub_export || {}; + }, + + // + // Rendering + // + + getDisplayState() { + var state; + + if (_.isEmpty(this.epubExport())) { + return null; + }; + + switch(this.epubExport().workflow_state) { + case 'generated': + state = I18n.t("Generated:"); + break; + case 'failed': + state = I18n.t("Failed:"); + break; + default: + state = I18n.t("Generating:"); + }; + return state; + }, + getDisplayTimestamp() { + var timestamp; + + if (_.isEmpty(this.epubExport())) { + return null; + }; + timestamp = this.epubExport().updated_at; + + return ; + }, + + render() { + var course = this.props.course; + + return ( +
  • +
    +
    + + {course.name} + +
    +
    + {this.getDisplayState()} {this.getDisplayTimestamp()} +
    +
    +
    + + + +
    +
    +
    +
  • + ); + }, + + // + // Callbacks + // + + _onComplete () { + CourseEpubExportStore.get(this.props.course.id, this.epubExport().id); + }, + }); + + return CourseListItem; +}); + diff --git a/app/jsx/epub_exports/CourseStore.jsx b/app/jsx/epub_exports/CourseStore.jsx new file mode 100644 index 00000000000..f9b4c1d950c --- /dev/null +++ b/app/jsx/epub_exports/CourseStore.jsx @@ -0,0 +1,38 @@ +/** @jsx */ + +define([ + 'react', + 'underscore', + 'jsx/shared/helpers/createStore', + 'jquery' +], (React, _, createStore, $) => { + var CourseEpubExportStore = createStore({}), + _courses = {}; + + CourseEpubExportStore.getAll = function() { + $.getJSON('/api/v1/epub_exports', function(data) { + _.each(data.courses, function(course) { + _courses[course.id] = course; + }); + CourseEpubExportStore.setState(_courses); + }); + } + + CourseEpubExportStore.get = function(course_id, id) { + var url = '/api/v1/courses/' + course_id + '/epub_exports/' + id; + $.getJSON(url, function(data) { + _courses[data.id] = data; + CourseEpubExportStore.setState(_courses); + }); + } + + CourseEpubExportStore.create = function(id) { + var url = '/api/v1/courses/' + id + '/epub_exports'; + $.post(url, {}, function(data) { + _courses[data.id] = data; + CourseEpubExportStore.setState(_courses); + }, 'json'); + } + + return CourseEpubExportStore; +}) diff --git a/app/jsx/epub_exports/DownloadLink.jsx b/app/jsx/epub_exports/DownloadLink.jsx new file mode 100644 index 00000000000..d465351892f --- /dev/null +++ b/app/jsx/epub_exports/DownloadLink.jsx @@ -0,0 +1,46 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'i18n!epub_exports', + 'underscore' +], function(React, I18n, _){ + + var DownloadLink = React.createClass({ + displayName: 'DownloadLink', + propTypes: { + course: React.PropTypes.object.isRequired + }, + + epubExport () { + return this.props.course.epub_export || {}; + }, + showDownloadLink () { + return _.isObject(this.epubExport().permissions) && + this.epubExport().permissions.download; + }, + + // + // Rendering + // + + render() { + var url; + + if (!this.showDownloadLink()) + return null; + + if (_.isObject(this.epubExport().attachment)) { + url = this.epubExport().attachment.url; + }; + + return ( + + {I18n.t("Download")} + + ); + } + }); + + return DownloadLink; +}); diff --git a/app/jsx/epub_exports/GenerateLink.jsx b/app/jsx/epub_exports/GenerateLink.jsx new file mode 100644 index 00000000000..5ff87cf491a --- /dev/null +++ b/app/jsx/epub_exports/GenerateLink.jsx @@ -0,0 +1,91 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'i18n!epub_exports', + 'bower/classnames/index', + 'underscore', + 'jsx/epub_exports/CourseStore' +], function(React, I18n, classnames, _, CourseEpubExportStore){ + + var GenerateLink = React.createClass({ + displayName: 'GenerateLink', + propTypes: { + course: React.PropTypes.object.isRequired + }, + + epubExport () { + return this.props.course.epub_export || {}; + }, + showGenerateLink () { + return _.isEmpty(this.epubExport()) || ( + _.isObject(this.epubExport().permissions) && + this.epubExport().permissions.regenerate + ); + }, + + // + // Preparation + // + + getInitialState: function() { + return { + triggered: false + }; + }, + + // + // Rendering + // + + render: function() { + var text = {}; + + if (!this.showGenerateLink() && !this.state.triggered) + return null; + + text[I18n.t("Regenerate ePub")] = + _.isObject(this.props.course.epub_export) && + !this.state.triggered; + text[I18n.t("Generate ePub")] = + !_.isObject(this.props.course.epub_export) && + !this.state.triggered; + text[I18n.t("Generating...")] = this.state.triggered; + + if (this.state.triggered) { + return ( + + + {classnames(text)} + + ); + } else { + return ( + + ); + }; + }, + + // + // Event handling + // + + _onClick: function(e) { + e.preventDefault(); + this.setState({ + triggered: true + }); + setTimeout(function() { + this.setState({ + triggered: false + }); + }.bind(this), 800); + CourseEpubExportStore.create(this.props.course.id); + } + }); + + return GenerateLink; +}); diff --git a/app/jsx/shared/ApiProgressBar.jsx b/app/jsx/shared/ApiProgressBar.jsx new file mode 100644 index 00000000000..102647d40e3 --- /dev/null +++ b/app/jsx/shared/ApiProgressBar.jsx @@ -0,0 +1,111 @@ +/** @jsx React.DOM */ + +define([ + 'react', + 'underscore', + 'jsx/shared/stores/ProgressStore', + 'jsx/shared/ProgressBar' +], function(React, _, ProgressStore, ProgressBar){ + var ApiProgressBar = React.createClass({ + displayName: 'ProgressBar', + propTypes: { + progress_id: React.PropTypes.string, + onComplete: React.PropTypes.func, + delay: React.PropTypes.number + }, + intervalID: null, + + // + // Preparation + // + + getDefaultProps(){ + return { + delay: 1000 + } + }, + getInitialState () { + return { + completion: 0, + workflow_state: null + } + }, + + // + // Lifecycle + // + + componentDidMount () { + ProgressStore.addChangeListener(this.handleStoreChange); + this.intervalID = setInterval(this.poll, this.props.delay); + }, + componentWillUnmount () { + ProgressStore.removeChangeListener(this.handleStoreChange); + if (!_.isNull(this.intervalID)) { + clearInterval(this.intervalID); + this.intervalID = null; + }; + }, + + shouldComponentUpdate (nextProps, nextState) { + return this.state.workflow_state != nextState.workflow_state || + this.props.progress_id != nextProps.progress_id; + }, + componentDidUpdate () { + if (this.isComplete()) { + if (!_.isNull(this.intervalID)) { + clearInterval(this.intervalID); + this.intervalID = null; + }; + + if (!_.isUndefined(this.props.onComplete)) { + this.props.onComplete(); + }; + }; + }, + + // + // Custom Helpers + // + + handleStoreChange () { + var progress = ProgressStore.getState()[this.props.progress_id]; + + if (_.isObject(progress)) { + this.setState({ + completion: progress.completion, + workflow_state: progress.workflow_state + }); + }; + }, + isComplete () { + return _.contains(['completed', 'failed'], this.state.workflow_state); + }, + isInProgress () { + return _.contains(['queued', 'running'], this.state.workflow_state); + }, + poll () { + if (!_.isUndefined(this.props.progress_id)) { + ProgressStore.get(this.props.progress_id); + } + }, + + // + // Render + // + + render() { + if (!this.isInProgress()) { + return null; + }; + + return ( +
    + +
    + ); + } + }); + + return ApiProgressBar; +}); diff --git a/app/jsx/shared/helpers/createStore.jsx b/app/jsx/shared/helpers/createStore.jsx index 0958a9fd0df..c6098b99546 100644 --- a/app/jsx/shared/helpers/createStore.jsx +++ b/app/jsx/shared/helpers/createStore.jsx @@ -48,6 +48,11 @@ define(['underscore', 'Backbone'], function(_, Backbone) { return state; }, + clearState() { + state = {}; + this.emitChange() + }, + addChangeListener (listener) { events.on('change', listener); }, diff --git a/app/jsx/shared/stores/ProgressStore.jsx b/app/jsx/shared/stores/ProgressStore.jsx new file mode 100644 index 00000000000..70f03cfac5a --- /dev/null +++ b/app/jsx/shared/stores/ProgressStore.jsx @@ -0,0 +1,22 @@ +/** @jsx */ + +define([ + 'react', + 'underscore', + 'jsx/shared/helpers/createStore', + 'jquery' +], (React, _, createStore, $) => { + var ProgressStore = createStore({}), + _progresses = {}; + + ProgressStore.get = function(progress_id) { + var url = "/api/v1/progress/" + progress_id; + + $.getJSON(url, function(data) { + _progresses[data.id] = data; + ProgressStore.setState(_progresses); + }); + }; + + return ProgressStore; +}) diff --git a/app/models/content_export.rb b/app/models/content_export.rb index 14ca65a86e6..3121730bf49 100644 --- a/app/models/content_export.rb +++ b/app/models/content_export.rb @@ -19,14 +19,14 @@ class ContentExport < ActiveRecord::Base include Workflow belongs_to :context, :polymorphic => true - belongs_to :user belongs_to :attachment belongs_to :content_migration has_many :attachments, :as => :context, :dependent => :destroy + has_one :epub_export has_a_broadcast_policy serialize :settings - attr_accessible :context + attr_accessible :context, :export_type, :user, :selected_content, :progress validates_presence_of :context_id, :workflow_state validates_inclusion_of :context_type, :in => ['Course', 'Group', 'User'] @@ -61,7 +61,7 @@ class ContentExport < ActiveRecord::Base p.whenever {|record| record.changed_state(:exported) && record.send_notification? } - + p.dispatch :content_export_failed p.to { [user] } p.whenever {|record| @@ -127,6 +127,7 @@ class ContentExport < ActiveRecord::Base self.job_progress.try :fail! ensure self.save + epub_export.try(:mark_exported) || true end end @@ -200,11 +201,11 @@ class ContentExport < ActiveRecord::Base def zip_export? self.export_type == ZIP end - + def error_message self.settings[:errors] ? self.settings[:errors].last : nil end - + def error_messages self.settings[:errors] ||= [] end @@ -226,8 +227,8 @@ class ContentExport < ActiveRecord::Base end # Method Summary - # Takes in an ActiveRecord object. Determines if the item being - # checked should be exported or not. + # Takes in an ActiveRecord object. Determines if the item being + # checked should be exported or not. # # Returns: bool def export_object?(obj, asset_type=nil) @@ -249,7 +250,7 @@ class ContentExport < ActiveRecord::Base # Takes a symbol containing the items that were selected to export. # is_set? will return true if the item is selected. Also handles # a case where 'everything' is set and returns true - # + # # Returns: bool def export_symbol?(symbol) selected_content.empty? || is_set?(selected_content[symbol]) || is_set?(selected_content[:everything]) @@ -264,7 +265,7 @@ class ContentExport < ActiveRecord::Base selected_content[asset_type] ||= {} selected_content[asset_type][select_content_key(obj)] = true end - + def add_error(user_message, exception_or_info=nil) self.settings[:errors] ||= [] er = nil @@ -283,11 +284,11 @@ class ContentExport < ActiveRecord::Base def root_account self.context.try_rescue(:root_account) end - + def running? ['created', 'exporting'].member? self.workflow_state end - + alias_method :destroy!, :destroy def destroy self.workflow_state = 'deleted' @@ -298,27 +299,35 @@ class ContentExport < ActiveRecord::Base def settings read_attribute(:settings) || write_attribute(:settings,{}.with_indifferent_access) end - + def fast_update_progress(val) content_migration.update_conversion_progress(val) if content_migration self.progress = val ContentExport.where(:id => self).update_all(:progress=>val) self.job_progress.try(:update_completion!, val) end - - scope :active, -> { where("workflow_state<>'deleted'") } - scope :not_for_copy, -> { where("export_type<>?", COURSE_COPY) } - scope :common_cartridge, -> { where(:export_type => COMMON_CARTRIDGE) } - scope :qti, -> { where(:export_type => QTI) } - scope :course_copy, -> { where(:export_type => COURSE_COPY) } - scope :running, -> { where(:workflow_state => ['created', 'exporting']) } - scope :admin, ->(user) { where("export_type NOT IN (?) OR user_id=?", [ZIP, USER_DATA], user) } - scope :non_admin, ->(user) { where("export_type IN (?) AND user_id=?", [ZIP, USER_DATA], user) } + + scope :active, -> { where("content_exports.workflow_state<>'deleted'") } + scope :not_for_copy, -> { where("content_exports.export_type<>?", COURSE_COPY) } + scope :common_cartridge, -> { where(export_type: COMMON_CARTRIDGE) } + scope :qti, -> { where(export_type: QTI) } + scope :course_copy, -> { where(export_type: COURSE_COPY) } + scope :running, -> { where(workflow_state: ['created', 'exporting']) } + scope :admin, ->(user) { + where("content_exports.export_type NOT IN (?) OR content_exports.user_id=?", [ + ZIP, USER_DATA + ], user) + } + scope :non_admin, ->(user) { + where("content_exports.export_type IN (?) AND content_exports.user_id=?", [ + ZIP, USER_DATA + ], user) + } + scope :without_epub, -> {eager_load(:epub_export).where(epub_exports: {id: nil})} private - def is_set?(option) Canvas::Plugin::value_to_boolean option end - + end diff --git a/app/models/course.rb b/app/models/course.rb index 15059726604..b28f2b8ab3b 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -75,7 +75,8 @@ class Course < ActiveRecord::Base :lock_all_announcements, :public_syllabus, :course_format, - :time_zone + :time_zone, + :organize_epub_by_content_type EXPORTABLE_ATTRIBUTES = [ :id, :name, :account_id, :group_weighting_scheme, :workflow_state, :uuid, :start_at, :conclude_at, :grading_standard_id, :is_public, :allow_student_wiki_edits, @@ -202,6 +203,7 @@ class Course < ActiveRecord::Base has_many :role_overrides, :as => :context has_many :content_migrations, :as => :context has_many :content_exports, :as => :context + has_many :epub_exports, order: :created_at has_many :course_imports has_many :alerts, as: :context, preload: :criteria has_many :appointment_group_contexts, :as => :context @@ -1990,7 +1992,8 @@ class Course < ActiveRecord::Base :storage_quota, :tab_configuration, :allow_wiki_comments, :turnitin_comments, :self_enrollment, :license, :indexed, :locale, :hide_final_grade, :hide_distribution_graphs, - :allow_student_discussion_topics, :allow_student_discussion_editing, :lock_all_announcements ] + :allow_student_discussion_topics, :allow_student_discussion_editing, :lock_all_announcements, + :organize_epub_by_content_type ] end def set_course_dates_if_blank(shift_options) @@ -2532,6 +2535,7 @@ class Course < ActiveRecord::Base add_setting :large_roster, :boolean => true, :default => lambda { |c| c.root_account.large_course_rosters? } add_setting :public_syllabus, :boolean => true, :default => false add_setting :course_format + add_setting :organize_epub_by_content_type, :boolean => true, :default => false add_setting :is_public_to_auth_users, :boolean => true, :default => false add_setting :restrict_student_future_view, :boolean => true, :inherited => true diff --git a/app/models/epub_export.rb b/app/models/epub_export.rb new file mode 100644 index 00000000000..08481c43bfd --- /dev/null +++ b/app/models/epub_export.rb @@ -0,0 +1,111 @@ +class EpubExport < ActiveRecord::Base + include Workflow + + belongs_to :content_export + belongs_to :course + belongs_to :user + has_one :attachment, as: :context, dependent: :destroy + has_one :job_progress, as: :context, class_name: 'Progress' + validates :course_id, :workflow_state, presence: true + + PERCENTAGE_COMPLETE = { + created: 0, + exporting: 25, + exported: 50, + generating: 75, + generated: 100 + }.freeze + + workflow do # percentage completion + state :created # 0% + state :exporting # 25% + state :exported # 50% + state :generating # 75% + state :generated # 100% + state :failed + state :deleted + end + + after_create do + create_job_progress(completion: 0, tag: 'epub_export') + end + + delegate :download_url, to: :attachment, allow_nil: true + delegate :completion, :running?, to: :job_progress, allow_nil: true + + scope :running, -> { where(workflow_state: ['created', 'exporting', 'exported', 'generating']) } + scope :visible_to, ->(user) { where(user_id: user) } + + set_policy do + given do |user| + course.grants_right?(user, :read_as_admin) || + course.grants_right?(user, :participate_as_student) + end + can :create + + given do |user| + self.user == user || course.grants_right?(user, :read_as_admin) + end + can :read + + given do |user| + grants_right?(user, :read) && generated? + end + can :download + + given do |user| + [ 'generated', 'failed' ].include?(workflow_state) && + self.grants_right?(user, :create) + end + can :regenerate + end + + def export + create_content_export!({ + user: user, + export_type: ContentExport::COMMON_CARTRIDGE, + selected_content: { :everything => true }, + progress: 0, + context: course + }) + job_progress.completion = PERCENTAGE_COMPLETE[:exporting] + job_progress.start + update_attribute(:workflow_state, 'exporting') + content_export.export + true + end + handle_asynchronously :export, priority: Delayed::LOW_PRIORITY, max_attempts: 1 + + def mark_exported + if content_export.failed? + fail + else + update_attribute(:workflow_state, 'exported') + job_progress.update_attribute(:completion, PERCENTAGE_COMPLETE[:exported]) + generate + end + end + handle_asynchronously :mark_exported, priority: Delayed::LOW_PRIORITY, max_attempts: 1 + + def generate + job_progress.update_attribute(:completion, PERCENTAGE_COMPLETE[:generating]) + update_attribute(:workflow_state, 'generating') + generate_epub + end + handle_asynchronously :generate, priority: Delayed::LOW_PRIORITY, max_attempts: 1 + + def generate_epub + success + end + handle_asynchronously :generate_epub, priority: Delayed::LOW_PRIORITY, max_attempts: 1 + + def success + job_progress.complete! if job_progress.running? + update_attribute(:workflow_state, 'generated') + end + + def fail + job_progress.try :fail! + update_attribute(:workflow_state, 'failed') + end +end diff --git a/app/models/epub_exports/create_service.rb b/app/models/epub_exports/create_service.rb new file mode 100644 index 00000000000..d889c682ce5 --- /dev/null +++ b/app/models/epub_exports/create_service.rb @@ -0,0 +1,50 @@ +# +# Copyright (C) 2011 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 EpubExports + class CreateService + def initialize(course, user) + @course = course + @user = user + end + attr_reader :course, :user + + def epub_export + unless @_epub_export + @_epub_export = course.epub_exports.visible_to(user).running.first + @_epub_export ||= course.epub_exports.build({ + user: user + }) + end + @_epub_export + end + + def already_running? + !epub_export.new_record? + end + + def save + if !already_running? && epub_export.save + # Queuing jobs always returns nil, yay + epub_export.export + true + else + false + end + end + end +end diff --git a/app/models/progress.rb b/app/models/progress.rb index aaaaf534d39..a3bf4684bf3 100644 --- a/app/models/progress.rb +++ b/app/models/progress.rb @@ -20,9 +20,13 @@ class Progress < ActiveRecord::Base include PolymorphicTypeOverride override_polymorphic_types context_type: {'QuizStatistics' => 'Quizzes::QuizStatistics'} + validates :context_type, inclusion: { + in: [ + 'ContentMigration', 'Course', 'User', 'Quizzes::QuizStatistics', 'Account', + 'GroupCategory', 'ContentExport', 'Assignment', 'Attachment', 'EpubExport' + ] + }, allow_nil: true belongs_to :context, :polymorphic => true - validates_inclusion_of :context_type, :allow_nil => true, :in => ['ContentMigration', 'Course', 'User', - 'Quizzes::QuizStatistics', 'Account', 'GroupCategory', 'ContentExport', 'Assignment', 'Attachment'] belongs_to :user attr_accessible :context, :tag, :completion, :message diff --git a/app/stylesheets/bundles/epub_exports.scss b/app/stylesheets/bundles/epub_exports.scss new file mode 100644 index 00000000000..a5b0d1c6b0f --- /dev/null +++ b/app/stylesheets/bundles/epub_exports.scss @@ -0,0 +1,2 @@ +@import "base/environment"; +@import "components/ProgressBar"; diff --git a/app/views/courses/settings.html.erb b/app/views/courses/settings.html.erb index c0897b24e6f..06ac3bbf0ec 100644 --- a/app/views/courses/settings.html.erb +++ b/app/views/courses/settings.html.erb @@ -273,6 +273,14 @@ TEXT <%= f.select :course_format, format_options %> + + + +
    + <%= f.check_box :organize_epub_by_content_type %> + <%= f.label :organize_epub_by_content_type, t("Organize epub by content type (default is by module).") %> +
    + diff --git a/app/views/epub_exports/index.html.erb b/app/views/epub_exports/index.html.erb new file mode 100644 index 00000000000..a52a7bdefdd --- /dev/null +++ b/app/views/epub_exports/index.html.erb @@ -0,0 +1,26 @@ +<% add_crumb t("Download Course Context") %> +<% css_bundle :epub_exports -%> +<% js_bundle :epub_exports -%> +<% content_for :page_title do %><%= t("Download Course Content") %><% end %> + +
    +

    <%= t("Download Course Content") %>

    +

    + <%= t("Downloading course content allows access to content while offline. \ + Content may include files, pages, assignments, discussion topics or \ + quizzes. Click \"Generate ePub\" for each course and open with any \ + eReader software to view.") %> +

    + +
    +
    +
    +

    + <%= t("Current Courses") %> +

    +
    + +
    +
    +
    +
    diff --git a/app/views/profile/profile.html.erb b/app/views/profile/profile.html.erb index e2e28dab818..d9c9630e335 100644 --- a/app/views/profile/profile.html.erb +++ b/app/views/profile/profile.html.erb @@ -16,6 +16,11 @@ <%= image_tag "unlock.png" %> <%= t('links.disable_mfa', "Disable Multi-Factor Authentication") %> <% end %> <%= t("Download Submissions") %> + <% if @current_user.feature_enabled?(:epub_export)%> + <%= link_to epub_exports_path, class: 'btn button-sidebar-wide' do %> + <%= t("Download Course Content") %> + <% end %> + <% end %> <% if show_request_delete_account %> <%= t('Delete My Account') %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 036d2c25431..6acd64031a0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,8 @@ Dir["{gems,vendor}/plugins/*/config/pre_routes.rb"].each { |pre_routes| CanvasRails::Application.routes.draw do resources :submission_comments, only: :destroy + resources :epub_exports, only: [:index] + get 'inbox' => 'context#inbox' get 'oauth/redirect_proxy' => 'oauth_proxy#redirect_proxy' @@ -1682,6 +1684,18 @@ CanvasRails::Application.routes.draw do get "courses/:course_id/content_list", action: :content_list, as: "course_content_list" end + scope(controller: :epub_exports) do + get 'courses/:course_id/epub_exports/:id', { + action: :show + } + get 'epub_exports', { + action: :index + } + post 'courses/:course_id/epub_exports', { + action: :create + } + end + scope(controller: :grading_standards_api) do get 'courses/:course_id/grading_standards', action: :context_index get 'accounts/:account_id/grading_standards', action: :context_index diff --git a/db/migrate/20150715215932_create_epub_exports.rb b/db/migrate/20150715215932_create_epub_exports.rb new file mode 100644 index 00000000000..d82424eb620 --- /dev/null +++ b/db/migrate/20150715215932_create_epub_exports.rb @@ -0,0 +1,23 @@ +class CreateEpubExports < ActiveRecord::Migration + tag :predeploy + def self.up + create_table :epub_exports do |t| + t.integer :content_export_id, :course_id, :user_id, limit: 8 + t.string :workflow_state, default: "created" + t.timestamps + end + + add_foreign_key_if_not_exists :epub_exports, :users, delay_validation: true + add_foreign_key_if_not_exists :epub_exports, :courses, delay_validation: true + add_foreign_key_if_not_exists :epub_exports, :content_exports, delay_validation: true + + add_index :epub_exports, :user_id + add_index :epub_exports, :course_id + add_index :epub_exports, :content_export_id + + end + + def self.down + drop_table :epub_exports + end +end diff --git a/lib/api/v1/epub_export.rb b/lib/api/v1/epub_export.rb new file mode 100644 index 00000000000..8ed1dd1735b --- /dev/null +++ b/lib/api/v1/epub_export.rb @@ -0,0 +1,46 @@ +# +# Copyright (C) 2015 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::EpubExport + include Api::V1::Attachment + + def course_epub_export_json(course) + api_json(course, @current_user, session, { + only: [ :name, :id ] + }) do |attrs| + if course.epub_exports.any? + attrs.epub_export = epub_export_json(course.epub_exports.last) + end + end + end + + def epub_export_json(epub_export) + api_json(epub_export, @current_user, session, {}, [ + :download, :regenerate + ]) do |attrs| + attrs.progress_id = epub_export.job_progress.id + attrs.progress_url = polymorphic_url([:api_v1, epub_export.job_progress]) + + if epub_export.attachment + attrs.attachment = attachment_json(epub_export.attachment, @current_user, {}, { + can_view_hidden_files: true + }) + end + end + end +end diff --git a/lib/feature.rb b/lib/feature.rb index 3bf76bd4638..37b4854936b 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -129,6 +129,15 @@ END root_opt_in: true, beta: true }, + 'epub_export' => + { + display_name: -> { I18n.t('ePub Export') }, + description: -> { I18n.t(< { display_name: -> { I18n.t('features.html5_first_videos', 'Prefer HTML5 for video playback') }, diff --git a/spec/coffeescripts/jsx/epub_exports/AppSpec.coffee b/spec/coffeescripts/jsx/epub_exports/AppSpec.coffee new file mode 100644 index 00000000000..c239040aae4 --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/AppSpec.coffee @@ -0,0 +1,34 @@ +define [ + 'underscore', + 'react', + 'jsx/epub_exports/App', + 'jsx/epub_exports/CourseStore' +], (_, React, App, CourseEpubExportStore) -> + TestUtils = React.addons.TestUtils + + module 'AppSpec', + setup: -> + @props = { + 1: { + name: 'Maths 101', + id: 1 + }, + 2: { + name: 'Physics 101', + id: 2 + } + } + sinon.stub(CourseEpubExportStore, 'getAll', -> true) + + teardown: -> + CourseEpubExportStore.getAll.restore() + + test 'handeCourseStoreChange', -> + component = TestUtils.renderIntoDocument(App()) + ok _.isEmpty(component.state), 'precondition' + + CourseEpubExportStore.setState(@props) + deepEqual component.state, CourseEpubExportStore.getState(), + 'CourseEpubExportStore.setState should trigger component setState' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + diff --git a/spec/coffeescripts/jsx/epub_exports/CourseEpubExportStoreSpec.coffee b/spec/coffeescripts/jsx/epub_exports/CourseEpubExportStoreSpec.coffee new file mode 100644 index 00000000000..e631e3b5c59 --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/CourseEpubExportStoreSpec.coffee @@ -0,0 +1,76 @@ +define [ + 'underscore', + 'react', + 'jsx/epub_exports/CourseStore' +], (_, React, CourseStore, I18n) -> + TestUtils = React.addons.TestUtils + + module 'CourseEpubExportStoreSpec', + setup: -> + @courses = { + courses: [{ + name: 'Maths 101', + id: 1, + epub_export: { + id: 1 + } + }, { + name: 'Physics 101', + id: 2 + }] + } + @server = sinon.fakeServer.create() + + teardown: -> + CourseStore.clearState() + @server.restore() + + test 'getAll', -> + @server.respondWith('GET', '/api/v1/epub_exports', [ + 200, {'Content-Type': 'application/json'}, + JSON.stringify(@courses) + ]) + ok _.isEmpty(CourseStore.getState()), 'precondition' + CourseStore.getAll() + @server.respond() + + state = CourseStore.getState() + _.each(@courses.courses, (course) -> + deepEqual state[course.id], course + ) + + test 'get', -> + url = "/api/v1/courses/1/epub_exports/1" + course = @courses.courses[0] + @server.respondWith('GET', url, [ + 200, {'Content-Type': 'application/json'}, + JSON.stringify(course) + ]) + ok _.isEmpty(CourseStore.getState()), 'precondition' + CourseStore.get(1, 1) + @server.respond() + + state = CourseStore.getState() + deepEqual state[course.id], course + + test 'create', -> + course_id = 3 + epub_export = { + name: 'Creative Writing', + id: course_id, + epub_export: { + permissions: {}, + workflow_state: 'created' + } + } + @server.respondWith('POST', '/api/v1/courses/' + course_id + '/epub_exports', [ + 200, {'Content-Type': 'application/josn'}, + JSON.stringify(epub_export) + ]) + + ok _.isUndefined(CourseStore.getState()[course_id]), 'precondition' + CourseStore.create(course_id) + @server.respond() + + state = CourseStore.getState() + deepEqual state[course_id], epub_export, 'should add new object to state' diff --git a/spec/coffeescripts/jsx/epub_exports/CourseListItemSpec.coffee b/spec/coffeescripts/jsx/epub_exports/CourseListItemSpec.coffee new file mode 100644 index 00000000000..adc01cb305b --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/CourseListItemSpec.coffee @@ -0,0 +1,38 @@ +define [ + 'underscore', + 'react', + 'jsx/epub_exports/CourseListItem' +], (_, React, CourseListItem, I18n) -> + TestUtils = React.addons.TestUtils + + module 'CourseListItemSpec', + setup: -> + @props = { + course: { + name: 'Maths 101', + id: 1 + } + } + + test 'getDisplayState', -> + component = TestUtils.renderIntoDocument(CourseListItem(@props)) + ok _.isNull(component.getDisplayState()), + 'display state should be null without epub_export' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + @props.course = { + epub_export: { + permissions: {}, + workflow_state: 'generating' + } + } + component = TestUtils.renderIntoDocument(CourseListItem(@props)) + ok !_.isNull(component.getDisplayState()), + 'display state should not be null with epub_export' + ok component.getDisplayState().match('Generating'), 'should include workflow_state' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'render', -> + component = TestUtils.renderIntoDocument(CourseListItem(@props)) + ok !_.isNull(component.getDOMNode()), 'should render with course' + React.unmountComponentAtNode(component.getDOMNode().parentNode) diff --git a/spec/coffeescripts/jsx/epub_exports/CourseListSpec.coffee b/spec/coffeescripts/jsx/epub_exports/CourseListSpec.coffee new file mode 100644 index 00000000000..43e9efb9707 --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/CourseListSpec.coffee @@ -0,0 +1,32 @@ +define [ + 'underscore', + 'react', + 'jsx/epub_exports/CourseList' +], (_, React, CourseList, I18n) -> + TestUtils = React.addons.TestUtils + + module 'CourseListSpec', + setup: -> + @props = { + 1: { + name: 'Maths 101', + id: 1 + }, + 2: { + name: 'Physics 101', + id: 2 + } + } + + test 'render', -> + component = TestUtils.renderIntoDocument(CourseList(courses: {})) + node = component.getDOMNode() + equal node.querySelectorAll('li').length, 0, 'should not render list items' + React.unmountComponentAtNode(node.parentNode) + + component = TestUtils.renderIntoDocument(CourseList(courses: @props)) + node = component.getDOMNode() + equal node.querySelectorAll('li').length, Object.keys(@props).length, + 'should have an li element per course in @props' + + React.unmountComponentAtNode(node.parentNode) diff --git a/spec/coffeescripts/jsx/epub_exports/DownloadLinkSpec.coffee b/spec/coffeescripts/jsx/epub_exports/DownloadLinkSpec.coffee new file mode 100644 index 00000000000..199b20f0da6 --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/DownloadLinkSpec.coffee @@ -0,0 +1,60 @@ +define [ + 'underscore', + 'react', + 'jsx/epub_exports/DownloadLink', + 'i18n!epub_exports', +], (_, React, DownloadLink, I18n) -> + TestUtils = React.addons.TestUtils + + module 'DownloadLink', + setup: -> + @props = { + course: { + name: 'Maths 101', + id: 1 + } + } + + test 'state showDownloadLink', -> + component = TestUtils.renderIntoDocument(DownloadLink(@props)) + ok !component.showDownloadLink(), 'should be false without epub_export object' + + @props.course.epub_export = { + permissions: { + download: false + } + } + component = TestUtils.renderIntoDocument(DownloadLink(@props)) + ok !component.showDownloadLink(), 'should be false without permissions to download' + + @props.course.epub_export = { + attachment: { + url: 'http://download.url' + }, + permissions: { + download: true + } + } + component = TestUtils.renderIntoDocument(DownloadLink(@props)) + ok component.showDownloadLink(), 'should be true with permissions to download' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'render', -> + component = TestUtils.renderIntoDocument(DownloadLink(@props)) + node = component.getDOMNode() + ok _.isNull(node) + + @props.course.epub_export = { + attachment: { + url: 'http://download.url' + }, + permissions: { + download: true + } + } + component = TestUtils.renderIntoDocument(DownloadLink(@props)) + node = component.getDOMNode() + equal node.tagName, 'A', 'tag should be link' + ok node.textContent.match(I18n.t("Download")), + 'should show download text' + React.unmountComponentAtNode(component.getDOMNode().parentNode) diff --git a/spec/coffeescripts/jsx/epub_exports/GenerateLinkSpec.coffee b/spec/coffeescripts/jsx/epub_exports/GenerateLinkSpec.coffee new file mode 100644 index 00000000000..2b45d2d48ce --- /dev/null +++ b/spec/coffeescripts/jsx/epub_exports/GenerateLinkSpec.coffee @@ -0,0 +1,87 @@ +define [ + 'jquery', + 'react', + 'jsx/epub_exports/GenerateLink', + 'jsx/epub_exports/CourseStore', + 'i18n!epub_exports', +], ($, React, GenerateLink, CourseEpubExportStore, I18n) -> + TestUtils = React.addons.TestUtils + + module 'GenerateLink', + setup: -> + @props = { + course: { + name: 'Maths 101', + id: 1 + } + } + + test 'showGenerateLink', -> + component = TestUtils.renderIntoDocument(GenerateLink(@props)) + ok component.showGenerateLink(), 'should be true without epub_export object' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + @props.course.epub_export = { + permissions: { + regenerate: false + } + } + component = TestUtils.renderIntoDocument(GenerateLink(@props)) + ok !component.showGenerateLink(), 'should be false without permissions to rengenerate' + + @props.course.epub_export = { + permissions: { + regenerate: true + } + } + component = TestUtils.renderIntoDocument(GenerateLink(@props)) + ok component.showGenerateLink(), 'should be true with permissions to rengenerate' + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'state triggered', -> + clock = sinon.useFakeTimers() + sinon.stub(CourseEpubExportStore, 'create') + component = TestUtils.renderIntoDocument(GenerateLink(@props)) + node = component.getDOMNode() + + TestUtils.Simulate.click(node) + ok component.state.triggered, 'should set state to triggered' + + clock.tick(1005) + ok !component.state.triggered, 'should toggle back to not triggered after 1000' + + clock.restore() + CourseEpubExportStore.create.restore() + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'render', -> + clock = sinon.useFakeTimers() + sinon.stub(CourseEpubExportStore, 'create') + + component = TestUtils.renderIntoDocument(GenerateLink(@props)) + node = component.getDOMNode() + equal node.tagName, 'BUTTON', 'tag should be a button' + ok node.querySelector('span').textContent.match(I18n.t("Generate ePub")), + 'should show generate text' + + TestUtils.Simulate.click(node) + node = component.getDOMNode() + equal node.tagName, 'SPAN', 'tag should be span' + ok node.textContent.match(I18n.t("Generating...")), + 'should show generating text' + + @props.course.epub_export = { + permissions: { + regenerate: true + } + } + component.setProps(@props) + clock.tick(2000) + node = component.getDOMNode() + equal node.tagName, 'BUTTON', 'tag should be a button' + ok node.querySelector('span').textContent.match(I18n.t("Regenerate ePub")), + 'should show regenerate text' + + clock.restore() + CourseEpubExportStore.create.restore() + React.unmountComponentAtNode(component.getDOMNode().parentNode) diff --git a/spec/coffeescripts/jsx/shared/ApiProgressBarSpec.coffee b/spec/coffeescripts/jsx/shared/ApiProgressBarSpec.coffee new file mode 100644 index 00000000000..e8fd350fe45 --- /dev/null +++ b/spec/coffeescripts/jsx/shared/ApiProgressBarSpec.coffee @@ -0,0 +1,145 @@ +define [ + 'underscore', + 'react', + 'jsx/shared/ApiProgressBar' + 'jsx/shared/stores/ProgressStore' +], (_, React, ApiProgressBar, ProgressStore) -> + TestUtils = React.addons.TestUtils + + module 'ApiProgressBarSpec', + setup: -> + @progress_id = '1' + @progress = { + id: @progress_id, + context_id: 1, + context_type: 'EpubExport', + user_id: 1, + tag: 'epub_export', + completion: 0, + workflow_state: 'queued' + } + @store_state = {} + @store_state[@progress_id] = @progress + @storeSpy = sinon.stub(ProgressStore, 'get', (=> + ProgressStore.setState(@store_state) + )) + @clock = sinon.useFakeTimers() + + teardown: -> + ProgressStore.get.restore() + @clock.restore() + + test 'shouldComponentUpdate', -> + component = TestUtils.renderIntoDocument(ApiProgressBar()) + + ok component.shouldComponentUpdate({ + progress_id: @progress_id + }, {}), 'should update when progress_id prop changes' + + ok component.shouldComponentUpdate({}, { + workflow_state: 'running' + }), 'should update when state changes' + + component.setProps(progress_id: @progress_id) + component.setState(workflow_state: 'running') + + ok !component.shouldComponentUpdate({ + progress_id: @progress_id + }, { + workflow_state: component.state.workflow_state + }), 'should not update if state & props are the same' + + test 'componentDidUpdate', -> + onCompleteSpy = sinon.spy() + component = TestUtils.renderIntoDocument(ApiProgressBar({ + onComplete: onCompleteSpy, + progress_id: @progress_id + })) + @clock.tick(component.props.delay + 5) + ok !_.isNull(component.intervalID), 'should have interval id' + + @progress.workflow_state = 'running' + @clock.tick(component.props.delay + 5) + ok !_.isNull(component.intervalID), 'should have an inverval id after updating to running' + + @progress.workflow_state = 'completed' + @clock.tick(component.props.delay + 5) + ok _.isNull(component.intervalID), 'should not have an inverval id after updating to completed' + ok onCompleteSpy.called, 'should call callback on update if complete' + + test 'handleStoreChange', -> + component = TestUtils.renderIntoDocument(ApiProgressBar({ + progress_id: @progress_id + })) + @clock.tick(component.props.delay + 5) + + _.each [ 'completion', 'workflow_state' ], (stateName) => + equal component.state[stateName], @progress[stateName], + "component #{stateName} should equal progress #{stateName}" + + @progress.workflow_state = 'running' + @progress.completion = 50 + ProgressStore.setState(@store_state) + + _.each [ 'completion', 'workflow_state' ], (stateName) => + equal component.state[stateName], @progress[stateName], + "component #{stateName} should equal progress #{stateName}" + + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'isComplete', -> + component = TestUtils.renderIntoDocument(ApiProgressBar({ + progress_id: @progress_id + })) + @clock.tick(component.props.delay + 5) + + ok !component.isComplete(), 'is not complete if state is queued' + + @progress.workflow_state = 'running' + @clock.tick(component.props.delay + 5) + ok !component.isComplete(), 'is not complete if state is running' + + @progress.workflow_state = 'completed' + @clock.tick(component.props.delay + 5) + ok component.isComplete(), 'is complete if state is completed' + + test 'isInProgress', -> + component = TestUtils.renderIntoDocument(ApiProgressBar({ + progress_id: @progress_id + })) + @clock.tick(component.props.delay + 5) + + ok component.isInProgress(), 'is in progress if state is queued' + + @progress.workflow_state = 'running' + @clock.tick(component.props.delay + 5) + ok component.isInProgress(), 'is in progress if state is running' + + @progress.workflow_state = 'completed' + @clock.tick(component.props.delay + 5) + ok !component.isInProgress(), 'is not in progress if state is completed' + + test 'poll', -> + component = TestUtils.renderIntoDocument(ApiProgressBar()) + component.poll() + ok !@storeSpy.called, + 'should not fetch from progress store without progress id' + + component.setProps(progress_id: @progress_id) + component.poll() + ok @storeSpy.called, 'should fetch when progress id is present' + + React.unmountComponentAtNode(component.getDOMNode().parentNode) + + test 'render', -> + component = TestUtils.renderIntoDocument(ApiProgressBar({ + progress_id: @progress_id + })) + ok _.isNull(component.getDOMNode()), + 'should not render to DOM if is not in progress' + + @clock.tick(component.props.delay + 5) + ok !_.isNull(component.getDOMNode()), + 'should render to DOM if is not in progress' + + React.unmountComponentAtNode(component.getDOMNode().parentNode) diff --git a/spec/coffeescripts/jsx/shared/stores/ProgressStoreSpec.coffee b/spec/coffeescripts/jsx/shared/stores/ProgressStoreSpec.coffee new file mode 100644 index 00000000000..59cbe1ac1b8 --- /dev/null +++ b/spec/coffeescripts/jsx/shared/stores/ProgressStoreSpec.coffee @@ -0,0 +1,36 @@ +define [ + 'underscore', + 'react', + 'jsx/shared/stores/ProgressStore' +], (_, React, ProgressStore, I18n) -> + TestUtils = React.addons.TestUtils + + module 'ProgressStoreSpec', + setup: -> + @progress_id = 1 + @progress = { + id: @progress_id, + context_id: 1, + context_type: 'EpubExport', + user_id: 1, + tag: 'epub_export', + completion: 0, + workflow_state: 'queued' + } + + @server = sinon.fakeServer.create() + + teardown: -> + @server.restore() + + test 'get', -> + @server.respondWith('GET', '/api/v1/progress/' + @progress_id, [ + 200, {'Content-Type': 'application/json'}, + JSON.stringify(@progress) + ]) + ok _.isEmpty(ProgressStore.getState()), 'precondition' + ProgressStore.get(@progress_id) + @server.respond() + + state = ProgressStore.getState() + deepEqual state[@progress.id], @progress diff --git a/spec/controllers/epub_exports_controller_spec.rb b/spec/controllers/epub_exports_controller_spec.rb new file mode 100644 index 00000000000..d8377b4ffe8 --- /dev/null +++ b/spec/controllers/epub_exports_controller_spec.rb @@ -0,0 +1,159 @@ +# +# Copyright (C) 2011 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 File.expand_path(File.dirname(__FILE__) + '/../apis/api_spec_helper') + +describe EpubExportsController do + + before :once do + course_with_teacher(active_all: true) + student_in_course(active_all: true) + end + + describe "GET index, format html" do + context "without user" do + it "should require user to be logged in to access the page" do + get 'index' + assert_unauthorized + end + end + + context "with user" do + before(:once) do + user_session(@student) + @n = @student.courses.count + @n_more = 4 + create_courses(@n_more, { + enroll_user: @student, + enrollment_type: 'StudentEnrollment' + }) + @student.enrollments.last.update_attribute( + :workflow_state, 'completed' + ) + end + + it "should assign collection of courses and render" do + get :index + + expect(response).to render_template(:index) + expect(response).to have_http_status(:success) + expect(assigns(:courses).size).to eq(@n + @n_more) + end + end + end + + describe "GET :index.json", type: :request do + before(:once) do + @n = @student.courses.count + @n_more = 4 + create_courses(@n_more, { + enroll_user: @student, + enrollment_type: 'StudentEnrollment' + }) + @student.enrollments.last.update_attribute( + :workflow_state, 'completed' + ) + end + + it "should return course epub exports" do + json = api_call_as_user(@student, :get, "/api/v1/epub_exports", { + controller: :epub_exports, + action: :index, + format: 'json' + }) + + expect(json['courses'].size).to eq(@n + @n_more) + end + end + + describe "GET :show.json", type: :request do + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student + }) + end + + it "should be success" do + json = api_call_as_user(@student, :get, "/api/v1/courses/#{@course.id}/epub_exports/#{epub_export.id}", { + controller: :epub_exports, + action: :show, + course_id: @course.to_param, + id: epub_export.to_param, + format: 'json' + }) + + expect(json['id']). to eq(@course.id) + expect(json['epub_export']['id']). to eq(epub_export.id) + end + end + + describe "POST :create.json", type: :request do + before :each do + EpubExport.any_instance.stubs(:export).returns(true) + end + + let_once(:url) do + "/api/v1/courses/#{@course.id}/epub_exports" + end + + context "when epub_export doesn't exist" do + it "should return json with newly created epub_export" do + json = api_call_as_user(@student, :post, url, { + action: :create, + controller: :epub_exports, + course_id: @course.id, + format: 'json' + }) + + expect(json['epub_export']['workflow_state']).to eq('created') + end + + it "should create one epub_export" do + expect { + api_call_as_user(@student, :post, url, { + action: :create, + controller: :epub_exports, + course_id: @course.id, + format: 'json' + }) + }.to change{EpubExport.count}.from(0).to(1) + end + end + + context "when there is a running epub_export" do + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student + }) + end + + it "should not create one epub_export" do + expect { + api_call_as_user(@student, :post, url, { + action: :create, + controller: :epub_exports, + course_id: @course.id, + format: 'json' + }, {}, {}, { + expected_status: 422 + }) + }.not_to change{EpubExport.count} + end + end + end +end diff --git a/spec/models/epub_export_spec.rb b/spec/models/epub_export_spec.rb new file mode 100644 index 00000000000..5ee6fb7a1b7 --- /dev/null +++ b/spec/models/epub_export_spec.rb @@ -0,0 +1,194 @@ +# +# Copyright (C) 2011 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 File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb') + +describe EpubExport do + before :once do + course_with_teacher(active_all: true) + student_in_course(active_all: true) + end + + describe "after_create" do + it "should create one job progress" do + expect{@course.epub_exports.create(user: @student)}.to change{Progress.count}.from(0).to(1) + end + end + + describe "#export" do + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student + }) + end + + context "method is successful" do + it "should create one content_export" do + expect{epub_export.export_without_send_later}.to change{ContentExport.count}.from(0).to(1) + end + + it "should set state to 'exporting'" do + epub_export.export_without_send_later + expect(epub_export.workflow_state).to eq 'exporting' + end + + it "should set job_progress completion to 25%" do + epub_export.export_without_send_later + expect(epub_export.job_progress.completion).to eq 25.0 + end + + it "should start job_progress" do + epub_export.export_without_send_later + expect(epub_export.job_progress.reload.running?).to be_truthy + end + end + end + + + describe "mark_exported" do + let_once(:content_export) do + @course.content_exports.create({ + user: @student + }) + end + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student, + content_export: content_export + }) + end + + context "when content export is successful" do + before(:once) do + epub_export.content_export.update_attribute(:workflow_state, 'exported') + epub_export.mark_exported_without_send_later + end + + it "should change the workflow state of epub_export to exported" do + expect(epub_export.workflow_state).to eq 'exported' + end + + it "should set job_progress completion to 50" do + expect(epub_export.job_progress.completion).to eq 50.0 + end + end + + context "when content export is failed" do + it "should change the workflow state of epub_export to failed" do + epub_export.content_export.update_attribute(:workflow_state, 'failed') + epub_export.mark_exported_without_send_later + expect(epub_export.workflow_state).to eq 'failed' + end + end + end + + describe "#generate" do + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student + }).tap do |epub_export| + epub_export.update_attribute(:workflow_state, 'exported') + end + end + + it "should set job_progress completion to 75" do + epub_export.generate_without_send_later + expect(epub_export.job_progress.completion).to eq 75.0 + end + + it "should set state to generating" do + epub_export.generate_without_send_later + expect(epub_export.generating?).to be_truthy + end + end + + describe "permissions" do + describe ":create" do + context "when user can :read_as_admin" do + it "should be able to :create an epub export instance" do + expect(@course.grants_right?(@teacher, :read_as_admin)).to be_truthy, 'precondition' + expect(EpubExport.new(course: @course).grants_right?(@teacher, :create)).to be_truthy + end + end + + context "when user can :participate_as_student" do + it "should be able to :create an epub export instance" do + expect(@course.grants_right?(@student, :participate_as_student)).to be_truthy, 'precondition' + expect(EpubExport.new(course: @course).grants_right?(@student, :create)).to be_truthy + end + end + + context "when user cannot :participate_as_student" do + it "should not be able to :create an epub export" do + student_in_course + expect(@course.grants_right?(@student, :participate_as_student)).to be_falsey, 'precondition' + expect(EpubExport.new(course: @course).grants_right?(@student, :create)).to be_falsey + end + end + end + + describe ":regenerate" do + let_once(:epub_export) do + @course.epub_exports.create(user: @student) + end + + [ "generated", "failed" ].each do |state| + context "when state is #{state}" do + it "should allow regeneration" do + epub_export.update_attribute(:workflow_state, state) + expect(epub_export.grants_right?(@student, :regenerate)).to be_truthy + end + end + end + end + end + + describe "scopes" do + let_once(:epub_export) do + @course.epub_exports.create({ + user: @student + }) + end + + context "running" do + ['created', 'exporting', 'exported', 'generating'].each do |state| + it "should return epub export when workflow_state is #{state}" do + epub_export.update_attribute(:workflow_state, state) + expect(EpubExport.running.count).to eq 1 + end + end + + ['generated', 'failed', 'deleted'].each do |state| + it "should return epub export when workflow_state is #{state}" do + epub_export.update_attribute(:workflow_state, state) + expect(EpubExport.running.count).to eq 0 + end + end + end + + context "visible_to" do + it "should be visible to the user who created the epub export" do + expect(EpubExport.visible_to(@student.id).count).to eq 1 + end + + it "should not be visible to the user who didn't create the epub export" do + expect(EpubExport.visible_to(@teacher.id).count).to eq 0 + end + end + end +end diff --git a/spec/models/epub_exports/create_service_spec.rb b/spec/models/epub_exports/create_service_spec.rb new file mode 100644 index 00000000000..72fd4e962b1 --- /dev/null +++ b/spec/models/epub_exports/create_service_spec.rb @@ -0,0 +1,80 @@ +# +# Copyright (C) 2011 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 File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb') + +describe EpubExports::CreateService do + before :once do + course_with_teacher(active_all: true) + student_in_course(active_all: true) + end + + describe "#save" do + let_once(:create_service) do + EpubExports::CreateService.new(@course, @student) + end + + it "should send save & export to epub_export" do + expect(create_service.epub_export.new_record?).to be_truthy, 'precondition' + create_service.epub_export.expects(:export).once.returns(nil) + expect(create_service.save).to be_truthy + expect(create_service.epub_export.new_record?).to be_falsey + end + end + + describe "#epub_export" do + context "when user has an active epub_export" do + before(:once) do + @epub_export = @course.epub_exports.create(user: @student) + @epub_export.export_without_send_later + @service = EpubExports::CreateService.new(@course, @student) + end + + it "should return said epub_export" do + expect(@service.epub_export).to eq @epub_export + end + end + + context "when user has no active epub_exports" do + it "should return a new epub_export instance" do + service = EpubExports::CreateService.new(@course, @student) + expect(service.epub_export).to be_new_record + end + end + end + + describe "#already_running?" do + context "when user has an active epub_export" do + before(:once) do + @course.epub_exports.create(user: @student).export_without_send_later + @service = EpubExports::CreateService.new(@course, @student) + end + + it "should return true" do + expect(@service.already_running?).to be_truthy + end + end + + context "when user doesn't have an active epub_export" do + it "should return true" do + service = EpubExports::CreateService.new(@course, @student) + expect(service.already_running?).to be_falsey + end + end + end +end