adds UI & backend for offline content

fixes CNVS-21793, CNVS-21794, CNVS-21795

- Adds EpubExport model to manage state of generated epubs.
- Adds controller to list, create & show epub exports.
- Adds a mini react app to manage UI for creating epub exports.
- Adds shared ApiProgressBar react comoponent to diplay a progress bar
  that polls the progress API.
- Updates ContentExport to have a relationship with EpubExport.

test plan:
- Navigate to `/epub_exports`.
- Observe a list of courses that are active & user is enrolled in.
- Click on the Generate button.
- Observe that info about the state of the export and the timestamp are
  added to the middle of the row.
- Observe that a progress bar is displayed while the export is in
  progress.
- Observe that upon completion, the progress bar is replaced by two
  button / links: Download & Regenerate.
- Observe that Download at the moment does nothing... this is because
  the backend is not yet hooked up to generate the epub.
- Observe that the Regenerate button triggers the process all over
  again.

Change-Id: I6cd844baa06db0c6648ad19389d235b89659919c
Reviewed-on: https://gerrit.instructure.com/62135
Tested-by: Jenkins
Reviewed-by: Matt Berns <mberns@instructure.com>
QA-Review: Adam Stone <astone@instructure.com>
Product-Review: Cosme Salazar <cosme@instructure.com>
This commit is contained in:
Abhishek Singh 2015-07-20 15:19:44 -06:00 committed by John Corrigan
parent 0274070a84
commit 4ba84e29a4
36 changed files with 1938 additions and 34 deletions

View File

@ -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)
)

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
# @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

View File

@ -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 (
<CourseList courses={this.state} />
);
}
});
return EpubExportApp;
});

View File

@ -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 (
<ul className='ig-list'>
{_.map(this.props.courses, function(course, key) {
return <CourseListItem key={key} course={course}/>;
})}
</ul>
);
}
});
return CourseList;
});

View File

@ -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 <FriendlyDatetime datetime={timestamp} />;
},
render() {
var course = this.props.course;
return (
<li>
<div className="ig-row">
<div className="ig-row__layout">
<span className="ig-title">
{course.name}
</span>
<div className="ig-details">
<div className="ellipses">
{this.getDisplayState()} {this.getDisplayTimestamp()}
</div>
</div>
<div className="ig-admin epub-exports-admin-controls">
<ApiProgressBar progress_id={this.epubExport().progress_id}
onComplete={this._onComplete}
key={this.epubExport().progress_id} />
<DownloadLink course={this.props.course} />
<GenerateLink course={this.props.course} />
</div>
</div>
</div>
</li>
);
},
//
// Callbacks
//
_onComplete () {
CourseEpubExportStore.get(this.props.course.id, this.epubExport().id);
},
});
return CourseListItem;
});

View File

@ -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;
})

View File

@ -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 (
<a href={url} className="icon-download">
{I18n.t("Download")}
</a>
);
}
});
return DownloadLink;
});

View File

@ -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 (
<span>
<i className="icon-refresh" aria-hidden="true"></i>
{classnames(text)}
</span>
);
} else {
return (
<button className="Button Button--link" onClick={this._onClick}>
<i className="icon-refresh" aria-hidden="true"></i>
{classnames(text)}
</button>
);
};
},
//
// 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;
});

View File

@ -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 (
<div style={{width: '300px'}}>
<ProgressBar progress={this.state.completion} />
</div>
);
}
});
return ApiProgressBar;
});

View File

@ -48,6 +48,11 @@ define(['underscore', 'Backbone'], function(_, Backbone) {
return state;
},
clearState() {
state = {};
this.emitChange()
},
addChangeListener (listener) {
events.on('change', listener);
},

View File

@ -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;
})

View File

@ -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

View File

@ -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

111
app/models/epub_export.rb Normal file
View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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

View File

@ -0,0 +1,2 @@
@import "base/environment";
@import "components/ProgressBar";

View File

@ -273,6 +273,14 @@ TEXT
<td>
<%= f.select :course_format, format_options %>
</td>
</tr><tr>
<td class="form-label"><label for="course_epub_export"><%= before_label('course_epub_export', %{Epub Export}) %></label></td>
<td colspan="3" id="course_visibility">
<div>
<%= 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).") %>
</div>
</td>
</tr><tr>
<td class="form-label"><label for="course_public_description"><%= before_label('course_description', %{Description}) %></label></td>
<td colspan="3">

View File

@ -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 %>
<div>
<h1><%= t("Download Course Content") %></h1>
<p>
<%= 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.") %>
</p>
<div class="item-group-container">
<div class="item-group-condensed">
<div class="ig-header">
<h2 class="ig-header-title">
<%= t("Current Courses") %>
</h2>
</div>
<div class="course-epub-exports-app"></div>
</div>
</div>
</div>

View File

@ -16,6 +16,11 @@
<a href="<%= disable_mfa_path('self') %>" class="btn button-sidebar-wide" id="disable_mfa_link"><%= image_tag "unlock.png" %> <%= t('links.disable_mfa', "Disable Multi-Factor Authentication") %></a>
<% end %>
<a href="<%= dashboard_content_exports_path %>" class="btn button-sidebar-wide"><i class="icon-download"></i> <%= t("Download Submissions") %></a>
<% if @current_user.feature_enabled?(:epub_export)%>
<%= link_to epub_exports_path, class: 'btn button-sidebar-wide' do %>
<i class="icon-download" aria-hidden='true'></i> <%= t("Download Course Content") %>
<% end %>
<% end %>
<% if show_request_delete_account %>
<a href="<%= request_delete_account_link %>" class="btn button-sidebar-wide"> <%= t('Delete My Account') %></a>
<% end %>

View File

@ -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

View File

@ -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

46
lib/api/v1/epub_export.rb Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -129,6 +129,15 @@ END
root_opt_in: true,
beta: true
},
'epub_export' =>
{
display_name: -> { I18n.t('ePub Export') },
description: -> { I18n.t(<<END) },
This enables users to generate and download course ePub.
END
applies_to: 'User',
state: 'hidden'
},
'html5_first_videos' =>
{
display_name: -> { I18n.t('features.html5_first_videos', 'Prefer HTML5 for video playback') },

View File

@ -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)

View File

@ -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'

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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