allow exporting of a set of Agents with their links from a Scenario; Scenario guid is now generated and copied to export, as well as a source link when public

This commit is contained in:
Andrew Cantino 2014-05-31 23:36:33 -07:00
parent eb49a8b203
commit 663250227d
19 changed files with 331 additions and 83 deletions

View file

@ -1,4 +1,6 @@
class ScenariosController < ApplicationController
skip_before_filter :authenticate_user!, :only => :export
def index
@scenarios = current_user.scenarios.page(params[:page])
@ -27,10 +29,8 @@ class ScenariosController < ApplicationController
end
end
# Share is a work in progress!
def share
@scenario = current_user.scenarios.find(params[:id])
@agents = @scenario.agents.preload(:scenarios).page(params[:page])
respond_to do |format|
format.html
@ -38,6 +38,19 @@ class ScenariosController < ApplicationController
end
end
def export
@scenario = Scenario.find(params[:id])
raise ActiveRecord::RecordNotFound unless @scenario.public? || (current_user && current_user.id == @scenario.user_id)
@exporter = AgentsExporter.new(:name => @scenario.name,
:description => @scenario.description,
:guid => @scenario.guid,
:source_url => @scenario.public? && export_scenario_url(@scenario),
:agents => @scenario.agents)
response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'
render :json => JSON.pretty_generate(@exporter.as_json)
end
def edit
@scenario = current_user.scenarios.find(params[:id])

View file

@ -1,16 +1,22 @@
class Scenario < ActiveRecord::Base
attr_accessible :name, :agent_ids
attr_accessible :name, :agent_ids, :description, :public
belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios
before_save :make_guid
validates_presence_of :name, :user
validate :agents_are_owned
protected
def make_guid
self.guid = SecureRandom.hex unless guid.present?
end
def agents_are_owned
errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
end

View file

@ -12,11 +12,28 @@
<div class="col-md-4">
<div class="form-group">
<%= f.label :name %>
<%= f.text_field :name, :class => 'form-control' %>
<%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="form-group">
<%= f.label :description, "Optional Description" %>
<%= f.text_area :description, :rows => 10, :class => 'form-control', :placeholder => "Optionally describe what this set of Agents will do" %>
</div>
<div class="checkbox">
<%= f.label :public do %>
<%= f.check_box :public %> Share this Scenario publicly
<% end %>
<span class="glyphicon glyphicon-question-sign hover-help" data-content="When selected, this Scenario and all Agents in it will be made public. An export URL will be available to share with other Huginn users. Be very careful that you do not have secret credentials stored in these Agents' options. Instead, use Credentials by reference."></span>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="form-group">

View file

@ -20,14 +20,16 @@
<% @scenarios.each do |scenario| %>
<tr>
<td><span class='label label-info'><%= scenario.name %></span></td>
<td>
<%= link_to(scenario.name, scenario, class: "label label-info") %>
</td>
<td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
<td>
<div class="btn-group btn-group-xs" style="float: right">
<%= link_to 'Show', scenario, class: "btn btn-default" %>
<%= link_to 'Edit', edit_scenario_path(scenario), class: "btn btn-default" %>
<%= link_to 'Share', share_scenario_path(scenario), class: "btn btn-default" %>
<%= link_to 'Delete', scenario_path(scenario), method: :delete, data: {confirm: 'Are you sure?'}, class: "btn btn-default" %>
<%= link_to 'Delete', scenario_path(scenario), method: :delete, data: { confirm: "This will remove the '#{scenario.name}' Scenerio from all Agents and delete it. Are you sure?" }, class: "btn btn-default" %>
</div>
</td>
</tr>

View file

@ -5,6 +5,22 @@
<h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
</div>
<p>
<strong>Please be sure that none of the Agents in this Scenario have sensitive data in their settings before sharing!</strong>
</p>
<% if @scenario.public? %>
<p>
This Scenario is public. You can <%= link_to "download and share your export file", export_scenario_path(@scenario) %>, or give out this URL:
</p>
<form onsubmit='return false;'>
<input type='text' class='form-control' value='<%= export_scenario_url(@scenario) %>' onclick="return this.select();"/>
</form>
<% else %>
This Scenario is not public. You can share it by <%= link_to "downloading and sharing your export file", export_scenario_path(@scenario) %>.
<% end %>
<hr>
<div class="row">

View file

