Commit e60eb7dc authored by pierre.tremouilhac's avatar pierre.tremouilhac
Browse files

Merge branch '737-export-import-collections' into 'development'

Resolve "export import collections"

Closes #737

See merge request ComPlat/chemotion_ELN!970
parents 8344847c c1f7908c
......@@ -65,6 +65,15 @@
/uploads/*
!/public/attachments/.keep
/public/json/*
!/public/json/schema.json
/public/zip/*
!/public/zip/.keep
/public/udm/*
!/public/udm/.keep
!/public/apple-touch-icon.png
/public/docx/*
......
......@@ -80,7 +80,7 @@ gem 'fun_sftp', git: 'https://github.com/fl9/fun_sftp.git',
branch: 'allow-port-option'
# API
gem 'grape', '~>1.2.3'
gem 'grape', '~>1.2.3'
gem 'grape-active_model_serializers'
gem 'grape-kaminari'
gem 'grape-entity'
......@@ -105,7 +105,7 @@ gem 'faraday_middleware', '~> 0.12.1'
gem 'httparty'
gem 'ketcherails', '~> 0.1.6', git: 'https://github.com/ComPlat/ketcher-rails',
ref: 'c8200cfce01f9c4a8d36d22d74964b818f02d6ef'
ref: 'c8200cfce01f9c4a8d36d22d74964b818f02d6ef'
#path: '../ketcher-rails'
# Free font icons
......@@ -151,10 +151,12 @@ gem 'gman'
gem 'ruby-mailchecker'
gem 'swot'
gem 'activejob-status'
group :development, :test do
gem 'binding_of_caller'
gem 'mailcatcher'
gem 'mailcatcher', '0.7.1'
# Call 'byebug' anywhere in the code to stop execution
# and get a debugger console
......
......@@ -105,6 +105,9 @@ GEM
activejob (4.2.11.1)
activesupport (= 4.2.11.1)
globalid (>= 0.3.0)
activejob-status (0.1.5)
activejob (>= 4.2)
activesupport (>= 4.2)
activemodel (4.2.11.1)
activesupport (= 4.2.11.1)
builder (~> 3.1)
......@@ -251,7 +254,7 @@ GEM
equalizer (0.0.11)
erubi (1.8.0)
erubis (2.7.0)
eventmachine (1.2.7)
eventmachine (1.0.9.1)
execjs (2.7.0)
factory_bot (4.11.1)
activesupport (>= 3.0.0)
......@@ -366,16 +369,14 @@ GEM
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
mailcatcher (0.2.4)
eventmachine
haml
i18n
json
mail
sinatra
skinny (>= 0.1.2)
sqlite3-ruby
thin
mailcatcher (0.7.1)
eventmachine (= 1.0.9.1)
mail (~> 2.3)
rack (~> 1.5)
sinatra (~> 1.2)
skinny (~> 0.2.3)
sqlite3 (~> 1.3)
thin (~> 1.5.0)
memoist (0.16.0)
memory_profiler (0.9.13)
meta_request (0.7.0)
......@@ -499,7 +500,7 @@ GEM
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.0)
rspec-support (3.8.2)
rtf (0.3.3)
rubocop (0.68.1)
jaro_winkler (~> 1.5.1)
......@@ -552,9 +553,9 @@ GEM
rack-protection (~> 1.4)
tilt (>= 1.3, < 3)
sixarm_ruby_unaccent (1.2.0)
skinny (0.2.2)
eventmachine (~> 1.0)
thin
skinny (0.2.4)
eventmachine (~> 1.0.0)
thin (>= 1.5, < 1.7)
slackistrano (3.8.4)
capistrano (>= 3.8.1)
spring (2.0.2)
......@@ -568,8 +569,6 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.4.1)
sqlite3-ruby (1.3.3)
sqlite3 (>= 1.3.3)
sshkit (1.18.2)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
......@@ -581,10 +580,10 @@ GEM
ffi
term-ansicolor (1.7.1)
tins (~> 1.0)
thin (1.7.2)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
rack (>= 1, < 3)
thin (1.5.1)
daemons (>= 1.0.9)
eventmachine (>= 0.12.6)
rack (>= 1.0.0)
thor (0.19.1)
thread_safe (0.3.6)
tilt (2.0.9)
......@@ -616,7 +615,7 @@ GEM
webmock (3.5.1)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
hashdiff (>= 0.4.0, < 2.0.0)
whenever (0.11.0)
chronic (>= 0.6.3)
with_advisory_lock (4.0.0)
......@@ -632,6 +631,7 @@ PLATFORMS
DEPENDENCIES
aasm
activejob-status
ancestry
api-pagination
awesome_print
......@@ -689,7 +689,7 @@ DEPENDENCIES
kaminari-grape
ketcherails (~> 0.1.6)!
launchy (~> 2.4.3)
mailcatcher
mailcatcher (= 0.7.1)
memory_profiler
meta_request
net-sftp
......
......@@ -219,12 +219,11 @@ module Chemotion
collection_attributes: params[:collection_attributes].merge(shared_by_id: current_user.id)
).execute!
channel = Channel.find_by(subject: Channel::SHARED_COLLECTION_WITH_ME)
return if channel.nil?
content = channel.msg_template
return if (content.nil?)
content['data'] = content['data'] % {:shared_by => current_user.name }
message = Message.create_msg_notification(channel.id,content,current_user.id,uids)
Message.create_msg_notification(
channel_subject: Channel::SHARED_COLLECTION_WITH_ME,
message_from: current_user.id, message_to: uids,
data_args: { 'shared_by': current_user.name }, level: 'info'
)
end
end
......@@ -334,15 +333,72 @@ module Chemotion
end
namespace :unshared do
desc "Create an unshared collection"
params do
requires :label, type: String, desc: "Collection label"
end
post do
Collection.create(user_id: current_user.id, label: params[:label])
end
end
namespace :exports do
desc "Create export job"
params do
requires :collections, type: Array[Integer]
requires :format, type: Symbol, values: [:json, :zip, :udm]
requires :nested, type: Boolean
end
post do
collection_ids = params[:collections].uniq
nested = params[:nested] == true
if collection_ids.empty?
# no collection was given, export all collections for this user
collection_ids = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).pluck(:id)
else
# check if the user is allowed to export these collections
collection_ids.each do |collection_id|
collection = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).find_by(id: collection_id)
error!('401 Unauthorized', 401) unless collection
end
end
ExportCollectionsJob.perform_later(collection_ids, params[:format].to_s, nested, current_user.id)
status 204
end
end
namespace :imports do
desc "Create import job"
params do
requires :file, type: File
end
post do
file = params[:file]
if tempfile = file[:tempfile]
att = Attachment.new(
bucket: file[:container_id],
filename: file[:filename],
key: file[:name],
file_path: file[:tempfile],
created_by: current_user.id,
created_for: current_user.id,
content_type: file[:type]
)
begin
att.save!
ensure
tempfile.close
tempfile.unlink
end
# run the asyncronous import job and return its id to the client
ImportCollectionsJob.perform_later(att, current_user.id)
status 204
end
end
end
end
......
......@@ -27,6 +27,11 @@ module Chemotion
else
cur = present(messages, with: Entities::MessageEntity, root: 'messages')
end
if messages&.length > 0
job_msgs = messages.select { |hash| hash[:channel_type] == 5 }
job_msgs.each { |msg| Notification.find(msg.id).update!(is_ack: 1) } unless job_msgs&.length == 0
end
cur
end
desc 'Return channels'
......@@ -102,12 +107,12 @@ module Chemotion
optional :user_ids, type: Array, desc: 'notification user ids'
end
post do
content = {}
content['data'] = params[:content]
message = Message.create_msg_notification(params[:channel_id],
content,
current_user.id,
params[:user_ids])
message = Message.create_msg_notification(
channel_id: params[:channel_id],
message_content: { 'data': params[:content] },
message_from: current_user.id,
message_to: params[:user_ids]
)
{ message: message }
end
end
......
......@@ -85,16 +85,11 @@ module Chemotion
end
cp.save!
channel = Channel.find_by(subject: Channel::COMPUTED_PROPS_NOTIFICATION)
return if channel.nil?
content = channel.msg_template
return if content.nil?
content['data'] = "Calculation for Sample #{sample.id} has started"
content['cprop'] = cp
Message.create_msg_notification(
channel.id, content, uid, [uid]
message_subject: Channel::COMPUTED_PROPS_NOTIFICATION,
message_from: uid,
data_args: { sample_id: sample.id, status: 'started' },
cprop: cp, level: 'info'
)
end
end
......
......@@ -2,15 +2,22 @@ module Chemotion
class PublicAPI < Grape::API
helpers do
def send_notification(attachment, user, status, has_error = false)
channel = Channel.find_by(subject: Channel::EDITOR_CALLBACK)
content = channel.msg_template
content['research_plan_id'] = content['research_plan_id'] % {:research_plan_id => attachment.attachable_id }
content['attach_id'] = content['attach_id'] % {:attach_id => attachment.id }
content['data'] = content['data'] % {:filename => attachment.filename }
content['data'] = attachment.filename + ' error has occurred, the file is not changed.' if has_error
content['data'] = attachment.filename + ' file has not changed.' if status == 4
content['data'] = attachment.filename + ' error has occurred while force saving the document, please review your changes.' if @status == 7
message = Message.create_msg_notification(channel.id,content,user.id,[user.id])
data_args = { 'filename': attachment.filename, 'comment': 'the file has been updated' }
level = 'success'
if has_error
data_args['comment'] = ' an error has occurred, the file is not changed.'
level = 'error'
elsif status == 4
data_args['comment'] = ' file has not changed.'
level = 'info'
elsif @status == 7
data_args['comment'] = ' an error has occurred while force saving the document, please review your changes.'
level = 'error'
end
message = Message.create_msg_notification(
message_subject: Channel::EDITOR_CALLBACK, message_from: user.id,
data_args: data_args, attach_id: attachment.id, research_plan_id: attachment.attachable_id, level: level
)
end
end
......@@ -124,16 +131,10 @@ module Chemotion
ComputedProp.from_raw(cp.id, params[:data])
channel = Channel.find_by(subject: Channel::COMPUTED_PROPS_NOTIFICATION)
return if channel.nil?
content = channel.msg_template
return if content.nil?
content['data'] = "Calculation for Sample #{cp.sample_id} has finished"
content['cprop'] = ComputedProp.find(cp.id)
Message.create_msg_notification(
channel.id, content, cp.creator, [cp.creator]
channel_subject: Channel::COMPUTED_PROPS_NOTIFICATION, message_from: cp.creator,
data_args: { sample_id: sample.id, status: 'finished'}, cprop: ComputedProp.find(cp.id),
level: 'success'
)
end
end
......
......@@ -135,13 +135,13 @@ module Chemotion
params[:user_ids] = uids
Usecases::Sharing::SyncWithUsers.new(params, current_user).execute!
channel = Channel.find_by(subject: Channel::SYNCHRONIZED_COLLECTION_WITH_ME)
return if channel.nil?
content = channel.msg_template
return if (content.nil?)
c = Collection.find_by(id: params[:id])
content['data'] = content['data'] % {:synchronized_by => current_user.name, :collection_name => c.label }
message = Message.create_msg_notification(channel.id,content,current_user.id,uids)
Message.create_msg_notification(
channel_subject: Channel::SYNCHRONIZED_COLLECTION_WITH_ME,
message_from: current_user.id, message_to: uids,
data_args: { synchronized_by: current_user.name, collection_name: c.label }, level: 'info'
)
end
desc "delete sync by id"
......
......@@ -154,6 +154,24 @@ class CollectionActions {
downloadReportReaction(reactionId){
Utils.downloadFile({contents: "/api/v1/reports/excel_reaction?id=" + reactionId});
}
exportCollectionsToFile(params) {
return (dispatch) => { CollectionsFetcher.createExportJob(params)
.then((result) => {
dispatch(result);
}).catch((errorMessage) => {
console.log(errorMessage);
});};
}
importCollectionsFromFile(params) {
return (dispatch) => { CollectionsFetcher.createImportJob(params)
.then((result) => {
dispatch(result);
}).catch((errorMessage) => {
console.log(errorMessage);
});};
}
}
export default alt.createActions(CollectionActions);
......@@ -187,7 +187,6 @@ class ElementActions {
// -- Collections --
fetchSamplesByCollectionId(id, queryParams = {}, collectionIsSync = false,
moleculeSort = false) {
return (dispatch) => {
......@@ -199,8 +198,6 @@ class ElementActions {
});};
}
fetchReactionsByCollectionId(id, queryParams={}, collectionIsSync = false) {
return (dispatch) => { ReactionsFetcher.fetchByCollectionId(id, queryParams, collectionIsSync)
.then((result) => {
......@@ -219,7 +216,6 @@ class ElementActions {
});};
}
fetchScreensByCollectionId(id, queryParams={}, collectionIsSync = false) {
return (dispatch) => { ScreensFetcher.fetchByCollectionId(id, queryParams, collectionIsSync)
.then((result) => {
......
......@@ -20,6 +20,31 @@ class NotificationActions {
uploadErrorNotify(message) {
return message;
}
notifyExImportStatus(type, status) {
const params = {
title: `Collection ${type}`,
message: "The task has been submitted: this might take a while but you will be notified as soon as it is completed.",
level: "info",
dismissible: true,
uid: "export_collection",
position: "tr",
autoDismiss: 5
};
switch(status) {
case 204:
break;
case 401:
params.message = `Unauthorized: you do not have the permission to ${type} this collection`;
params.level = 'error';
break;
default:
params.message = `An issue occured with your ${type} (status ${status}); please contact the administrators of the site if the problem persists.`;
params.level = 'error';
}
return this.add(params);
}
}
export default alt.createActions(NotificationActions);
......@@ -2,12 +2,15 @@ import React, {Component} from 'react';
import PropTypes from 'prop-types';
import { Dropdown, Button, MenuItem, Glyphicon } from 'react-bootstrap';
import CollectionActions from '../actions/CollectionActions';
import ElementActions from '../actions/ElementActions';
import UIActions from '../actions/UIActions';
import ModalImport from './ModalImport';
import ModalImportChemScanner from './ModalImportChemScanner';
import ModalExport from './ModalExport';
import ModalReactionExport from './ModalReactionExport';
import ModalExportCollection from './ModalExportCollection';
import ModalImportCollection from './ModalImportCollection';
const ExportImportButton = ({ isDisabled, updateModalProps, customClass }) => (
<Dropdown id='export-dropdown'>
......@@ -28,6 +31,20 @@ const ExportImportButton = ({ isDisabled, updateModalProps, customClass }) => (
title='Import from spreadsheet or sdf'>
Import samples to collection
</MenuItem>
<MenuItem divider />
<MenuItem onSelect={() => exportCollectionFunction(updateModalProps)}
title='Export collections as ZIP archive'>
Export selected collection
</MenuItem>
<MenuItem onSelect={() => importCollectionFunction(updateModalProps)}
title='Import collections from ZIP archive'>
Import collections
</MenuItem>
<MenuItem divider />
<MenuItem onSelect={() => exportCollectionFunctionFull(updateModalProps)}
title='Export all collections as one ZIP archive'>
Export all collections
</MenuItem>
{/* <MenuItem onSelect={() => importChemScannerFunction(updateModalProps)} disabled={isDisabled} */}
{/* title='Import from Docs'> */}
{/* Import elements from Docs */}
......@@ -99,4 +116,59 @@ const exportReactionFunction = (updateModalProps) => {
updateModalProps(modalProps);
}
const exportCollectionFunction = (updateModalProps) => {
const title = "Export Collection as ZIP archive";
const component = ModalExportCollection;
const action = CollectionActions.exportCollectionsToFile;
const full = false;
const listSharedCollections = false;
const modalProps = {
show: true,
title,
component,
action,
full,
listSharedCollections,
};
updateModalProps(modalProps);
}
const exportCollectionFunctionFull = (updateModalProps) => {
const title = "Export all collections as one ZIP archive";
const component = ModalExportCollection;
const action = CollectionActions.exportCollectionsToFile;
const full = true;
const listSharedCollections = false;
const modalProps = {
show: true,
title,
component,
action,
full,
listSharedCollections,
};
updateModalProps(modalProps);
}
const importCollectionFunction = (updateModalProps) => {
const title = "Import Collections from ZIP archive";
const component = ModalImportCollection;
const action = CollectionActions.importCollectionsFromFile;
const listSharedCollections = false;
const modalProps = {
show: true,
title,
component,
action,
listSharedCollections,
};
updateModalProps(modalProps);
};
export default ExportImportButton
import React from 'react';
import {Button, ButtonToolbar} from 'react-bootstrap';
import UIStore from './../stores/UIStore';
export default class ModalExportCollection extends React.Component {
constructor(props) {
super(props);
this.state = {
nested: true,
processing: false
}
this.handleClick = this.handleClick.bind(this)
this.toggleCheckbox = this.toggleCheckbox.bind(this)
}
checkbox() {
return (
<div>
<input type="checkbox"
onChange={this.toggleCheckbox}
checked={this.state.nested}
className="common-checkbox" />
<span className="g-marginLeft--10"> Include nested collections </span>
</div>
)
}
buttonBar() {
const { onHide } = this.props;
const { processing } = this.state;
const bStyle = processing === true ? 'danger' : 'warning';
const bClass = processing === true ? 'fa fa-spinner fa-pulse fa-fw' : 'fa fa-file-text-o';
const bTitle = processing === true ? 'Exporting' : 'Export ZIP';
return (
<ButtonToolbar>
<div className="pull-right">
<ButtonToolbar>
<Button bsStyle="primary" onClick={onHide}>Cancel</Button>
<Button bsStyle={bStyle} id="md-export-dropdown" disabled={this.isDisabled()}
title="Export as ZIP file (incl. attachments)" onClick={this.handleClick}>