@@ -61,6 +61,9 @@ $(document).ready -> |
||
| 61 | 61 |
if $(".flash").length
|
| 62 | 62 |
setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
|
| 63 | 63 |
|
| 64 |
+ # Help popovers |
|
| 65 |
+ $('.hover-help').popover(trigger: 'hover')
|
|
| 66 |
+ |
|
| 64 | 67 |
# Agent Navigation |
| 65 | 68 |
$agentNavigate = $('#agent-navigate')
|
| 66 | 69 |
|
@@ -99,7 +102,7 @@ $(document).ready -> |
||
| 99 | 102 |
e.preventDefault() |
| 100 | 103 |
$agentNavigate.focus() |
| 101 | 104 |
|
| 102 |
-# Agent Show |
|
| 105 |
+ # Agent Show |
|
| 103 | 106 |
fetchLogs = (e) -> |
| 104 | 107 |
agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
|
| 105 | 108 |
e.preventDefault() |
@@ -147,3 +147,14 @@ tr.agent-disabled {
|
||
| 147 | 147 |
.json-editor blockquote {
|
| 148 | 148 |
font-size: 14px; |
| 149 | 149 |
} |
| 150 |
+ |
|
| 151 |
+// Position tweeks |
|
| 152 |
+ |
|
| 153 |
+.hover-help {
|
|
| 154 |
+ top: 2px; |
|
| 155 |
+} |
|
| 156 |
+ |
|
| 157 |
+h2 .scenario {
|
|
| 158 |
+ position: relative; |
|
| 159 |
+ top: -2px; |
|
| 160 |
+} |
@@ -0,0 +1,87 @@ |
||
| 1 |
+class ScenariosController < ApplicationController |
|
| 2 |
+ def index |
|
| 3 |
+ @scenarios = current_user.scenarios.page(params[:page]) |
|
| 4 |
+ |
|
| 5 |
+ respond_to do |format| |
|
| 6 |
+ format.html |
|
| 7 |
+ format.json { render json: @scenarios }
|
|
| 8 |
+ end |
|
| 9 |
+ end |
|
| 10 |
+ |
|
| 11 |
+ def new |
|
| 12 |
+ @scenario = current_user.scenarios.build |
|
| 13 |
+ |
|
| 14 |
+ respond_to do |format| |
|
| 15 |
+ format.html |
|
| 16 |
+ format.json { render json: @scenario }
|
|
| 17 |
+ end |
|
| 18 |
+ end |
|
| 19 |
+ |
|
| 20 |
+ def show |
|
| 21 |
+ @scenario = current_user.scenarios.find(params[:id]) |
|
| 22 |
+ @agents = @scenario.agents.preload(:scenarios).page(params[:page]) |
|
| 23 |
+ |
|
| 24 |
+ respond_to do |format| |
|
| 25 |
+ format.html |
|
| 26 |
+ format.json { render json: @scenario }
|
|
| 27 |
+ end |
|
| 28 |
+ end |
|
| 29 |
+ |
|
| 30 |
+ # Share is a work in progress! |
|
| 31 |
+ def share |
|
| 32 |
+ @scenario = current_user.scenarios.find(params[:id]) |
|
| 33 |
+ @agents = @scenario.agents.preload(:scenarios).page(params[:page]) |
|
| 34 |
+ |
|
| 35 |
+ respond_to do |format| |
|
| 36 |
+ format.html |
|
| 37 |
+ format.json { render json: @scenario }
|
|
| 38 |
+ end |
|
| 39 |
+ end |
|
| 40 |
+ |
|
| 41 |
+ def edit |
|
| 42 |
+ @scenario = current_user.scenarios.find(params[:id]) |
|
| 43 |
+ |
|
| 44 |
+ respond_to do |format| |
|
| 45 |
+ format.html |
|
| 46 |
+ format.json { render json: @scenario }
|
|
| 47 |
+ end |
|
| 48 |
+ end |
|
| 49 |
+ |
|
| 50 |
+ def create |
|
| 51 |
+ @scenario = current_user.scenarios.build(params[:scenario]) |
|
| 52 |
+ |
|
| 53 |
+ respond_to do |format| |
|
| 54 |
+ if @scenario.save |
|
| 55 |
+ format.html { redirect_to @scenario, notice: 'This Scenario was successfully created.' }
|
|
| 56 |
+ format.json { render json: @scenario, status: :created, location: @scenario }
|
|
| 57 |
+ else |
|
| 58 |
+ format.html { render action: "new" }
|
|
| 59 |
+ format.json { render json: @scenario.errors, status: :unprocessable_entity }
|
|
| 60 |
+ end |
|
| 61 |
+ end |
|
| 62 |
+ end |
|
| 63 |
+ |
|
| 64 |
+ def update |
|
| 65 |
+ @scenario = current_user.scenarios.find(params[:id]) |
|
| 66 |
+ |
|
| 67 |
+ respond_to do |format| |
|
| 68 |
+ if @scenario.update_attributes(params[:scenario]) |
|
| 69 |
+ format.html { redirect_to @scenario, notice: 'This Scenario was successfully updated.' }
|
|
| 70 |
+ format.json { head :no_content }
|
|
| 71 |
+ else |
|
| 72 |
+ format.html { render action: "edit" }
|
|
| 73 |
+ format.json { render json: @scenario.errors, status: :unprocessable_entity }
|
|
| 74 |
+ end |
|
| 75 |
+ end |
|
| 76 |
+ end |
|
| 77 |
+ |
|
| 78 |
+ def destroy |
|
| 79 |
+ @scenario = current_user.scenarios.find(params[:id]) |
|
| 80 |
+ @scenario.destroy |
|
| 81 |
+ |
|
| 82 |
+ respond_to do |format| |
|
| 83 |
+ format.html { redirect_to scenarios_path }
|
|
| 84 |
+ format.json { head :no_content }
|
|
| 85 |
+ end |
|
| 86 |
+ end |
|
| 87 |
+end |
@@ -6,6 +6,12 @@ module AgentHelper |
||
| 6 | 6 |
end |
| 7 | 7 |
end |
| 8 | 8 |
|
| 9 |
+ def scenario_links(agent) |
|
| 10 |
+ agent.scenarios.map { |scenario|
|
|
| 11 |
+ link_to(scenario.name, scenario, class: "label label-info") |
|
| 12 |
+ }.to_sentence |
|
| 13 |
+ end |
|
| 14 |
+ |
|
| 9 | 15 |
def agent_show_class(agent) |
| 10 | 16 |
agent.short_type.underscore.dasherize |
| 11 | 17 |
end |
@@ -22,13 +22,14 @@ class Agent < ActiveRecord::Base |
||
| 22 | 22 |
|
| 23 | 23 |
EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
|
| 24 | 24 |
|
| 25 |
- attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :keep_events_for, :propagate_immediately |
|
| 25 |
+ attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately |
|
| 26 | 26 |
|
| 27 | 27 |
json_serialize :options, :memory |
| 28 | 28 |
|
| 29 | 29 |
validates_presence_of :name, :user |
| 30 | 30 |
validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last) |
| 31 | 31 |
validate :sources_are_owned |
| 32 |
+ validate :scenarios_are_owned |
|
| 32 | 33 |
validate :validate_schedule |
| 33 | 34 |
validate :validate_options |
| 34 | 35 |
|
@@ -48,6 +49,8 @@ class Agent < ActiveRecord::Base |
||
| 48 | 49 |
has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver |
| 49 | 50 |
has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers |
| 50 | 51 |
has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources |
| 52 |
+ has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent |
|
| 53 |
+ has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents |
|
| 51 | 54 |
|
| 52 | 55 |
scope :of_type, lambda { |type|
|
| 53 | 56 |
type = case type |
@@ -210,6 +213,10 @@ class Agent < ActiveRecord::Base |
||
| 210 | 213 |
errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
|
| 211 | 214 |
end |
| 212 | 215 |
|
| 216 |
+ def scenarios_are_owned |
|
| 217 |
+ errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user }
|
|
| 218 |
+ end |
|
| 219 |
+ |
|
| 213 | 220 |
def validate_schedule |
| 214 | 221 |
unless cannot_be_scheduled? |
| 215 | 222 |
errors.add(:schedule, "is not a valid schedule") unless SCHEDULES.include?(schedule.to_s) |
@@ -0,0 +1,17 @@ |
||
| 1 |
+class Scenario < ActiveRecord::Base |
|
| 2 |
+ attr_accessible :name, :agent_ids |
|
| 3 |
+ |
|
| 4 |
+ belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios |
|
| 5 |
+ has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario |
|
| 6 |
+ has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios |
|
| 7 |
+ |
|
| 8 |
+ validates_presence_of :name, :user |
|
| 9 |
+ |
|
| 10 |
+ validate :agents_are_owned |
|
| 11 |
+ |
|
| 12 |
+ protected |
|
| 13 |
+ |
|
| 14 |
+ def agents_are_owned |
|
| 15 |
+ errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
|
|
| 16 |
+ end |
|
| 17 |
+end |
@@ -0,0 +1,4 @@ |
||
| 1 |
+class ScenarioMembership < ActiveRecord::Base |
|
| 2 |
+ belongs_to :agent, :inverse_of => :scenario_memberships |
|
| 3 |
+ belongs_to :scenario, :inverse_of => :scenario_memberships |
|
| 4 |
+end |
@@ -26,6 +26,7 @@ class User < ActiveRecord::Base |
||
| 26 | 26 |
has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
|
| 27 | 27 |
has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
|
| 28 | 28 |
has_many :logs, :through => :agents, :class_name => "AgentLog" |
| 29 |
+ has_many :scenarios, :inverse_of => :user, :dependent => :destroy |
|
| 29 | 30 |
|
| 30 | 31 |
# Allow users to login via either email or username. |
| 31 | 32 |
def self.find_first_by_auth_conditions(warden_conditions) |
@@ -41,6 +41,7 @@ |
||
| 41 | 41 |
<div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>"> |
| 42 | 42 |
<div class="form-group"> |
| 43 | 43 |
<%= f.label :keep_events_for, "Keep events" %> |
| 44 |
+ <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time. Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span> |
|
| 44 | 45 |
<%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
|
| 45 | 46 |
</div> |
| 46 | 47 |
</div> |
@@ -59,6 +60,17 @@ |
||
| 59 | 60 |
<% end %> |
| 60 | 61 |
</div> |
| 61 | 62 |
</div> |
| 63 |
+ |
|
| 64 |
+ <% if current_user.scenario_count > 0 %> |
|
| 65 |
+ <div class="form-group"> |
|
| 66 |
+ <%= f.label :scenarios %> |
|
| 67 |
+ <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span> |
|
| 68 |
+ <%= f.select(:scenario_ids, |
|
| 69 |
+ options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids), |
|
| 70 |
+ {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
|
|
| 71 |
+ </div> |
|
| 72 |
+ <% end %> |
|
| 73 |
+ |
|
| 62 | 74 |
</div> |
| 63 | 75 |
|
| 64 | 76 |
<!-- Form controls full width --> |
@@ -0,0 +1,79 @@ |
||
| 1 |
+<div class='table-responsive'> |
|
| 2 |
+ <table class='table table-striped'> |
|
| 3 |
+ <tr> |
|
| 4 |
+ <th>Name</th> |
|
| 5 |
+ <th>Schedule</th> |
|
| 6 |
+ <th>Last Check</th> |
|
| 7 |
+ <th>Last Event Out</th> |
|
| 8 |
+ <th>Last Event In</th> |
|
| 9 |
+ <th>Events Created</th> |
|
| 10 |
+ <th>Working?</th> |
|
| 11 |
+ <th></th> |
|
| 12 |
+ </tr> |
|
| 13 |
+ |
|
| 14 |
+ <% @agents.each do |agent| %> |
|
| 15 |
+ <tr class='<%= "agent-disabled" if agent.disabled? %>'> |
|
| 16 |
+ <td> |
|
| 17 |
+ <%= agent.name %> |
|
| 18 |
+ <br/> |
|
| 19 |
+ <span class='text-muted'><%= agent.short_type.titleize %></span> |
|
| 20 |
+ <% if agent.scenarios.present? %> |
|
| 21 |
+ <span> |
|
| 22 |
+ <%= scenario_links(agent) %> |
|
| 23 |
+ </span> |
|
| 24 |
+ <% end %> |
|
| 25 |
+ </td> |
|
| 26 |
+ <td> |
|
| 27 |
+ <% if agent.can_be_scheduled? %> |
|
| 28 |
+ <%= agent.schedule.to_s.humanize.titleize %> |
|
| 29 |
+ <% else %> |
|
| 30 |
+ <span class='not-applicable'></span> |
|
| 31 |
+ <% end %> |
|
| 32 |
+ </td> |
|
| 33 |
+ <td> |
|
| 34 |
+ <% if agent.can_be_scheduled? %> |
|
| 35 |
+ <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %> |
|
| 36 |
+ <% else %> |
|
| 37 |
+ <span class='not-applicable'></span> |
|
| 38 |
+ <% end %> |
|
| 39 |
+ </td> |
|
| 40 |
+ <td> |
|
| 41 |
+ <% if agent.can_create_events? %> |
|
| 42 |
+ <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %> |
|
| 43 |
+ <% else %> |
|
| 44 |
+ <span class='not-applicable'></span> |
|
| 45 |
+ <% end %> |
|
| 46 |
+ </td> |
|
| 47 |
+ <td> |
|
| 48 |
+ <% if agent.can_receive_events? %> |
|
| 49 |
+ <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %> |
|
| 50 |
+ <% else %> |
|
| 51 |
+ <span class='not-applicable'></span> |
|
| 52 |
+ <% end %> |
|
| 53 |
+ </td> |
|
| 54 |
+ <td> |
|
| 55 |
+ <% if agent.can_create_events? %> |
|
| 56 |
+ <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %> |
|
| 57 |
+ <% else %> |
|
| 58 |
+ <span class='not-applicable'></span> |
|
| 59 |
+ <% end %> |
|
| 60 |
+ </td> |
|
| 61 |
+ <td><%= working(agent) %></td> |
|
| 62 |
+ <td> |
|
| 63 |
+ <div class="btn-group btn-group-xs"> |
|
| 64 |
+ <%= link_to 'Show', agent_path(agent), class: "btn btn-default" %> |
|
| 65 |
+ <%= link_to 'Edit', edit_agent_path(agent), class: "btn btn-default" %> |
|
| 66 |
+ <%= link_to 'Delete', agent_path(agent), method: :delete, data: { confirm: 'Are you sure you wish to permenantly delete this Agent?' }, class: "btn btn-default" %>
|
|
| 67 |
+ <% if agent.can_be_scheduled? && !agent.disabled? %> |
|
| 68 |
+ <%= link_to 'Run', run_agent_path(agent, :return => "index"), method: :post, class: "btn btn-default" %> |
|
| 69 |
+ <% else %> |
|
| 70 |
+ <%= link_to 'Run', "#", class: "btn btn-default disabled" %> |
|
| 71 |
+ <% end %> |
|
| 72 |
+ </div> |
|
| 73 |
+ </td> |
|
| 74 |
+ </tr> |
|
| 75 |
+ <% end %> |
|
| 76 |
+ </table> |
|
| 77 |
+</div> |
|
| 78 |
+ |
|
| 79 |
+<%= paginate @agents, :theme => 'twitter-bootstrap-3' %> |
@@ -5,80 +5,7 @@ |
||
| 5 | 5 |
<h2>Your Agents</h2> |
| 6 | 6 |
</div> |
| 7 | 7 |
|
| 8 |
- <div class='table-responsive'> |
|
| 9 |
- <table class='table table-striped'> |
|
| 10 |
- <tr> |
|
| 11 |
- <th>Name</th> |
|
| 12 |
- <th>Schedule</th> |
|
| 13 |
- <th>Last Check</th> |
|
| 14 |
- <th>Last Event Out</th> |
|
| 15 |
- <th>Last Event In</th> |
|
| 16 |
- <th>Events Created</th> |
|
| 17 |
- <th>Working?</th> |
|
| 18 |
- <th></th> |
|
| 19 |
- </tr> |
|
| 20 |
- |
|
| 21 |
- <% @agents.each do |agent| %> |
|
| 22 |
- <tr class='<%= "agent-disabled" if agent.disabled? %>'> |
|
| 23 |
- <td> |
|
| 24 |
- <%= agent.name %> |
|
| 25 |
- <br/> |
|
| 26 |
- <span class='text-muted'><%= agent.short_type.titleize %></span> |
|
| 27 |
- </td> |
|
| 28 |
- <td> |
|
| 29 |
- <% if agent.can_be_scheduled? %> |
|
| 30 |
- <%= agent.schedule.to_s.humanize.titleize %> |
|
| 31 |
- <% else %> |
|
| 32 |
- <span class='not-applicable'></span> |
|
| 33 |
- <% end %> |
|
| 34 |
- </td> |
|
| 35 |
- <td> |
|
| 36 |
- <% if agent.can_be_scheduled? %> |
|
| 37 |
- <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %> |
|
| 38 |
- <% else %> |
|
| 39 |
- <span class='not-applicable'></span> |
|
| 40 |
- <% end %> |
|
| 41 |
- </td> |
|
| 42 |
- <td> |
|
| 43 |
- <% if agent.can_create_events? %> |
|
| 44 |
- <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %> |
|
| 45 |
- <% else %> |
|
| 46 |
- <span class='not-applicable'></span> |
|
| 47 |
- <% end %> |
|
| 48 |
- </td> |
|
| 49 |
- <td> |
|
| 50 |
- <% if agent.can_receive_events? %> |
|
| 51 |
- <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %> |
|
| 52 |
- <% else %> |
|
| 53 |
- <span class='not-applicable'></span> |
|
| 54 |
- <% end %> |
|
| 55 |
- </td> |
|
| 56 |
- <td> |
|
| 57 |
- <% if agent.can_create_events? %> |
|
| 58 |
- <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %> |
|
| 59 |
- <% else %> |
|
| 60 |
- <span class='not-applicable'></span> |
|
| 61 |
- <% end %> |
|
| 62 |
- </td> |
|
| 63 |
- <td><%= working(agent) %></td> |
|
| 64 |
- <td> |
|
| 65 |
- <div class="btn-group btn-group-xs"> |
|
| 66 |
- <%= link_to 'Show', agent_path(agent), class: "btn btn-default" %> |
|
| 67 |
- <%= link_to 'Edit', edit_agent_path(agent), class: "btn btn-default" %> |
|
| 68 |
- <%= link_to 'Delete', agent_path(agent), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
|
|
| 69 |
- <% if agent.can_be_scheduled? && !agent.disabled? %> |
|
| 70 |
- <%= link_to 'Run', run_agent_path(agent, :return => "index"), method: :post, class: "btn btn-default" %> |
|
| 71 |
- <% else %> |
|
| 72 |
- <%= link_to 'Run', "#", class: "btn btn-default disabled" %> |
|
| 73 |
- <% end %> |
|
| 74 |
- </div> |
|
| 75 |
- </td> |
|
| 76 |
- </tr> |
|
| 77 |
- <% end %> |
|
| 78 |
- </table> |
|
| 79 |
- </div> |
|
| 80 |
- |
|
| 81 |
- <%= paginate @agents, :theme => 'twitter-bootstrap-3' %> |
|
| 8 |
+ <%= render :partial => 'agents/table' %> |
|
| 82 | 9 |
|
| 83 | 10 |
<br/> |
| 84 | 11 |
|
@@ -45,7 +45,7 @@ |
||
| 45 | 45 |
</li> |
| 46 | 46 |
|
| 47 | 47 |
<li> |
| 48 |
- <%= link_to '<span class="glyphicon glyphicon-remove"></span> Delete'.html_safe, agent_path(@agent), method: :delete, data: { confirm: 'Are you sure?' }, :tabindex => "-1" %>
|
|
| 48 |
+ <%= link_to '<span class="glyphicon glyphicon-remove"></span> Delete'.html_safe, agent_path(@agent), method: :delete, data: { confirm: 'Are you sure you wish to permenantly delete this Agent?' }, :tabindex => "-1" %>
|
|
| 49 | 49 |
</li> |
| 50 | 50 |
</ul> |
| 51 | 51 |
</li> |
@@ -20,13 +20,13 @@ |
||
| 20 | 20 |
<% next unless event.agent %> |
| 21 | 21 |
<tr> |
| 22 | 22 |
<td><%= link_to event.agent.name, agent_path(event.agent) %></td> |
| 23 |
- <td><%= time_ago_in_words event.created_at %> ago</td> |
|
| 23 |
+ <td title='<%= event.created_at %>'><%= time_ago_in_words event.created_at %> ago</td> |
|
| 24 | 24 |
<td class='payload'><%= truncate event.payload.to_json, :length => 90, :omission => "" %></td> |
| 25 | 25 |
<td> |
| 26 | 26 |
<div class="btn-group btn-group-xs"> |
| 27 | 27 |
<%= link_to 'Show', event_path(event), class: "btn btn-default" %> |
| 28 | 28 |
<%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-default" %>
|
| 29 |
- <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
|
|
| 29 |
+ <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
|
|
| 30 | 30 |
</div> |
| 31 | 31 |
</td> |
| 32 | 32 |
</tr> |
@@ -1,7 +1,7 @@ |
||
| 1 | 1 |
<% if flash.keys.length > 0 %> |
| 2 | 2 |
<div class="flash"> |
| 3 | 3 |
<% flash.each do |name, msg| %> |
| 4 |
- <div class="alert alert-<%= name.to_sym == :notice ? "success" : "error" %> alert-dismissable"> |
|
| 4 |
+ <div class="alert alert-<%= name.to_sym == :notice ? "success" : "danger" %> alert-dismissable"> |
|
| 5 | 5 |
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button> |
| 6 | 6 |
<%= content_tag :div, msg, :id => "flash_#{name}" if msg.is_a?(String) %>
|
| 7 | 7 |
</div> |
@@ -13,6 +13,7 @@ |
||
| 13 | 13 |
<% if user_signed_in? %> |
| 14 | 14 |
<ul class='nav navbar-nav'> |
| 15 | 15 |
<%= nav_link "Agents", agents_path %> |
| 16 |
+ <%= nav_link "Scenarios", scenarios_path %> |
|
| 16 | 17 |
<%= nav_link "Events", events_path %> |
| 17 | 18 |
<%= nav_link "Credentials", user_credentials_path %> |
| 18 | 19 |
</ul> |
@@ -0,0 +1,40 @@ |
||
| 1 |
+<%= form_for(@scenario, :method => @scenario.new_record? ? "POST" : "PUT") do |f| %> |
|
| 2 |
+ <% if @scenario.errors.any? %> |
|
| 3 |
+ <div class="row well"> |
|
| 4 |
+ <h2><%= pluralize(@scenario.errors.count, "error") %> prohibited this Scenario from being saved:</h2> |
|
| 5 |
+ <% @scenario.errors.full_messages.each do |msg| %> |
|
| 6 |
+ <p class='text-warning'><%= msg %></p> |
|
| 7 |
+ <% end %> |
|
| 8 |
+ </div> |
|
| 9 |
+ <% end %> |
|
| 10 |
+ |
|
| 11 |
+ <div class="row"> |
|
| 12 |
+ <div class="col-md-4"> |
|
| 13 |
+ <div class="form-group"> |
|
| 14 |
+ <%= f.label :name %> |
|
| 15 |
+ <%= f.text_field :name, :class => 'form-control' %> |
|
| 16 |
+ </div> |
|
| 17 |
+ </div> |
|
| 18 |
+ </div> |
|
| 19 |
+ |
|
| 20 |
+ <div class="row"> |
|
| 21 |
+ <div class="col-md-4"> |
|
| 22 |
+ <div class="form-group"> |
|
| 23 |
+ <div> |
|
| 24 |
+ <%= f.label :agents %> |
|
| 25 |
+ <%= f.select(:agent_ids, |
|
| 26 |
+ options_for_select(current_user.agents.pluck(:name, :id), @scenario.agent_ids), |
|
| 27 |
+ {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
|
|
| 28 |
+ </div> |
|
| 29 |
+ </div> |
|
| 30 |
+ </div> |
|
| 31 |
+ </div> |
|
| 32 |
+ |
|
| 33 |
+ <div class="row"> |
|
| 34 |
+ <div class="col-md-12"> |
|
| 35 |
+ <div class='form-actions' style='clear: both'> |
|
| 36 |
+ <%= f.submit "Save Scenario", :class => "btn btn-primary" %> |
|
| 37 |
+ </div> |
|
| 38 |
+ </div> |
|
| 39 |
+ </div> |
|
| 40 |
+<% end %> |
@@ -0,0 +1,21 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <div class="page-header"> |
|
| 5 |
+ <h2> |
|
| 6 |
+ Edit Scenario |
|
| 7 |
+ </h2> |
|
| 8 |
+ </div> |
|
| 9 |
+ |
|
| 10 |
+ <%= render 'form' %> |
|
| 11 |
+ |
|
| 12 |
+ <hr> |
|
| 13 |
+ |
|
| 14 |
+ <div class="row"> |
|
| 15 |
+ <div class="col-md-12"> |
|
| 16 |
+ <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %> |
|
| 17 |
+ </div> |
|
| 18 |
+ </div> |
|
| 19 |
+ </div> |
|
| 20 |
+ </div> |
|
| 21 |
+</div> |
@@ -0,0 +1,46 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <div class="page-header"> |
|
| 5 |
+ <h2> |
|
| 6 |
+ Your Scenarios |
|
| 7 |
+ </h2> |
|
| 8 |
+ </div> |
|
| 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> |
|
| 13 |
+ |
|
| 14 |
+ <table class='table table-striped'> |
|
| 15 |
+ <tr> |
|
| 16 |
+ <th>Name</th> |
|
| 17 |
+ <th>Agents</th> |
|
| 18 |
+ <th></th> |
|
| 19 |
+ </tr> |
|
| 20 |
+ |
|
| 21 |
+ <% @scenarios.each do |scenario| %> |
|
| 22 |
+ <tr> |
|
| 23 |
+ <td><span class='label label-info'><%= scenario.name %></span></td> |
|
| 24 |
+ <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td> |
|
| 25 |
+ <td> |
|
| 26 |
+ <div class="btn-group btn-group-xs" style="float: right"> |
|
| 27 |
+ <%= link_to 'Show', scenario, class: "btn btn-default" %> |
|
| 28 |
+ <%= link_to 'Edit', edit_scenario_path(scenario), class: "btn btn-default" %> |
|
| 29 |
+ <%= link_to 'Share', share_scenario_path(scenario), class: "btn btn-default" %> |
|
| 30 |
+ <%= link_to 'Delete', scenario_path(scenario), method: :delete, data: {confirm: 'Are you sure?'}, class: "btn btn-default" %>
|
|
| 31 |
+ </div> |
|
| 32 |
+ </td> |
|
| 33 |
+ </tr> |
|
| 34 |
+ <% end %> |
|
| 35 |
+ </table> |
|
| 36 |
+ |
|
| 37 |
+ <%= paginate @scenarios, :theme => 'twitter-bootstrap' %> |
|
| 38 |
+ |
|
| 39 |
+ <br/> |
|
| 40 |
+ |
|
| 41 |
+ <div class="btn-group"> |
|
| 42 |
+ <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %> |
|
| 43 |
+ </div> |
|
| 44 |
+ </div> |
|
| 45 |
+ </div> |
|
| 46 |
+</div> |
@@ -0,0 +1,21 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <div class="page-header"> |
|
| 5 |
+ <h2> |
|
| 6 |
+ Create a new Scenario |
|
| 7 |
+ </h2> |
|
| 8 |
+ </div> |
|
| 9 |
+ |
|
| 10 |
+ <%= render 'form' %> |
|
| 11 |
+ |
|
| 12 |
+ <hr> |
|
| 13 |
+ |
|
| 14 |
+ <div class="row"> |
|
| 15 |
+ <div class="col-md-12"> |
|
| 16 |
+ <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %> |
|
| 17 |
+ </div> |
|
| 18 |
+ </div> |
|
| 19 |
+ </div> |
|
| 20 |
+ </div> |
|
| 21 |
+</div> |
@@ -0,0 +1,17 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <div class="page-header"> |
|
| 5 |
+ <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2> |
|
| 6 |
+ </div> |
|
| 7 |
+ |
|
| 8 |
+ <hr> |
|
| 9 |
+ |
|
| 10 |
+ <div class="row"> |
|
| 11 |
+ <div class="col-md-12"> |
|
| 12 |
+ <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %> |
|
| 13 |
+ </div> |
|
| 14 |
+ </div> |
|
| 15 |
+ </div> |
|
| 16 |
+ </div> |
|
| 17 |
+</div> |
@@ -0,0 +1,21 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <div class="page-header"> |
|
| 5 |
+ <h2>Scenario <span class='label label-info scenario'><%= @scenario.name %></span></h2> |
|
| 6 |
+ </div> |
|
| 7 |
+ |
|
| 8 |
+ <div class="btn-group"> |
|
| 9 |
+ <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %> |
|
| 10 |
+ <%= link_to '<span class="glyphicon glyphicon-edit"></span> Edit'.html_safe, edit_scenario_path(@scenario), class: "btn btn-default" %> |
|
| 11 |
+ <%= link_to '<span class="glyphicon glyphicon-share-alt"></span> Share'.html_safe, share_scenario_path(@scenario), class: "btn btn-default" %> |
|
| 12 |
+ </div> |
|
| 13 |
+ |
|
| 14 |
+ <div class="page-header"> |
|
| 15 |
+ <h3>Agents</h3> |
|
| 16 |
+ </div> |
|
| 17 |
+ |
|
| 18 |
+ <%= render :partial => 'agents/table' %> |
|
| 19 |
+ </div> |
|
| 20 |
+ </div> |
|
| 21 |
+</div> |
@@ -26,6 +26,12 @@ Huginn::Application.routes.draw do |
||
| 26 | 26 |
end |
| 27 | 27 |
end |
| 28 | 28 |
|
| 29 |
+ resources :scenarios do |
|
| 30 |
+ member do |
|
| 31 |
+ get :share |
|
| 32 |
+ end |
|
| 33 |
+ end |
|
| 34 |
+ |
|
| 29 | 35 |
resources :user_credentials, :except => :show |
| 30 | 36 |
|
| 31 | 37 |
get "/worker_status" => "worker_status#show" |
@@ -0,0 +1,12 @@ |
||
| 1 |
+class CreateScenarios < ActiveRecord::Migration |
|
| 2 |
+ def change |
|
| 3 |
+ create_table :scenarios do |t| |
|
| 4 |
+ t.string :name, :null => false |
|
| 5 |
+ t.integer :user_id, :null => false |
|
| 6 |
+ |
|
| 7 |
+ t.timestamps |
|
| 8 |
+ end |
|
| 9 |
+ |
|
| 10 |
+ add_column :users, :scenario_count, :integer, :null => false, :default => 0 |
|
| 11 |
+ end |
|
| 12 |
+end |
@@ -0,0 +1,10 @@ |
||
| 1 |
+class CreateScenarioMemberships < ActiveRecord::Migration |
|
| 2 |
+ def change |
|
| 3 |
+ create_table :scenario_memberships do |t| |
|
| 4 |
+ t.integer :agent_id, :null => false |
|
| 5 |
+ t.integer :scenario_id, :null => false |
|
| 6 |
+ |
|
| 7 |
+ t.timestamps |
|
| 8 |
+ end |
|
| 9 |
+ end |
|
| 10 |
+end |
@@ -101,6 +101,20 @@ ActiveRecord::Schema.define(:version => 20140408150825) do |
||
| 101 | 101 |
|
| 102 | 102 |
add_index "user_credentials", ["user_id", "credential_name"], :name => "index_user_credentials_on_user_id_and_credential_name", :unique => true |
| 103 | 103 |
|
| 104 |
+ create_table "scenario_memberships", force: true do |t| |
|
| 105 |
+ t.integer "agent_id", null: false |
|
| 106 |
+ t.integer "scenario_id", null: false |
|
| 107 |
+ t.datetime "created_at" |
|
| 108 |
+ t.datetime "updated_at" |
|
| 109 |
+ end |
|
| 110 |
+ |
|
| 111 |
+ create_table "scenarios", force: true do |t| |
|
| 112 |
+ t.string "name", null: false |
|
| 113 |
+ t.integer "user_id", null: false |
|
| 114 |
+ t.datetime "created_at" |
|
| 115 |
+ t.datetime "updated_at" |
|
| 116 |
+ end |
|
| 117 |
+ |
|
| 104 | 118 |
create_table "users", :force => true do |t| |
| 105 | 119 |
t.string "email", :default => "", :null => false |
| 106 | 120 |
t.string "encrypted_password", :default => "", :null => false |
@@ -120,6 +134,7 @@ ActiveRecord::Schema.define(:version => 20140408150825) do |
||
| 120 | 134 |
t.datetime "locked_at" |
| 121 | 135 |
t.string "username", :null => false |
| 122 | 136 |
t.string "invitation_code", :null => false |
| 137 |
+ t.integer "scenario_count", default: 0, null: false |
|
| 123 | 138 |
end |
| 124 | 139 |
|
| 125 | 140 |
add_index "users", ["email"], :name => "index_users_on_email", :unique => true |
@@ -0,0 +1,98 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe ScenariosController 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 index" do |
|
| 13 |
+ it "only returns Scenarios for the current user" do |
|
| 14 |
+ get :index |
|
| 15 |
+ assigns(:scenarios).all? {|i| i.user.should == users(:bob) }.should be_true
|
|
| 16 |
+ end |
|
| 17 |
+ end |
|
| 18 |
+ |
|
| 19 |
+ describe "GET show" do |
|
| 20 |
+ it "only shows Scenarios for the current user" do |
|
| 21 |
+ get :show, :id => scenarios(:bob_weather).to_param |
|
| 22 |
+ assigns(:scenario).should eq(scenarios(:bob_weather)) |
|
| 23 |
+ |
|
| 24 |
+ lambda {
|
|
| 25 |
+ get :show, :id => scenarios(:jane_weather).to_param |
|
| 26 |
+ }.should raise_error(ActiveRecord::RecordNotFound) |
|
| 27 |
+ end |
|
| 28 |
+ |
|
| 29 |
+ it "loads Agents for the requested Scenario" do |
|
| 30 |
+ get :show, :id => scenarios(:bob_weather).to_param |
|
| 31 |
+ assigns(:agents).pluck(:id).should eq(scenarios(:bob_weather).agents.pluck(:id)) |
|
| 32 |
+ end |
|
| 33 |
+ end |
|
| 34 |
+ |
|
| 35 |
+ describe "GET edit" do |
|
| 36 |
+ it "only shows Scenarios for the current user" do |
|
| 37 |
+ get :edit, :id => scenarios(:bob_weather).to_param |
|
| 38 |
+ assigns(:scenario).should eq(scenarios(:bob_weather)) |
|
| 39 |
+ |
|
| 40 |
+ lambda {
|
|
| 41 |
+ get :edit, :id => scenarios(:jane_weather).to_param |
|
| 42 |
+ }.should raise_error(ActiveRecord::RecordNotFound) |
|
| 43 |
+ end |
|
| 44 |
+ end |
|
| 45 |
+ |
|
| 46 |
+ describe "POST create" do |
|
| 47 |
+ it "creates Scenarios for the current user" do |
|
| 48 |
+ expect {
|
|
| 49 |
+ post :create, :scenario => valid_attributes |
|
| 50 |
+ }.to change { users(:bob).scenarios.count }.by(1)
|
|
| 51 |
+ end |
|
| 52 |
+ |
|
| 53 |
+ it "shows errors" do |
|
| 54 |
+ expect {
|
|
| 55 |
+ post :create, :scenario => valid_attributes(:name => "") |
|
| 56 |
+ }.not_to change { users(:bob).scenarios.count }
|
|
| 57 |
+ assigns(:scenario).should have(1).errors_on(:name) |
|
| 58 |
+ response.should render_template("new")
|
|
| 59 |
+ end |
|
| 60 |
+ |
|
| 61 |
+ it "will not create Scenarios for other users" do |
|
| 62 |
+ expect {
|
|
| 63 |
+ post :create, :scenario => valid_attributes(:user_id => users(:jane).id) |
|
| 64 |
+ }.to raise_error(ActiveModel::MassAssignmentSecurity::Error) |
|
| 65 |
+ end |
|
| 66 |
+ end |
|
| 67 |
+ |
|
| 68 |
+ describe "PUT update" do |
|
| 69 |
+ it "updates attributes on Scenarios for the current user" do |
|
| 70 |
+ post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name" }
|
|
| 71 |
+ response.should redirect_to(scenario_path(scenarios(:bob_weather))) |
|
| 72 |
+ scenarios(:bob_weather).reload.name.should == "new_name" |
|
| 73 |
+ |
|
| 74 |
+ lambda {
|
|
| 75 |
+ post :update, :id => scenarios(:jane_weather).to_param, :scenario => { :name => "new_name" }
|
|
| 76 |
+ }.should raise_error(ActiveRecord::RecordNotFound) |
|
| 77 |
+ scenarios(:jane_weather).reload.name.should_not == "new_name" |
|
| 78 |
+ end |
|
| 79 |
+ |
|
| 80 |
+ it "shows errors" do |
|
| 81 |
+ post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "" }
|
|
| 82 |
+ assigns(:scenario).should have(1).errors_on(:name) |
|
| 83 |
+ response.should render_template("edit")
|
|
| 84 |
+ end |
|
| 85 |
+ end |
|
| 86 |
+ |
|
| 87 |
+ describe "DELETE destroy" do |
|
| 88 |
+ it "destroys only Scenarios owned by the current user" do |
|
| 89 |
+ expect {
|
|
| 90 |
+ delete :destroy, :id => scenarios(:bob_weather).to_param |
|
| 91 |
+ }.to change(Scenario, :count).by(-1) |
|
| 92 |
+ |
|
| 93 |
+ lambda {
|
|
| 94 |
+ delete :destroy, :id => scenarios(:jane_weather).to_param |
|
| 95 |
+ }.should raise_error(ActiveRecord::RecordNotFound) |
|
| 96 |
+ end |
|
| 97 |
+ end |
|
| 98 |
+end |
@@ -0,0 +1,15 @@ |
||
| 1 |
+jane_weather_agent_scenario_membership: |
|
| 2 |
+ agent: jane_weather_agent |
|
| 3 |
+ scenario: jane_weather |
|
| 4 |
+ |
|
| 5 |
+jane_rain_notifier_agent_scenario_membership: |
|
| 6 |
+ agent: jane_rain_notifier_agent |
|
| 7 |
+ scenario: jane_weather |
|
| 8 |
+ |
|
| 9 |
+bob_weather_agent_scenario_membership: |
|
| 10 |
+ agent: bob_weather_agent |
|
| 11 |
+ scenario: bob_weather |
|
| 12 |
+ |
|
| 13 |
+bob_rain_notifier_agent_scenario_membership: |
|
| 14 |
+ agent: bob_rain_notifier_agent |
|
| 15 |
+ scenario: bob_weather |
@@ -0,0 +1,7 @@ |
||
| 1 |
+jane_weather: |
|
| 2 |
+ name: Jane's weather alert Scenario |
|
| 3 |
+ user: jane |
|
| 4 |
+ |
|
| 5 |
+bob_weather: |
|
| 6 |
+ name: Bob's weather alert Scenario |
|
| 7 |
+ user: bob |
@@ -4,8 +4,10 @@ bob: |
||
| 4 | 4 |
email: "bob@example.com" |
| 5 | 5 |
username: bob |
| 6 | 6 |
invitation_code: <%= User::INVITATION_CODES.last %> |
| 7 |
+ scenario_count: 1 |
|
| 7 | 8 |
|
| 8 | 9 |
jane: |
| 9 | 10 |
email: "jane@example.com" |
| 10 | 11 |
username: jane |
| 11 |
- invitation_code: <%= User::INVITATION_CODES.last %> |
|
| 12 |
+ invitation_code: <%= User::INVITATION_CODES.last %> |
|
| 13 |
+ scenario_count: 1 |
@@ -480,6 +480,23 @@ describe Agent do |
||
| 480 | 480 |
agent.should have(0).errors_on(:sources) |
| 481 | 481 |
end |
| 482 | 482 |
|
| 483 |
+ it "should not allow scenarios owned by other people" do |
|
| 484 |
+ agent = Agents::SomethingSource.new(:name => "something") |
|
| 485 |
+ agent.user = users(:bob) |
|
| 486 |
+ |
|
| 487 |
+ agent.scenario_ids = [scenarios(:bob_weather).id] |
|
| 488 |
+ agent.should have(0).errors_on(:scenarios) |
|
| 489 |
+ |
|
| 490 |
+ agent.scenario_ids = [scenarios(:bob_weather).id, scenarios(:jane_weather).id] |
|
| 491 |
+ agent.should have(1).errors_on(:scenarios) |
|
| 492 |
+ |
|
| 493 |
+ agent.scenario_ids = [scenarios(:jane_weather).id] |
|
| 494 |
+ agent.should have(1).errors_on(:scenarios) |
|
| 495 |
+ |
|
| 496 |
+ agent.user = users(:jane) |
|
| 497 |
+ agent.should have(0).errors_on(:scenarios) |
|
| 498 |
+ end |
|
| 499 |
+ |
|
| 483 | 500 |
it "validates keep_events_for" do |
| 484 | 501 |
agent = Agents::SomethingSource.new(:name => "something") |
| 485 | 502 |
agent.user = users(:bob) |
@@ -0,0 +1,44 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe Scenario do |
|
| 4 |
+ describe "validations" do |
|
| 5 |
+ before do |
|
| 6 |
+ @scenario = users(:bob).scenarios.new(:name => "some scenario") |
|
| 7 |
+ @scenario.should be_valid |
|
| 8 |
+ end |
|
| 9 |
+ |
|
| 10 |
+ it "validates the presence of name" do |
|
| 11 |
+ @scenario.name = '' |
|
| 12 |
+ @scenario.should_not be_valid |
|
| 13 |
+ end |
|
| 14 |
+ |
|
| 15 |
+ it "validates the presence of user" do |
|
| 16 |
+ @scenario.user = nil |
|
| 17 |
+ @scenario.should_not be_valid |
|
| 18 |
+ end |
|
| 19 |
+ |
|
| 20 |
+ it "only allows Agents owned by user" do |
|
| 21 |
+ @scenario.agent_ids = [agents(:bob_website_agent).id] |
|
| 22 |
+ @scenario.should be_valid |
|
| 23 |
+ |
|
| 24 |
+ @scenario.agent_ids = [agents(:jane_website_agent).id] |
|
| 25 |
+ @scenario.should_not be_valid |
|
| 26 |
+ end |
|
| 27 |
+ end |
|
| 28 |
+ |
|
| 29 |
+ describe "counters" do |
|
| 30 |
+ before do |
|
| 31 |
+ @scenario = users(:bob).scenarios.new(:name => "some scenario") |
|
| 32 |
+ end |
|
| 33 |
+ |
|
| 34 |
+ it "maintains a counter cache on user" do |
|
| 35 |
+ lambda {
|
|
| 36 |
+ @scenario.save! |
|
| 37 |
+ }.should change { users(:bob).reload.scenario_count }.by(1)
|
|
| 38 |
+ |
|
| 39 |
+ lambda {
|
|
| 40 |
+ @scenario.destroy |
|
| 41 |
+ }.should change { users(:bob).reload.scenario_count }.by(-1)
|
|
| 42 |
+ end |
|
| 43 |
+ end |
|
| 44 |
+end |