@ -2,20 +2,19 @@
<div class='row'>
<div class='col-md-12'>
<div class="page-header">
<h2>Scenario <span class='label label-info scenario'><%= @scenario.name %></span></h2>
<h2><%= "Public" if @scenario.public? %> Scenario <span class='label label-info scenario'><%= @scenario.name %></span></h2>
</div>
<%= render 'agents/table', :returnTo => scenario_path(@scenario) %>
<br/>
<div class="btn-group">
<%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
<%= link_to '<span class="glyphicon glyphicon-edit"></span> Edit'.html_safe, edit_scenario_path(@scenario), class: "btn btn-default" %>
<%= link_to '<span class="glyphicon glyphicon-share-alt"></span> Share'.html_safe, share_scenario_path(@scenario), class: "btn btn-default" %>
<%= link_to '<span class="glyphicon glyphicon-trash"></span> Delete'.html_safe, scenario_path(@scenario), method: :delete, data: { confirm: "This will remove the '#{@scenario.name}' Scenerio from all Agents and delete it. Are you sure?" }, class: "btn btn-default" %>
</div>
<div class="page-header">
<h3>Agents</h3>
</div>
<%= render 'agents/table', :returnTo => scenario_path(@scenario) %>
</div>
</div>
</div>

View file

@ -29,6 +29,7 @@ Huginn::Application.routes.draw do
resources :scenarios do
member do
get :share
get :export
end
end

View file

@ -0,0 +1,8 @@
class AddFieldsToScenarios < ActiveRecord::Migration
def change
add_column :scenarios, :description, :text
add_column :scenarios, :public, :boolean, :default => false, :null => false
add_column :scenarios, :guid, :string, :null => false
add_column :scenarios, :source_url, :string
end
end

View file

@ -9,23 +9,23 @@
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended to check this file into your version control system.
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(:version => 20140408150825) do
ActiveRecord::Schema.define(version: 20140531232016) do
create_table "agent_logs", :force => true do |t|
t.integer "agent_id", :null => false
t.text "message", :null => false
t.integer "level", :default => 3, :null => false
create_table "agent_logs", force: true do |t|
t.integer "agent_id", null: false
t.text "message", limit: 16777215, null: false
t.integer "level", default: 3, null: false
t.integer "inbound_event_id"
t.integer "outbound_event_id"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "agents", :force => true do |t|
create_table "agents", force: true do |t|
t.integer "user_id"
t.text "options"
t.text "options", limit: 16777215
t.string "type"
t.string "name"
t.string "schedule"
@ -33,73 +33,62 @@ ActiveRecord::Schema.define(:version => 20140408150825) do
t.datetime "last_check_at"
t.datetime "last_receive_at"
t.integer "last_checked_event_id"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.text "memory", :limit => 2147483647
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "memory", limit: 2147483647
t.datetime "last_web_request_at"
t.integer "keep_events_for", :default => 0, :null => false
t.datetime "last_event_at"
t.datetime "last_error_log_at"
t.boolean "propagate_immediately", :default => false, :null => false
t.boolean "disabled", :default => false, :null => false
t.integer "keep_events_for", default: 0, null: false
t.boolean "propagate_immediately", default: false, null: false
t.boolean "disabled", default: false, null: false
end
add_index "agents", ["schedule"], :name => "index_agents_on_schedule"
add_index "agents", ["type"], :name => "index_agents_on_type"
add_index "agents", ["user_id", "created_at"], :name => "index_agents_on_user_id_and_created_at"
add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree
add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
create_table "delayed_jobs", :force => true do |t|
t.integer "priority", :default => 0
t.integer "attempts", :default => 0
t.text "handler", :limit => 16777215
t.text "last_error"
create_table "delayed_jobs", force: true do |t|
t.integer "priority", default: 0
t.integer "attempts", default: 0
t.text "handler", limit: 16777215
t.text "last_error", limit: 16777215
t.datetime "run_at"
t.datetime "locked_at"
t.datetime "failed_at"
t.string "locked_by"
t.string "queue"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "delayed_jobs", ["priority", "run_at"], :name => "delayed_jobs_priority"
add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
create_table "events", :force => true do |t|
create_table "events", force: true do |t|
t.integer "user_id"
t.integer "agent_id"
t.decimal "lat", :precision => 15, :scale => 10
t.decimal "lng", :precision => 15, :scale => 10
t.text "payload", :limit => 16777215
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.decimal "lat", precision: 15, scale: 10
t.decimal "lng", precision: 15, scale: 10
t.text "payload", limit: 2147483647
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "expires_at"
end
add_index "events", ["agent_id", "created_at"], :name => "index_events_on_agent_id_and_created_at"
add_index "events", ["expires_at"], :name => "index_events_on_expires_at"
add_index "events", ["user_id", "created_at"], :name => "index_events_on_user_id_and_created_at"
add_index "events", ["agent_id", "created_at"], name: "index_events_on_agent_id_and_created_at", using: :btree
add_index "events", ["expires_at"], name: "index_events_on_expires_at", using: :btree
add_index "events", ["user_id", "created_at"], name: "index_events_on_user_id_and_created_at", using: :btree
create_table "links", :force => true do |t|
create_table "links", force: true do |t|
t.integer "source_id"
t.integer "receiver_id"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.integer "event_id_at_creation", :default => 0, :null => false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "event_id_at_creation", default: 0, null: false
end
add_index "links", ["receiver_id", "source_id"], :name => "index_links_on_receiver_id_and_source_id"
add_index "links", ["source_id", "receiver_id"], :name => "index_links_on_source_id_and_receiver_id"
create_table "user_credentials", :force => true do |t|
t.integer "user_id", :null => false
t.string "credential_name", :null => false
t.text "credential_value", :null => false
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.string "mode", :default => "text", :null => false
end
add_index "user_credentials", ["user_id", "credential_name"], :name => "index_user_credentials_on_user_id_and_credential_name", :unique => true
add_index "links", ["receiver_id", "source_id"], name: "index_links_on_receiver_id_and_source_id", using: :btree
add_index "links", ["source_id", "receiver_id"], name: "index_links_on_source_id_and_receiver_id", using: :btree
create_table "scenario_memberships", force: true do |t|
t.integer "agent_id", null: false
@ -109,37 +98,52 @@ ActiveRecord::Schema.define(:version => 20140408150825) do
end
create_table "scenarios", force: true do |t|
t.string "name", null: false
t.integer "user_id", null: false
t.string "name", null: false
t.integer "user_id", null: false
t.datetime "created_at"
t.datetime "updated_at"
t.text "description"
t.boolean "public", default: false, null: false
t.string "guid", null: false
t.string "source_url"
end
create_table "users", :force => true do |t|
t.string "email", :default => "", :null => false
t.string "encrypted_password", :default => "", :null => false
create_table "user_credentials", force: true do |t|
t.integer "user_id", null: false
t.string "credential_name", null: false
t.text "credential_value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "mode", default: "text", null: false
end
add_index "user_credentials", ["user_id", "credential_name"], name: "index_user_credentials_on_user_id_and_credential_name", unique: true, using: :btree
create_table "users", force: true do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.integer "sign_in_count", :default => 0
t.integer "sign_in_count", default: 0
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.boolean "admin", :default => false, :null => false
t.integer "failed_attempts", :default => 0
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "admin", default: false, null: false
t.integer "failed_attempts", default: 0
t.string "unlock_token"
t.datetime "locked_at"
t.string "username", :null => false
t.string "invitation_code", :null => false
t.string "username", null: false
t.string "invitation_code", null: false
t.integer "scenario_count", default: 0, null: false
end
add_index "users", ["email"], :name => "index_users_on_email", :unique => true
add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true
add_index "users", ["unlock_token"], :name => "index_users_on_unlock_token", :unique => true
add_index "users", ["username"], :name => "index_users_on_username", :unique => true
add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree
add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree
add_index "users", ["unlock_token"], name: "index_users_on_unlock_token", unique: true, using: :btree
add_index "users", ["username"], name: "index_users_on_username", unique: true, using: :btree
end

