upload and fetch by url, data object manages import; next step is actual import

Andrew Cantino 10 years ago
parent
commit
5c1fbdb997

+ 21 - 0
app/controllers/scenario_imports_controller.rb

@@ -0,0 +1,21 @@
1
+class ScenarioImportsController < ApplicationController
2
+  def new
3
+    @scenario_import = ScenarioImport.new
4
+  end
5
+
6
+  def create
7
+    @scenario_import = ScenarioImport.new(params[:scenario_import])
8
+    @scenario_import.set_user(current_user)
9
+
10
+    if @scenario_import.valid?
11
+      if @scenario_import.do_import?
12
+        @scenario_import.import!
13
+        redirect_to @scenario_import.scenario, notice: "Import successful!"
14
+      else
15
+        render action: "new"
16
+      end
17
+    else
18
+      render action: "new"
19
+    end
20
+  end
21
+end

+ 80 - 0
app/models/scenario_import.rb

@@ -0,0 +1,80 @@
1
+# This is a helper class for managing Scenario imports.
2
+class ScenarioImport
3
+  include ActiveModel::Model
4
+  include ActiveModel::Callbacks
5
+  include ActiveModel::Validations::Callbacks
6
+
7
+  URL_REGEX = /\Ahttps?:\/\//i
8
+
9
+  attr_accessor :file, :url, :data, :do_import
10
+
11
+  attr_reader :user
12
+
13
+  before_validation :fetch_url
14
+  before_validation :parse_file
15
+
16
+  validate :validate_presence_of_file_url_or_data
17
+  validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid"
18
+  validate :validate_data
19
+
20
+  def step_one?
21
+    data.blank?
22
+  end
23
+
24
+  def step_two?
25
+    valid?
26
+  end
27
+
28
+  def set_user(user)
29
+    @user = user
30
+  end
31
+
32
+  def existing_scenario
33
+    @existing_scenario ||= user.scenarios.find_by_guid(parsed_data["guid"])
34
+  end
35
+
36
+  def parsed_data
37
+    @parsed_data
38
+  end
39
+
40
+  def do_import?
41
+    do_import == "1"
42
+  end
43
+
44
+  def import!
45
+  end
46
+
47
+  def scenario
48
+    existing_scenario
49
+  end
50
+
51
+  protected
52
+
53
+  def parse_file
54
+    if data.blank? && file.present?
55
+      self.data = file.read
56
+    end
57
+  end
58
+
59
+  def fetch_url
60
+    if data.blank? && url.present? && url =~ URL_REGEX
61
+      self.data = Faraday.get(url).body
62
+    end
63
+  end
64
+
65
+  def validate_data
66
+    if data.present?
67
+      @parsed_data = JSON.parse(data) rescue {}
68
+      if (%w[name guid] - @parsed_data.keys).length > 0
69
+        errors.add(:base, "The provided data does not appear to be a valid Scenario.")
70
+        self.data = nil
71
+      end
72
+    end
73
+  end
74
+
75
+  def validate_presence_of_file_url_or_data
76
+    unless file.present? || url.present? || data.present?
77
+      errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.")
78
+    end
79
+  end
80
+end

+ 23 - 0
app/views/scenario_imports/_step_one.html.erb

@@ -0,0 +1,23 @@
1
+<div class="page-header">
2
+  <h2>
3
+    Import a Public Scenario
4
+  </h2>
5
+</div>
6
+
7
+<blockquote>You can import Scenarios, either from a <code>.json</code> file, or via a public Scenario URL.  When you import a Scenario, Huginn will keep track of where it came from and later let you update it.</blockquote>
8
+
9
+<div class="col-md-4">
10
+  <div class="form-group">
11
+    <%= f.label :url, 'Option 1: Provide a Public Scenario URL' %>
12
+    <%= f.text_field :url, :class => 'form-control', :placeholder => "Public Scenario URL" %>
13
+  </div>
14
+
15
+  <div class="form-group">
16
+    <%= f.label :file, 'Option 2: Upload a Scenario JSON File' %>
17
+    <%= f.file_field :file, :class => 'form-control' %>
18
+  </div>
19
+
20
+  <div class='form-actions'>
21
+    <%= f.submit "Start Import", :class => "btn btn-primary" %>
22
+  </div>
23
+</div>

+ 51 - 0
app/views/scenario_imports/_step_two.html.erb

