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
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@exports = @context.content_exports_visible_to(@current_user).active.not_for_copy.order('created_at DESC')
|
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 = nil
|
@current_export_id = scope.running.first.try(:id)
|
||||||
if export = @context.content_exports_visible_to(@current_user).running.first
|
|
||||||
@current_export_id = export.id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
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)
|
render_export(export)
|
||||||
else
|
else
|
||||||
render :json => {:errors => {:base => t('errors.not_found', "Export does not exist")}}, :status => :not_found
|
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;
|
return state;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearState() {
|
||||||
|
state = {};
|
||||||
|
this.emitChange()
|
||||||
|
},
|
||||||
|
|
||||||
addChangeListener (listener) {
|
addChangeListener (listener) {
|
||||||
events.on('change', 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
|
class ContentExport < ActiveRecord::Base
|
||||||
include Workflow
|
include Workflow
|
||||||
belongs_to :context, :polymorphic => true
|
belongs_to :context, :polymorphic => true
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :attachment
|
belongs_to :attachment
|
||||||
belongs_to :content_migration
|
belongs_to :content_migration
|
||||||
has_many :attachments, :as => :context, :dependent => :destroy
|
has_many :attachments, :as => :context, :dependent => :destroy
|
||||||
|
has_one :epub_export
|
||||||
has_a_broadcast_policy
|
has_a_broadcast_policy
|
||||||
serialize :settings
|
serialize :settings
|
||||||
attr_accessible :context
|
attr_accessible :context, :export_type, :user, :selected_content, :progress
|
||||||
validates_presence_of :context_id, :workflow_state
|
validates_presence_of :context_id, :workflow_state
|
||||||
validates_inclusion_of :context_type, :in => ['Course', 'Group', 'User']
|
validates_inclusion_of :context_type, :in => ['Course', 'Group', 'User']
|
||||||
|
|
||||||
|
@ -127,6 +127,7 @@ class ContentExport < ActiveRecord::Base
|
||||||
self.job_progress.try :fail!
|
self.job_progress.try :fail!
|
||||||
ensure
|
ensure
|
||||||
self.save
|
self.save
|
||||||
|
epub_export.try(:mark_exported) || true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -306,17 +307,25 @@ class ContentExport < ActiveRecord::Base
|
||||||
self.job_progress.try(:update_completion!, val)
|
self.job_progress.try(:update_completion!, val)
|
||||||
end
|
end
|
||||||
|
|
||||||
scope :active, -> { where("workflow_state<>'deleted'") }
|
scope :active, -> { where("content_exports.workflow_state<>'deleted'") }
|
||||||
scope :not_for_copy, -> { where("export_type<>?", COURSE_COPY) }
|
scope :not_for_copy, -> { where("content_exports.export_type<>?", COURSE_COPY) }
|
||||||
scope :common_cartridge, -> { where(:export_type => COMMON_CARTRIDGE) }
|
scope :common_cartridge, -> { where(export_type: COMMON_CARTRIDGE) }
|
||||||
scope :qti, -> { where(:export_type => QTI) }
|
scope :qti, -> { where(export_type: QTI) }
|
||||||
scope :course_copy, -> { where(:export_type => COURSE_COPY) }
|
scope :course_copy, -> { where(export_type: COURSE_COPY) }
|
||||||
scope :running, -> { where(:workflow_state => ['created', 'exporting']) }
|
scope :running, -> { where(workflow_state: ['created', 'exporting']) }
|
||||||
scope :admin, ->(user) { where("export_type NOT IN (?) OR user_id=?", [ZIP, USER_DATA], user) }
|
scope :admin, ->(user) {
|
||||||
scope :non_admin, ->(user) { where("export_type IN (?) AND user_id=?", [ZIP, USER_DATA], 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
|
private
|
||||||
|
|
||||||
def is_set?(option)
|
def is_set?(option)
|
||||||
Canvas::Plugin::value_to_boolean option
|
Canvas::Plugin::value_to_boolean option
|
||||||
end
|
end
|
||||||
|
|
|
@ -75,7 +75,8 @@ class Course < ActiveRecord::Base
|
||||||
:lock_all_announcements,
|
:lock_all_announcements,
|
||||||
:public_syllabus,
|
:public_syllabus,
|
||||||
:course_format,
|
:course_format,
|
||||||
:time_zone
|
:time_zone,
|
||||||
|
:organize_epub_by_content_type
|
||||||
|
|
||||||
EXPORTABLE_ATTRIBUTES = [
|
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,
|
: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 :role_overrides, :as => :context
|
||||||
has_many :content_migrations, :as => :context
|
has_many :content_migrations, :as => :context
|
||||||
has_many :content_exports, :as => :context
|
has_many :content_exports, :as => :context
|
||||||
|
has_many :epub_exports, order: :created_at
|
||||||
has_many :course_imports
|
has_many :course_imports
|
||||||
has_many :alerts, as: :context, preload: :criteria
|
has_many :alerts, as: :context, preload: :criteria
|
||||||
has_many :appointment_group_contexts, :as => :context
|
has_many :appointment_group_contexts, :as => :context
|
||||||
|
@ -1990,7 +1992,8 @@ class Course < ActiveRecord::Base
|
||||||
:storage_quota, :tab_configuration, :allow_wiki_comments,
|
:storage_quota, :tab_configuration, :allow_wiki_comments,
|
||||||
:turnitin_comments, :self_enrollment, :license, :indexed, :locale,
|
:turnitin_comments, :self_enrollment, :license, :indexed, :locale,
|
||||||
:hide_final_grade, :hide_distribution_graphs,
|
: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
|
end
|
||||||
|
|
||||||
def set_course_dates_if_blank(shift_options)
|
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 :large_roster, :boolean => true, :default => lambda { |c| c.root_account.large_course_rosters? }
|
||||||
add_setting :public_syllabus, :boolean => true, :default => false
|
add_setting :public_syllabus, :boolean => true, :default => false
|
||||||
add_setting :course_format
|
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 :is_public_to_auth_users, :boolean => true, :default => false
|
||||||
|
|
||||||
add_setting :restrict_student_future_view, :boolean => true, :inherited => true
|
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
|
include PolymorphicTypeOverride
|
||||||
override_polymorphic_types context_type: {'QuizStatistics' => 'Quizzes::QuizStatistics'}
|
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
|
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
|
belongs_to :user
|
||||||
attr_accessible :context, :tag, :completion, :message
|
attr_accessible :context, :tag, :completion, :message
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
@import "base/environment";
|
||||||
|
@import "components/ProgressBar";
|
|
@ -273,6 +273,14 @@ TEXT
|
||||||
<td>
|
<td>
|
||||||
<%= f.select :course_format, format_options %>
|
<%= f.select :course_format, format_options %>
|
||||||
</td>
|
</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>
|
</tr><tr>
|
||||||
<td class="form-label"><label for="course_public_description"><%= before_label('course_description', %{Description}) %></label></td>
|
<td class="form-label"><label for="course_public_description"><%= before_label('course_description', %{Description}) %></label></td>
|
||||||
<td colspan="3">
|
<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>
|
<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 %>
|
<% end %>
|
||||||
<a href="<%= dashboard_content_exports_path %>" class="btn button-sidebar-wide"><i class="icon-download"></i> <%= t("Download Submissions") %></a>
|
<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 %>
|
<% if show_request_delete_account %>
|
||||||
<a href="<%= request_delete_account_link %>" class="btn button-sidebar-wide"> <%= t('Delete My Account') %></a>
|
<a href="<%= request_delete_account_link %>" class="btn button-sidebar-wide"> <%= t('Delete My Account') %></a>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -8,6 +8,8 @@ Dir["{gems,vendor}/plugins/*/config/pre_routes.rb"].each { |pre_routes|
|
||||||
CanvasRails::Application.routes.draw do
|
CanvasRails::Application.routes.draw do
|
||||||
resources :submission_comments, only: :destroy
|
resources :submission_comments, only: :destroy
|
||||||
|
|
||||||
|
resources :epub_exports, only: [:index]
|
||||||
|
|
||||||
get 'inbox' => 'context#inbox'
|
get 'inbox' => 'context#inbox'
|
||||||
get 'oauth/redirect_proxy' => 'oauth_proxy#redirect_proxy'
|
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"
|
get "courses/:course_id/content_list", action: :content_list, as: "course_content_list"
|
||||||
end
|
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
|
scope(controller: :grading_standards_api) do
|
||||||
get 'courses/:course_id/grading_standards', action: :context_index
|
get 'courses/:course_id/grading_standards', action: :context_index
|
||||||
get 'accounts/:account_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,
|
root_opt_in: true,
|
||||||
beta: 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' =>
|
'html5_first_videos' =>
|
||||||
{
|
{
|
||||||
display_name: -> { I18n.t('features.html5_first_videos', 'Prefer HTML5 for video playback') },
|
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