53
lib/agents_exporter.rb Normal file
View file

@ -0,0 +1,53 @@
class AgentsExporter
attr_accessor :options
def initialize(options)
self.options = options
end
# Filename should have no commas or special characters to support Content-Disposition on older browsers.
def filename
((options[:name] || '').downcase.gsub(/[^a-z0-9_-]/, '-').gsub(/-+/, '-').gsub(/^-|-$/, '').presence || 'exported-agents') + ".json"
end
def as_json(opts = {})
{
:name => options[:name].presence || 'No name provided',
:description => options[:description].presence || 'No description provided',
:source_url => options[:source_url],
:guid => options[:guid],
:exported_at => Time.now.utc.iso8601,
:agents => agents.map { |agent| agent_as_json(agent) },
:links => links
}
end
def agents
options[:agents].to_a
end
def links
agent_ids = agents.map(&:id)
contained_links = agents.map.with_index do |agent, index|
agent.links_as_source.where(:receiver_id => agent_ids).map do |link|
{ :source => index, :receiver => agent_ids.index(link.receiver_id) }
end
end
contained_links.flatten.compact
end
def agent_as_json(agent)
{
:type => agent.type,
:name => agent.name,
:schedule => agent.schedule,
:keep_events_for => agent.keep_events_for,
:propagate_immediately => agent.propagate_immediately,
:disabled => agent.disabled,
:source_system_agent_id => agent.id,
:options => agent.options
}
end
end

View file

@ -32,6 +32,59 @@ describe ScenariosController do
end
end
describe "GET share" do
it "only displays Scenario share information for the current user" do
get :share, :id => scenarios(:bob_weather).to_param
assigns(:scenario).should eq(scenarios(:bob_weather))
lambda {
get :share, :id => scenarios(:jane_weather).to_param
}.should raise_error(ActiveRecord::RecordNotFound)
end
end
describe "GET export" do
it "returns a JSON file download from an instantiated AgentsExporter" do
get :export, :id => scenarios(:bob_weather).to_param
assigns(:exporter).options[:name].should == scenarios(:bob_weather).name
assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
assigns(:exporter).options[:source_url].should be_false
response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
response.headers['Content-Type'].should == 'application/json; charset=utf-8'
JSON.parse(response.body)["name"].should == scenarios(:bob_weather).name
end
it "only exports private Scenarios for the current user" do
get :export, :id => scenarios(:bob_weather).to_param
assigns(:scenario).should eq(scenarios(:bob_weather))
lambda {
get :export, :id => scenarios(:jane_weather).to_param
}.should raise_error(ActiveRecord::RecordNotFound)
end
describe "public exports" do
before do
scenarios(:jane_weather).update_attribute :public, true
end
it "exports public scenarios for other users when logged in" do
get :export, :id => scenarios(:jane_weather).to_param
assigns(:scenario).should eq(scenarios(:jane_weather))
assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
end
it "exports public scenarios for other users when logged out" do
sign_out :user
get :export, :id => scenarios(:jane_weather).to_param
assigns(:scenario).should eq(scenarios(:jane_weather))
assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
end
end
end
describe "GET edit" do
it "only shows Scenarios for the current user" do
get :edit, :id => scenarios(:bob_weather).to_param
@ -67,9 +120,10 @@ describe ScenariosController do
describe "PUT update" do
it "updates attributes on Scenarios for the current user" do
post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name" }
post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name", :public => "1" }
response.should redirect_to(scenario_path(scenarios(:bob_weather)))
scenarios(:bob_weather).reload.name.should == "new_name"
scenarios(:bob_weather).should be_public
lambda {
post :update, :id => scenarios(:jane_weather).to_param, :scenario => { :name => "new_name" }

View file

@ -1,7 +1,13 @@
jane_weather:
name: Jane's weather alert Scenario
user: jane
description: Jane's weather alert system
public: false
guid: random-guid-generated-by-bob
bob_weather:
name: Bob's weather alert Scenario
user: bob
description: Bob's weather alert system
public: false
guid: random-guid-generated-by-jane

View file

@ -0,0 +1,58 @@
# encoding: utf-8
require 'spec_helper'
describe AgentsExporter do
describe "#as_json" do
let(:name) { "My set of Agents" }
let(:description) { "These Agents work together nicely!" }
let(:guid) { "some-guid" }
let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
it "outputs a structure containing name, description, the date, all agents & their links" do
data = exporter.as_json
data[:name].should == name
data[:description].should == description
data[:source_url].should == source_url
data[:guid].should == guid
Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
data[:links].should == [{ :source => 0, :receiver => 1 }]
data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
data[:agents].all? { |agent_json| agent_json[:source_system_agent_id] && agent_json[:type] && agent_json[:name] }.should be_true
end
it "does not output links to other agents" do
Link.create!(:source_id => agents(:jane_weather_agent).id, :receiver_id => agents(:jane_website_agent).id)
Link.create!(:source_id => agents(:jane_website_agent).id, :receiver_id => agents(:jane_rain_notifier_agent).id)
exporter.as_json[:links].should == [{ :source => 0, :receiver => 1 }]
end
end
describe "#filename" do
it "strips special characters" do
AgentsExporter.new(:name => "ƏfooƐƕƺbar").filename.should == "foo-bar.json"
end
it "strips punctuation" do
AgentsExporter.new(:name => "foo,bar").filename.should == "foo-bar.json"
end
it "strips leading and trailing dashes" do
AgentsExporter.new(:name => ",foo,").filename.should == "foo.json"
end
it "has a default when options[:name] is nil" do
AgentsExporter.new(:name => nil).filename.should == "exported-agents.json"
end
it "has a default when the result is empty" do
AgentsExporter.new(:name => "").filename.should == "exported-agents.json"
AgentsExporter.new(:name => "Ə").filename.should == "exported-agents.json"
AgentsExporter.new(:name => "-").filename.should == "exported-agents.json"
AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
end
end
end

View file

@ -26,6 +26,17 @@ describe Scenario do
end
end
describe "guid" do
it "gets created before_save, but only if it's not present" do
scenario = users(:bob).scenarios.new(:name => "some scenario")
scenario.guid.should be_nil
scenario.save!
scenario.guid.should_not be_nil
lambda { scenario.save! }.should_not change { scenario.reload.guid }
end
end
describe "counters" do
before do
@scenario = users(:bob).scenarios.new(:name => "some scenario")