@@ -0,0 +1,51 @@
1
+<div class="col-md-12">
2
+  <div class="page-header">
3
+    <h2><%= @scenario_import.parsed_data["name"] %> (exported <%= time_ago_in_words Time.parse(@scenario_import.parsed_data["exported_at"]) %> ago)</h2>
4
+  </div>
5
+
6
+  <% if @scenario_import.parsed_data["description"].present? %>
7
+    <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote>
8
+  <% end %>
9
+
10
+  <p>
11
+    This import contains <%= pluralize @scenario_import.parsed_data["agents"].length, "Agent" %>:
12
+  </p>
13
+
14
+  <ul class='agent-import-list'>
15
+    <% @scenario_import.parsed_data["agents"].each do |agent_data| %>
16
+      <li>
17
+        <%= link_to agent_data['name'], '#', :class => 'options-toggle' %>
18
+        <span class='text-muted'>
19
+          (<%= agent_data["type"].split("::").pop.titleize %>)
20
+        </span>
21
+        <pre class='options' style='display: none;'><%= Utils.pretty_jsonify agent_data["options"] || {} %></pre>
22
+      </li>
23
+    <% end %>
24
+  </ul>
25
+
26
+  <script>
27
+    $(function() {
28
+      $('.agent-import-list .options-toggle').on('click', function(e) {
29
+        e.preventDefault();
30
+        $(this).siblings('.options').fadeToggle();
31
+      });
32
+    });
33
+  </script>
34
+
35
+  <% if @scenario_import.existing_scenario.present? %>
36
+    <strong>
37
+      This Scenario already exists on your Huginn.
38
+      If you continue, the import will overwrite your existing <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario.
39
+    </strong>
40
+  <% end %>
41
+
42
+  <div class="checkbox">
43
+    <%= f.label :do_import do %>
44
+      <%= f.check_box :do_import %> I confirm that I want to import these Agents.
45
+    <% end %>
46
+  </div>
47
+
48
+  <div class='form-actions'>
49
+    <%= f.submit "Finish Import", :class => "btn btn-primary" %>
50
+  </div>
51
+</div>

+ 34 - 0
app/views/scenario_imports/new.html.erb

@@ -0,0 +1,34 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <% if @scenario_import.errors.any? %>
5
+        <div class="row well">
6
+          <h2><%= pluralize(@scenario_import.errors.count, "error") %> prohibited this Scenario from being imported:</h2>
7
+          <% @scenario_import.errors.full_messages.each do |msg| %>
8
+            <p class='text-warning'><%= msg %></p>
9
+          <% end %>
10
+        </div>
11
+      <% end %>
12
+
13
+      <%= form_for @scenario_import, :multipart => true do |f| %>
14
+        <%= f.hidden_field :data %>
15
+
16
+        <div class="row">
17
+          <% if @scenario_import.step_one? %>
18
+            <%= render 'step_one', :f => f %>
19
+          <% elsif @scenario_import.step_two? %>
20
+            <%= render 'step_two', :f => f %>
21
+          <% end %>
22
+        </div>
23
+      <% end %>
24
+
25
+      <hr />
26
+
27
+      <div class="row">
28
+        <div class="col-md-12">
29
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
30
+        </div>
31
+      </div>
32
+    </div>
33
+  </div>
34
+</div>

+ 2 - 3
app/views/scenarios/index.html.erb

@@ -7,9 +7,7 @@
7 7
         </h2>
8 8
       </div>
9 9
 
10
-      <blockquote>
11
-        Scenarios are named groups of Agents.  Scenarios allow you to organize your agents, and to export sets of Agents for sharing.
12
-      </blockquote>
10
+      <blockquote>Scenarios are named groups of Agents.  Scenarios allow you to organize your agents, and to export sets of Agents for sharing.</blockquote>
13 11
 
14 12
       <table class='table table-striped'>
15 13
         <tr>
@@ -42,6 +40,7 @@
42 40
 
43 41
       <div class="btn-group">
44 42
         <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %>
43
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> Import Scenario'.html_safe, new_scenario_imports_path, class: "btn btn-default" %>
45 44
       </div>
46 45
     </div>
47 46
   </div>

+ 4 - 0
config/routes.rb

@@ -28,6 +28,10 @@ Huginn::Application.routes.draw do
28 28
   end
29 29
 
30 30
   resources :scenarios do
31
+    collection do
32
+      resource :scenario_imports, :only => [:new, :create]
33
+    end
34
+
31 35
     member do
32 36
       get :share
33 37
       get :export

+ 7 - 0
db/migrate/20140602014917_add_indices_to_scenarios.rb

@@ -0,0 +1,7 @@
1
+class AddIndicesToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_index :scenarios, [:user_id, :guid]
4
+    add_index :scenario_memberships, :agent_id
5
+    add_index :scenario_memberships, :scenario_id
6
+  end
7
+end

+ 6 - 1
db/schema.rb

@@ -11,7 +11,7 @@
11 11
 #
12 12
 # It's strongly recommended that you check this file into your version control system.
13 13
 
14
-ActiveRecord::Schema.define(version: 20140531232016) do
14
+ActiveRecord::Schema.define(version: 20140602014917) do
15 15
 
16 16
   create_table "agent_logs", force: true do |t|
17 17
     t.integer  "agent_id",                                       null: false
@@ -97,6 +97,9 @@ ActiveRecord::Schema.define(version: 20140531232016) do
97 97
     t.datetime "updated_at"
98 98
   end
99 99
 
100
+  add_index "scenario_memberships", ["agent_id"], name: "index_scenario_memberships_on_agent_id", using: :btree
101
+  add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree
102
+
100 103
   create_table "scenarios", force: true do |t|
101 104
     t.string   "name",                        null: false
102 105
     t.integer  "user_id",                     null: false
@@ -108,6 +111,8 @@ ActiveRecord::Schema.define(version: 20140531232016) do
108 111
     t.string   "source_url"
109 112
   end
110 113
 
114
+  add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", using: :btree
115
+
111 116
   create_table "user_credentials", force: true do |t|
112 117
     t.integer  "user_id",                           null: false
113 118
     t.string   "credential_name",                   null: false

+ 29 - 0
spec/controllers/scenario_imports_controller_spec.rb

@@ -0,0 +1,29 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImportsController do
4
+  def valid_attributes(options = {})
5
+    { :name => "some_name" }.merge(options)
6
+  end
7
+
8
+  before do
9
+    sign_in users(:bob)
10
+  end
11
+
12
+  describe "GET new" do
13
+    it "initializes a new ScenarioImport and renders new" do
14
+      get :new
15
+      assigns(:scenario_import).should be_a(ScenarioImport)
16
+      response.should render_template(:new)
17
+    end
18
+  end
19
+
20
+  describe "POST create" do
21
+    it "initializes a ScenarioImport for current_user, passing in params" do
22
+      post :create, :scenario_import => { :url => "bad url" }
23
+      assigns(:scenario_import).user.should == users(:bob)
24
+      assigns(:scenario_import).url.should == "bad url"
25
+      response.should render_template(:new)
26
+    end
27
+  end
28
+end
29
+

+ 2 - 1
spec/models/agents/slack_agent_spec.rb

@@ -51,7 +51,8 @@ describe Agents::SlackAgent do
51 51
                        username: @event.payload[:username]
52 52
                       )
53 53
       end
54
-      expect(@checker.receive([@event])).to_not raise_error
54
+
55
+      lambda { @checker.receive([@event]) }.should_not raise_error
55 56
     end
56 57
   end
57 58
 

+ 80 - 0
spec/models/scenario_import_spec.rb

@@ -0,0 +1,80 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImport do
4
+  describe "initialization" do
5
+    it "is initialized with an attributes hash" do
6
+      ScenarioImport.new(:url => "http://google.com").url.should == "http://google.com"
7
+    end
8
+  end
9
+
10
+  describe "validations" do
11
+    subject { ScenarioImport.new }
12
+    let(:valid_json) { { :name => "some scenario", :guid => "someguid" }.to_json }
13
+    let(:invalid_json) { { :name => "some scenario missing a guid" }.to_json }
14
+
15
+    it "is not valid when none of file, url, or data are present" do
16
+      subject.should_not be_valid
17
+      subject.should have(1).error_on(:base)
18
+      subject.errors[:base].should include("Please provide either a Scenario JSON File or a Public Scenario URL.")
19
+    end
20
+
21
+    describe "data" do
22
+      it "should be invalid with invalid data" do
23
+        subject.data = invalid_json
24
+        subject.should_not be_valid
25
+        subject.should have(1).error_on(:base)
26
+
27
+        subject.data = "foo"
28
+        subject.should_not be_valid
29
+        subject.should have(1).error_on(:base)
30
+
31
+        # It also clears the data when invalid
32
+        subject.data.should be_nil
33
+      end
34
+
35
+      it "should be valid with valid data" do
36
+        subject.data = valid_json
37
+        subject.should be_valid
38
+      end
39
+    end
40
+
41
+    describe "url" do
42
+      it "should be invalid with an unreasonable URL" do
43
+        subject.url = "foo"
44
+        subject.should_not be_valid
45
+        subject.should have(1).error_on(:url)
46
+        subject.errors[:url].should include("appears to be invalid")
47
+      end
48
+
49
+      it "should be invalid when the referenced url doesn't contain a scenario" do
50
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_json)
51
+        subject.url = "http://example.com/scenarios/1/export.json"
52
+        subject.should_not be_valid
53
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
54
+      end
55
+
56
+      it "should be valid when the url points to a valid scenario" do
57
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_json)
58
+        subject.url = "http://example.com/scenarios/1/export.json"
59
+        subject.should be_valid
60
+      end
61
+    end
62
+
63
+    describe "file" do
64
+      it "should be invalid when the uploaded file doesn't contain a scenario" do
65
+        subject.file = StringIO.new("foo")
66
+        subject.should_not be_valid
67
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
68
+
69
+        subject.file = StringIO.new(invalid_json)
70
+        subject.should_not be_valid
71
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
72
+      end
73
+
74
+      it "should be valid with a valid uploaded scenario" do
75
+        subject.file = StringIO.new(valid_json)
76
+        subject.should be_valid
77
+      end
78
+    end
79
+  end
80
+end