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:
parent
0274070a84
commit
4ba84e29a4
|
@ -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)
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
})
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -48,6 +48,11 @@ define(['underscore', 'Backbone'], function(_, Backbone) {
|
|||
return state;
|
||||
},
|
||||
|
||||
clearState() {
|
||||
state = {};
|
||||
this.emitChange()
|
||||
},
|
||||
|
||||
addChangeListener (listener) {
|
||||
events.on('change', listener);
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
@import "base/environment";
|
||||
@import "components/ProgressBar";
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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') },
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -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'
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue