arturo 0.2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ ## v 1.1.0 - cleanup
2
+ * changed `require_feature!` to `require_feature`
3
+ * replaced `Arturo.permit_management` and `Arturo.feature_recipient`
4
+ blocks with instance methods
5
+ `Arturo::FeatureManagement.may_manage_features?` and
6
+ `Arturo::FeatureAvailability.feature_recipient`
7
+
8
+ ## v 1.0.0 - Initial Release
9
+ * `require_feature!` controller filter
10
+ * `if_feature_enabled` controller and view method
11
+ * `feature_enabled?` controller and view method
12
+ * CRUD for features
13
+ * `Arturo.permit_management` to configure management permission
14
+ * `Arturo.feature_recipient` to configure on what basis features are deployed
15
+ * whitelists and blacklists
16
+
@@ -0,0 +1,277 @@
1
+ ## Rails 2.3
2
+
3
+ This is the Rails 2.3 branch of Arturo. It is available as a gem with the
4
+ version number `0.2.3.x`. See [Installation](#installation), below. For
5
+ Rails 3.0 support, see the
6
+ [master branch](http://github.com/jamesarosen/arturo).
7
+
8
+ ## What
9
+
10
+ Arturo provides feature sliders for Rails. It lets you turn features on and off
11
+ just like
12
+ [feature flippers](http://code.flickr.com/blog/2009/12/02/flipping-out/),
13
+ but offers more fine-grained control. It supports deploying features only for
14
+ a given percent* of your users and whitelisting and blacklisting users based
15
+ on any criteria you can express in Ruby.
16
+
17
+ * The selection isn't random. It's not even pseudo-random. It's completely
18
+ deterministic. This assures that if a user has a feature on Monday, the
19
+ user will still have it on Tuesday (unless, of course, you *decrease*
20
+ the feature's deployment percentage or change its white- or blacklist
21
+ settings).
22
+
23
+ ### A quick example
24
+
25
+ Trish, a developer is working on a new feature: a live feed of recent postings
26
+ in the user's city that shows up in the user's sidebar. First, she uses Arturo's
27
+ view helpers to control who sees the sidebar widget:
28
+
29
+ <%# in app/views/layout/_sidebar.html.erb: %>
30
+ <% if_feature_enabled(:live_postings) do %>
31
+ <div class='widget'>
32
+ <h3>Recent Postings</h3>
33
+ <ol id='live_postings'>
34
+ </ol>
35
+ </div>
36
+ <% end %>
37
+
38
+ Then Trish writes some Javascript that will poll the server for recent
39
+ postings and put them in the sidebar widget:
40
+
41
+ // in public/javascript/live_postings.js:
42
+ $(function() {
43
+ var livePostingsList = $('#live_postings');
44
+ if (livePostingsList.length > 0) {
45
+ var updatePostingsList = function() {
46
+ livePostingsList.load('/listings/recent');
47
+ setTimeout(updatePostingsList, 30);
48
+ }
49
+ updatePostingsList();
50
+ }
51
+ });
52
+
53
+ Trish uses Arturo's Controller filters to control who has access to
54
+ the feature:
55
+
56
+ # in app/controllers/postings_controller:
57
+ class PostingsController < ApplicationController
58
+ require_feature :live_postings, :only => :recent
59
+ # ...
60
+ end
61
+
62
+ Trish then deploys this code to production. Nobody will see the feature yet,
63
+ since it's not on for anyone. (In fact, the feature doesn't yet exist
64
+ in the database, which is the same as being deployed to 0% of users.) A week
65
+ later, when the company is ready to start deploying the feature to a few
66
+ people, the product manager, Vijay, signs in to their site and navigates
67
+ to `/features`, adds a new feature called "live_postings" and sets its
68
+ deployment percentage to 3%. After a few days, the operations team decides
69
+ that the increase in traffic is not going to overwhelm their servers, and
70
+ Vijay can bump the deployment percentage up to 50%. A few more days go by
71
+ and they clean up the last few bugs they found with the "live_postings"
72
+ feature and deploy it to all users.
73
+
74
+ ## <span id='installation'>Installation</span>
75
+
76
+ Arturo's support for Rails 2.3 is available as a gem with version
77
+ numbers matching `0.2.3.x`. The best strategy is to use the
78
+ "pessimistic" gem version `"~> 0.2.3"`, which will get you the
79
+ latest version of Rails 2.3 support.
80
+
81
+ ### In Rails 2.3, with Bundler
82
+
83
+ In your `Gemfile`:
84
+
85
+ gem 'arturo', :git => 'git://github.com/jamesarosen/arturo.git',
86
+ :version => '~> 0.2.3'
87
+
88
+ Unfortunately, Rails 2.3 won't automatically load Arturo as a Plugin
89
+ (and thus not pick up its "engine-ness") without **also** declaring
90
+ it as a Rails gem. Thus, in `config/environment.rb`:
91
+
92
+ config.gem 'arturo', :version => '~> 0.2.3'
93
+
94
+ ### In Rails 2.3, without Bundler
95
+
96
+ Put the `rails_2_3` branch of `git://github.com/jamesarosen/arturo.git` into
97
+ your `vendor/plugins/` directory. You can use Git submodules or a simple
98
+ checkout.
99
+
100
+ ## Configuration
101
+
102
+ ### In Rails
103
+
104
+ #### Run the generators:
105
+
106
+ $ script/generate arturo:migration
107
+ $ script/generate arturo:initializer
108
+ $ script/generate arturo:route
109
+ $ script/generate arturo:assets
110
+
111
+ #### Edit the generated migration as necessary
112
+
113
+ #### Run the migration:
114
+
115
+ $ rake db:migrate
116
+
117
+ #### Edit the configuration
118
+
119
+ ##### Initializer
120
+
121
+ Open up the newly-generated `config/initializers/arturo_initializer.rb`.
122
+ There are configuration options for the following:
123
+
124
+ * the method that determines whether a user has permission to manage features
125
+ (see [admin permissions](#adminpermissions))
126
+ * the method that returns the object that has features
127
+ (e.g. User, Person, or Account; see
128
+ [feature recipients](#featurerecipients))
129
+ * whitelists and blacklists for features
130
+ (see [white- and blacklisting](#wblisting))
131
+
132
+ ##### CSS
133
+
134
+ Open up the newly-generated `public/stylehseets/arturo_customizations.css`.
135
+ You can add any overrides you like to the feature configuration page styles
136
+ here. **Do not** edit `public/stylehseets/arturo.css` as that file may be
137
+ overwritten in future updates to Arturo.
138
+
139
+ ### In other frameworks
140
+
141
+ Arturo is a Rails engine. I want to promote reuse on other frameworks by
142
+ extracting key pieces into mixins, though this isn't done yet. Open an
143
+ [issue](http://github.com/jamesarosen/arturo/issues) and I'll be happy to
144
+ work with you on support for your favorite framework.
145
+
146
+ ## Deep-Dive
147
+
148
+ ### <span id='adminpermissions'>Admin Permissions</span>
149
+
150
+ `Arturo::FeatureManagement#may_manage_features?` is a method that is run in
151
+ the context of a Controller or View instance. It should return `true` if
152
+ and only if the current user may manage permissions. The default implementation
153
+ is as follows:
154
+
155
+ current_user.present? && current_user.admin?
156
+
157
+ You can change the implementation in
158
+ `config/initializers/arturo_initializer.rb`. A reasonable implementation
159
+ might be
160
+
161
+ Arturo.permit_management do
162
+ signed_in? && current_user.can?(:manage_features)
163
+ end
164
+
165
+ ### <span id='featurerecipients'>Feature Recipients</span>
166
+
167
+ Clients of Arturo may want to deploy new features on a per-user, per-project,
168
+ per-account, or other basis. For example, it is likely Twitter deployed
169
+ "#newtwitter" on a per-user basis. Conversely, Facebook -- at least in its
170
+ early days -- may have deployed features on a per-university basis. It wouldn't
171
+ make much sense to deploy a feature to one user of a Basecamp project but not
172
+ to others, so 37Signals would probably want a per-project or per-account basis.
173
+
174
+ `Arturo::FeatureAvailability#feature_recipient` is intended to support these
175
+ many use cases. It is a method that returns the current "thing" (a user, account,
176
+ project, university, ...) that is a member of the category that is the basis for
177
+ deploying new features. It should return an `Object` that responds to `#id`.
178
+
179
+ The default implementation simply returns `current_user`. Like
180
+ `Arturo::FeatureManagement#may_manage_features?`, this method can be configured
181
+ in `config/initializers/arturo_initializer.rb`. If you want to deploy features
182
+ on a per-account basis, a reasonable implementation might be
183
+
184
+ Arturo.feature_recipient do
185
+ current_account
186
+ end
187
+
188
+ or
189
+
190
+ Arturo.feature_recipient do
191
+ current_user.account
192
+ end
193
+
194
+ If the block returns `nil`, the feature will be disabled.
195
+
196
+ ### <span id='wblisting'>Whitelists & Blacklists</span>
197
+
198
+ Whitelists and blacklists allow you to control exactly which users or accounts
199
+ will have a feature. For example, if all premium users should have the
200
+ `:awesome` feature, place the following in
201
+ `config/initializers/arturo_initializer.rb`:
202
+
203
+ Arturo::Feature.whitelist(:awesome) do |user|
204
+ user.account.premium?
205
+ end
206
+
207
+ If, on the other hand, no users on the free plan should have the
208
+ `:awesome` feature, place the following in
209
+ `config/initializers/arturo_initializer.rb`:
210
+
211
+ Arturo::Feature.blacklist(:awesome) do |user|
212
+ user.account.free?
213
+ end
214
+
215
+ ### Feature Conditionals
216
+
217
+ All that configuration is just a waste of time if Arturo didn't modify the
218
+ behavior of your application based on feature availability. There are a few
219
+ ways to do so.
220
+
221
+ #### Controller Filters
222
+
223
+ If an action should only be available to those with a feature enabled,
224
+ use a before filter. The following will raise a 403 Forbidden error for
225
+ every action within `BookHoldsController` that is invoked by a user who
226
+ does not have the `:hold_book` feature.
227
+
228
+ class BookHoldsController < ApplicationController
229
+ require_feature :hold_book
230
+ end
231
+
232
+ `require_feature` accepts as a second argument a `Hash` that it passes on
233
+ to `before_filter`, so you can use `:only` and `:except` to specify exactly
234
+ which actions are filtered.
235
+
236
+ If you want to customize the page that is rendered on 403 Forbidden
237
+ responses, put the view in
238
+ `RAILS_ROOT/app/views/arturo/features/forbidden.html.erb`. Rails will
239
+ check there before falling back on Arturo's forbidden page.
240
+
241
+ #### Conditional Evaluation
242
+
243
+ Both controllers and views have access to the `if_feature_enabled` and
244
+ `feature_enabled?` methods. The former is used like so:
245
+
246
+ <% if_feature_enabled?(:reserve_table) %>
247
+ <%= link_to 'Reserve a table', new_restaurant_reservation_path(:restaurant_id => @restaurant) %>
248
+ <% end %>
249
+
250
+ The latter can be used like so:
251
+
252
+ def widgets_for_sidebar
253
+ widgets = []
254
+ widgets << twitter_widget if feature_enabled?(:twitter_integration)
255
+ ...
256
+ widgets
257
+ end
258
+
259
+ #### Caching
260
+
261
+ **Note**: Arturo does not yet have caching support. Be very careful when
262
+ caching actions or pages that involve feature detection as you will get
263
+ strange behavior when a user who has access to a feature requests a page
264
+ just after one who does not (and vice versa). The following is the
265
+ **intended** support for caching.
266
+
267
+ Both the `require_feature` before filter and the `if_feature_enabled` block
268
+ evaluation automatically append a string based on the feature's
269
+ `last_modified` timestamp to cache keys that Rails generates. Thus, you don't
270
+ have to worry about expiring caches when you increase a feature's deployment
271
+ percentage. See `Arturo::CacheSupport` for more information.
272
+
273
+ ## The Name
274
+
275
+ Arturo gets its name from
276
+ [Professor Maximillian Arturo](http://en.wikipedia.org/wiki/Maximillian_Arturo)
277
+ on [Sliders](http://en.wikipedia.org/wiki/Sliders).
@@ -0,0 +1,125 @@
1
+ require 'action_controller'
2
+
3
+ module Arturo
4
+
5
+ begin
6
+ require 'application_controller'
7
+ rescue LoadError
8
+ # do nothing
9
+ end
10
+
11
+ base = Object.const_defined?(:ApplicationController) ? ApplicationController : ActionController::Base
12
+
13
+ # Handles all Feature actions. Clients of the Arturo engine
14
+ # should redefine Arturo::FeaturesController#permitted? to
15
+ # return true only for users who are permitted to manage features.
16
+ class FeaturesController < base
17
+ include Arturo::FeatureManagement
18
+
19
+ unloadable
20
+ before_filter :require_permission
21
+ before_filter :load_feature, :only => [ :show, :edit, :update, :destroy ]
22
+
23
+ def index
24
+ @features = Arturo::Feature.all
25
+ respond_to do |format|
26
+ format.html { }
27
+ format.json { render :json => @features }
28
+ format.xml { render :xml => @features }
29
+ end
30
+ end
31
+
32
+ def update_all
33
+ updated_count = 0
34
+ errors = []
35
+ features_params = params[:features] || {}
36
+ features_params.each do |id, attributes|
37
+ feature = Arturo::Feature.find_by_id(id)
38
+ if feature.blank?
39
+ errors << t('arturo.features.flash.no_such_feature', :id => id)
40
+ elsif feature.update_attributes(attributes)
41
+ updated_count += 1
42
+ else
43
+ errors << t('arturo.features.flash.error_updating', :id => id)
44
+ end
45
+ end
46
+ if errors.any?
47
+ flash[:error] = errors
48
+ else
49
+ flash[:success] = t('arturo.features.flash.updated_many', :count => updated_count)
50
+ end
51
+ redirect_to features_path
52
+ end
53
+
54
+ def show
55
+ respond_to do |format|
56
+ format.html { }
57
+ format.json { render :json => @feature }
58
+ format.xml { render :xml => @feature }
59
+ end
60
+ end
61
+
62
+ def new
63
+ @feature = Arturo::Feature.new(params[:feature])
64
+ respond_to do |format|
65
+ format.html { }
66
+ format.json { render :json => @feature }
67
+ format.xml { render :xml => @feature }
68
+ end
69
+ end
70
+
71
+ def create
72
+ @feature = Arturo::Feature.new(params[:feature])
73
+ if @feature.save
74
+ flash[:notice] = t('arturo.features.flash.created', :name => @feature.to_s)
75
+ redirect_to features_path
76
+ else
77
+ flash[:alert] = t('arturo.features.flash.error_creating', :name => @feature.to_s)
78
+ render :action => 'new'
79
+ end
80
+ end
81
+
82
+ def edit
83
+ respond_to do |format|
84
+ format.html { }
85
+ format.json { render :json => @feature }
86
+ format.xml { render :xml => @feature }
87
+ end
88
+ end
89
+
90
+ def update
91
+ if @feature.update_attributes(params[:feature])
92
+ flash[:notice] = t('arturo.features.flash.updated', :name => @feature.to_s)
93
+ redirect_to feature_path(@feature)
94
+ else
95
+ flash[:alert] = t('arturo.features.flash.error_updating', :name => @feature.to_s)
96
+ render :action => 'edit'
97
+ end
98
+ end
99
+
100
+ def destroy
101
+ if @feature.destroy
102
+ flash[:notice] = t('arturo.features.flash.removed', :name => @feature.to_s)
103
+ else
104
+ flash[:alert] = t('arturo.features.flash.error_removing', :name => @feature.to_s)
105
+ end
106
+ redirect_to features_path
107
+ end
108
+
109
+ protected
110
+
111
+ def require_permission
112
+ unless may_manage_features?
113
+ render :action => 'forbidden', :status => 403
114
+ return false
115
+ end
116
+ end
117
+
118
+ def load_feature
119
+ @feature ||= Arturo::Feature.find(params[:id])
120
+ end
121
+
122
+ end
123
+
124
+ end
125
+
@@ -0,0 +1,38 @@
1
+ require 'action_view/helpers/tag_helper'
2
+ require 'action_view/helpers/form_tag_helper'
3
+
4
+ module Arturo
5
+ module FeaturesHelper
6
+ include ActionView::Helpers::TagHelper
7
+
8
+ def deployment_percentage_range_and_output_tags(name, value, options = {})
9
+ id = sanitize_to_id(name)
10
+ options = {
11
+ 'type' => 'range',
12
+ 'name' => name,
13
+ 'id' => id,
14
+ 'value' => value,
15
+ 'min' => '0',
16
+ 'max' => '100',
17
+ 'step' => '1',
18
+ 'class' => 'deployment_percentage'
19
+ }.update(options.stringify_keys)
20
+ tag(:input, options) + deployment_percentage_output_tag(id, value)
21
+ end
22
+
23
+ def deployment_percentage_output_tag(id, value)
24
+ content_tag(:output, value, { 'for' => id, 'class' => 'deployment_percentage no_js' })
25
+ end
26
+
27
+ def error_messages_for(feature, attribute)
28
+ errors = feature.errors.on(attribute)
29
+ if errors && errors.any?
30
+ content_tag(:ul, :class => 'errors') do
31
+ errors.map { |msg| content_tag(:li, msg, :class => 'error') }.join(''.html_safe)
32
+ end
33
+ else
34
+ ''
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,85 @@
1
+ require 'active_record'
2
+
3
+ # a stub
4
+ # possible TODO: remove and and refactor into an acts_as_feature mixin
5
+ module Arturo
6
+ class Feature < ::ActiveRecord::Base
7
+
8
+ include Arturo::SpecialHandling
9
+
10
+ Arturo::Feature::SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
11
+ DEFAULT_ATTRIBUTES = { :deployment_percentage => 0 }
12
+
13
+ attr_readonly :symbol
14
+
15
+ validates_presence_of :symbol, :deployment_percentage
16
+ validates_uniqueness_of :symbol, :allow_blank => true
17
+ validates_numericality_of :deployment_percentage,
18
+ :only_integer => true,
19
+ :allow_blank => true,
20
+ :greater_than_or_equal_to => 0,
21
+ :less_than_or_equal_to => 100
22
+
23
+ # Looks up a feature by symbol. Also accepts a Feature as input.
24
+ # @param [Symbol, Arturo::Feature] feature_or_name a Feature or the Symbol of a Feature
25
+ # @return [Arturo::Feature, nil] the Feature if found, else nil
26
+ def self.to_feature(feature_or_symbol)
27
+ return feature_or_symbol if feature_or_symbol.kind_of?(self)
28
+ self.find(:first, :conditions => { :symbol => feature_or_symbol.to_s })
29
+ end
30
+
31
+ # Create a new Feature
32
+ def initialize(attributes = {})
33
+ super(DEFAULT_ATTRIBUTES.merge(attributes || {}))
34
+ end
35
+
36
+ # @param [Object] feature_recipient a User, Account,
37
+ # or other model with an #id method
38
+ # @return [true,false] whether or not this feature is enabled
39
+ # for feature_recipient
40
+ # @see Arturo::SpecialHandling#whitelisted?
41
+ # @see Arturo::SpecialHandling#blacklisted?
42
+ def enabled_for?(feature_recipient)
43
+ return false if feature_recipient.nil?
44
+ return false if blacklisted?(feature_recipient)
45
+ return true if whitelisted?(feature_recipient)
46
+ passes_threshold?(feature_recipient)
47
+ end
48
+
49
+ def name
50
+ return I18n.translate("arturo.feature.nameless") if symbol.blank?
51
+ I18n.translate("arturo.feature.#{symbol}", :default => symbol.to_s.titleize)
52
+ end
53
+
54
+ def to_s
55
+ "Feature #{name}"
56
+ end
57
+
58
+ def to_param
59
+ new_record? ? nil : "#{id}-#{symbol.to_s.parameterize}"
60
+ end
61
+
62
+ def inspect
63
+ "<Arturo::Feature #{name}, deployed to #{deployment_percentage}%>"
64
+ end
65
+
66
+ def symbol
67
+ sym = read_attribute(:symbol).to_s
68
+ sym.blank? ? nil : sym.to_sym
69
+ end
70
+
71
+ def symbol=(sym)
72
+ write_attribute(:symbol, sym.to_s)
73
+ end
74
+
75
+ protected
76
+
77
+ def passes_threshold?(feature_recipient)
78
+ threshold = self.deployment_percentage || 0
79
+ return false if threshold == 0
80
+ return true if threshold == 100
81
+ (((feature_recipient.id + 17) * 13) % 100) < threshold
82
+
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ <tr class='feature' id="feature_<%= feature.id %>">
2
+ <td><code class='ruby symbol'><%= feature.symbol %></code></td>
3
+ <td><%= deployment_percentage_range_and_output_tags("features[#{feature.id}][deployment_percentage]", feature.deployment_percentage) %></td>
4
+ <td><%= link_to t('.edit'), edit_feature_path(feature), :rel => 'edit', :class => 'edit' %></td>
5
+ </tr>
@@ -0,0 +1,16 @@
1
+ <% form_for(feature, :as => 'feature', :url => (feature.new_record? ? features_path : feature_path(feature))) do |form| %>
2
+ <fieldset>
3
+ <legend><%= legend %></legend>
4
+
5
+ <%= form.label(:symbol) %>
6
+ <%= form.text_field(:symbol, :required => 'required', :pattern => Arturo::Feature::SYMBOL_REGEX.source, :class => 'symbol') %>
7
+ <%= error_messages_for(feature, :symbol) %>
8
+
9
+ <%= form.label(:deployment_percentage) %>
10
+ <%= form.range_field(:deployment_percentage, :min => '0', :max => '100', :step => '1', :class => 'deployment_percentage') %>
11
+ <%= deployment_percentage_output_tag 'feature_deployment_percentage', feature.deployment_percentage %>
12
+ <%= error_messages_for(feature, :deployment_percentage) %>
13
+
14
+ <footer><%= form.submit %></footer>
15
+ </fieldset>
16
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h2><%= t('.title', :name => @feature.name) %></h2>
2
+ <%= render :partial => 'form', :locals => { :feature => @feature, :legend => t('.legend', :name => @feature.name) } %>
@@ -0,0 +1,2 @@
1
+ <h2><%= t('.title') %></h2>
2
+ <p><%= t('.text') %></p>
@@ -0,0 +1,29 @@
1
+ <h2><%= t('.title') %></h2>
2
+ <% form_tag(features_path, :method => 'put', 'data-update-path' => feature_path(:id => ':id'), :remote => true) do %>
3
+ <fieldset>
4
+ <legend><%= t('.title') %></legend>
5
+ <table class='features'>
6
+ <col class='name' />
7
+ <col class='deployment_percentage' />
8
+ <col class='edit' />
9
+ <thead>
10
+ <tr>
11
+ <th><%= t('activerecord.attributes.arturo/feature.name') %></th>
12
+ <th><%= t('activerecord.attributes.arturo/feature.deployment_percentage') %></th>
13
+ <th>&nbsp;</th>
14
+ </tr>
15
+ </thead>
16
+ <tfoot>
17
+ <tr><th colspan='4'><%= link_to t('.new'), new_feature_path %> <%= submit_tag %></th></tr>
18
+ </tfoot>
19
+ <tbody>
20
+ <% @features.each do |f| %>
21
+ <%= render :partial => 'feature', :locals => { :feature => f } %>
22
+ <% end %>
23
+ <% if @features.none? %>
24
+ <tr class='if_no_features'><td colspan='4'><%= t('.none_yet') %></td></tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </fieldset>
29
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h2><%= t('.title') %></h2>
2
+ <%= render :partial => 'form', :locals => { :feature => @feature, :legend => t('.legend') } %>
@@ -0,0 +1,2 @@
1
+ <h2><%= t('.title', :name => @feature.name) %></h2>
2
+ <p>Deployment percentage: <%= @feature.deployment_percentage %></p>
@@ -0,0 +1,40 @@
1
+ en:
2
+ activerecord:
3
+ models:
4
+ "arturo/feature": "Feature"
5
+ attributes:
6
+ "arturo/feature":
7
+ symbol: "Symbol"
8
+ name: "Name"
9
+ deployment_percentage: "Deployment Percentage"
10
+ arturo:
11
+ feature:
12
+ nameless: "(no name)"
13
+ features:
14
+ index:
15
+ title: 'Features'
16
+ new: 'New'
17
+ none_yet: No features yet.
18
+ new:
19
+ title: New Feature
20
+ legend: "New Feature"
21
+ edit:
22
+ title: "Edit Feature %{name}"
23
+ legend: "Edit Feature %{name}"
24
+ feature:
25
+ edit: 'Edit'
26
+ show:
27
+ title: "Feature %{name}"
28
+ forbidden:
29
+ title: Forbidden
30
+ text: You do not have permission to access that resource.
31
+ flash:
32
+ no_such_feature: "No such feature: {id}"
33
+ error_updating: "Error updating feature #{id}"
34
+ updated_many: "Updated {count} feature(s)"
35
+ created: "Created {name}"
36
+ error_creating: "Sorry, there was an error creating the feature."
37
+ updated: "Updated {name}"
38
+ error_updating: "Sorry, there was an error updating {name}"
39
+ removed: "Removed {name}"
40
+ error_removing: "Sorry, there was an error removing {name}"
@@ -0,0 +1,10 @@
1
+ module Arturo
2
+
3
+ require 'arturo/special_handling'
4
+ require 'arturo/feature_availability'
5
+ require 'arturo/feature_management'
6
+ require 'arturo/controller_filters'
7
+ require 'arturo/range_form_support'
8
+ require 'arturo/engine' if defined?(Rails) && Rails::VERSION::MAJOR == 2 && Rails::VERSION::MINOR == 3
9
+
10
+ end
@@ -0,0 +1,33 @@
1
+ module Arturo
2
+
3
+ # Adds before filters to controllers for specifying that actions
4
+ # require features to be enabled for the requester.
5
+ #
6
+ # To configure how the controller responds when the feature is
7
+ # *not* enabled, redefine #on_feature_disabled(feature_name).
8
+ # It must render or raise an exception.
9
+ module ControllerFilters
10
+
11
+ def self.included(base)
12
+ base.extend Arturo::ControllerFilters::ClassMethods
13
+ end
14
+
15
+ def on_feature_disabled(feature_name)
16
+ render :text => 'Forbidden', :status => 403
17
+ end
18
+
19
+ module ClassMethods
20
+
21
+ def require_feature(name, options = {})
22
+ before_filter options do |controller|
23
+ unless controller.feature_enabled?(name)
24
+ controller.on_feature_disabled(name)
25
+ end
26
+ end
27
+ end
28
+
29
+ end
30
+
31
+ end
32
+
33
+ end
@@ -0,0 +1,15 @@
1
+ ActionController::Base.class_eval do
2
+ include Arturo::FeatureAvailability
3
+ helper Arturo::FeatureAvailability
4
+ include Arturo::ControllerFilters
5
+ helper Arturo::FeatureManagement
6
+ helper Arturo::RangeFormSupport::HelperMethods
7
+ end
8
+
9
+ ActionView::Helpers::FormBuilder.instance_eval do
10
+ include Arturo::RangeFormSupport::FormBuilderMethods
11
+ end
12
+
13
+ require 'rails_generator'
14
+ generators_path = File.expand_path('../../generators', __FILE__)
15
+ Rails::Generator::Base.sources << Rails::Generator::PathSource.new(:arturo, generators_path)
@@ -0,0 +1,37 @@
1
+ module Arturo
2
+
3
+ # A mixin that provides #feature_enabled? and #if_feature_enabled
4
+ # methods; to be mixed in by Controllers and Helpers. The including
5
+ # class must return some "thing that has features" (e.g. a User, Person,
6
+ # or Account) when Arturo.feature_recipient is bound to an instance
7
+ # and called.
8
+ #
9
+ # @see Arturo.feature_recipient
10
+ module FeatureAvailability
11
+
12
+ def feature_enabled?(symbol_or_feature)
13
+ feature = ::Arturo::Feature.to_feature(symbol_or_feature)
14
+ return false if feature.blank?
15
+ feature.enabled_for?(feature_recipient)
16
+ end
17
+
18
+ def if_feature_enabled(symbol_or_feature, &block)
19
+ if feature_enabled?(symbol_or_feature)
20
+ block.call
21
+ else
22
+ nil
23
+ end
24
+ end
25
+
26
+ # By default, returns current_user.
27
+ #
28
+ # If you would like to change this implementation, it is recommended
29
+ # you do so in config/initializers/arturo_initializer.rb
30
+ # @return [Object] the recipient of features.
31
+ def feature_recipient
32
+ current_user
33
+ end
34
+
35
+ end
36
+
37
+ end
@@ -0,0 +1,4 @@
1
+ Factory.define :feature, :class => Arturo::Feature do |f|
2
+ f.sequence(:symbol) { |n| "feature_#{n}".to_sym }
3
+ f.deployment_percentage { |_| rand(101) }
4
+ end
@@ -0,0 +1,23 @@
1
+ module Arturo
2
+
3
+ # A mixin that is included by Arturo::FeaturesController and is declared
4
+ # as a helper for all views. It provides a single method,
5
+ # may_manage_features?, that returns whether or not the current user
6
+ # may manage features. By default, it is implemented as follows:
7
+ #
8
+ # def may_manage_features?
9
+ # current_user.present? && current_user.admin?
10
+ # end
11
+ #
12
+ # If you would like to change this implementation, it is recommended
13
+ # you do so in config/initializers/arturo_initializer.rb
14
+ module FeatureManagement
15
+
16
+ # @return [true,false] whether the current user may manage features
17
+ def may_manage_features?
18
+ current_user.present? && current_user.admin?
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,17 @@
1
+ module Arturo
2
+ module RangeFormSupport
3
+
4
+ module HelperMethods
5
+ def range_field(object_name, method, options = {})
6
+ ActionView::Helpers::InstanceTag.new(object_name, method, self, options.delete(:object)).to_input_field_tag("range", options)
7
+ end
8
+ end
9
+
10
+ module FormBuilderMethods
11
+ def range_field(method, options = {})
12
+ @template.send('range_field', @object_name, method, objectify_options(options))
13
+ end
14
+ end
15
+
16
+ end
17
+ end
@@ -0,0 +1,62 @@
1
+ module Arturo
2
+
3
+ # Adds whitelist and blacklist support to individual features by name.
4
+ # Blacklists override whitelists. (In the world of Apache, Features
5
+ # are "(deny,allow)".)
6
+ # @example
7
+ # # allow admins:
8
+ # Arturo::Feature.whitelist(:some_feature) do |user|
9
+ # user.is_admin?
10
+ # end
11
+ #
12
+ # # disallow for small accounts:
13
+ # Arturo::Feature.blacklist(:another_feature) do |user|
14
+ # user.account.small?
15
+ # end
16
+ #
17
+ # Blacklists and whitelists can be defined before the feature exists
18
+ # and are not persisted, so they are best defined in initializers.
19
+ # This is particularly important if your application runs in several
20
+ # different processes or on several servers.
21
+ module SpecialHandling
22
+
23
+ def self.included(base)
24
+ base.extend Arturo::SpecialHandling::ClassMethods
25
+ end
26
+
27
+ module ClassMethods
28
+ def whitelists
29
+ @whitelists ||= {}
30
+ end
31
+
32
+ def blacklists
33
+ @blacklists ||= {}
34
+ end
35
+
36
+ def whitelist(feature_symbol, &block)
37
+ whitelists[feature_symbol.to_sym] = block
38
+ end
39
+
40
+ def blacklist(feature_symbol, &block)
41
+ blacklists[feature_symbol.to_sym] = block
42
+ end
43
+ end
44
+
45
+ protected
46
+
47
+ def whitelisted?(feature_recipient)
48
+ x_listed?(self.class.whitelists, feature_recipient)
49
+ end
50
+
51
+ def blacklisted?(feature_recipient)
52
+ x_listed?(self.class.blacklists, feature_recipient)
53
+ end
54
+
55
+ def x_listed?(list_map, feature_recipient)
56
+ list = list_map[self.symbol.to_sym]
57
+ list.present? && list.call(feature_recipient)
58
+ end
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,40 @@
1
+ require 'rails_generator'
2
+
3
+ class ArturoGenerator < Rails::Generator::Base
4
+ def manifest
5
+ record do |m|
6
+ m.file 'initializer.rb', 'config/initializers/arturo_initializer.rb'
7
+ m.file 'arturo.css', 'public/stylesheets/arturo.css', :collision => :force
8
+ m.file 'arturo_customizations.css', 'public/stylesheets/arturo_customizations.css', :collision => :skip
9
+ m.file 'arturo.js', 'public/javascripts/arturo.js'
10
+ m.file 'semicolon.png', 'public/images/semicolon.png'
11
+ m.migration_template 'migration.rb', 'db/migrate', :migration_file_name => 'create_features'
12
+ add_feature_routes(m)
13
+ end
14
+ end
15
+
16
+ protected
17
+
18
+ def source_root
19
+ File.expand_path('../templates', __FILE__)
20
+ end
21
+
22
+ def banner
23
+ %{Usage: #{$0} #{spec.name}\nCopies an initializer; copies CSS, JS, and PNG assets; generates a migration; adds routes/}
24
+ end
25
+
26
+ def add_feature_routes(manifest)
27
+ sentinel = 'ActionController::Routing::Routes.draw do |map|'
28
+ logger.route "map.resources features"
29
+ unless options[:pretend]
30
+ manifest.gsub_file 'config/routes.rb', /(#{Regexp.escape(sentinel)})/mi do |match|
31
+ "#{match}#{feature_routes}\n"
32
+ end
33
+ end
34
+ end
35
+
36
+ def feature_routes
37
+ "\n map.resources :features, :controller => 'arturo/features'" +
38
+ "\n map.features 'features', :controller => 'arturo/features', :action => 'update_all', :conditions => { :method => :put }"
39
+ end
40
+ end
@@ -0,0 +1,67 @@
1
+ /*
2
+ WARNING:
3
+
4
+ Do not edit this file. Any changes you make to this file will be overwritten
5
+ when you regenerate the arturo assets (which happens when you upgrade the gem).
6
+ Instead, make customizations to arturo_customizations.css.
7
+ */
8
+
9
+ .features code.symbol:before { content: ":"; }
10
+
11
+ .features { border-collapse: collapse; }
12
+
13
+ .features thead tr:last-child th { border-bottom: 1px solid; }
14
+ .features tfoot tr:first-child th { border-top: 1px solid; }
15
+
16
+ .features th, .features td {
17
+ margin: 0;
18
+ padding: 0.5em 1.5em;
19
+ text-align: left;
20
+ }
21
+
22
+ input.deployment_percentage[type=range] { width: 200px; }
23
+
24
+ output.deployment_percentage.no_js { display: none; }
25
+ output.deployment_percentage { margin-left: 1em; }
26
+ output.deployment_percentage:after { content: "%"; }
27
+
28
+ .features a[rel=edit] { visibility: hidden; }
29
+ .features tr:hover a[rel=edit] { visibility: inherit; }
30
+
31
+ .features tfoot th {
32
+ text-align: right;
33
+ }
34
+
35
+ .features tfoot th * + * {
36
+ margin-left: 2em;
37
+ }
38
+
39
+ .feature_new label, .feature_edit label { font-weight: bold; }
40
+
41
+ .feature_new label, .feature_new .errors,
42
+ .feature_edit label, .feature_edit .errors {
43
+ display: block;
44
+ }
45
+
46
+ .feature_new label + input, .feature_new label + textarea, .feature_new label + select,
47
+ .feature_edit label + input, .feature_edit label + textarea, .feature_edit label + select {
48
+ margin-top: 0.5em;
49
+ }
50
+
51
+ .feature_new input + label, .feature_new textarea + label, .feature_new select + label,
52
+ .feature_edit input + label, .feature_edit textarea + label, .feature_edit select + label {
53
+ margin-top: 1.5em;
54
+ }
55
+
56
+ .feature_new input[type=text], .feature_edit input[type=text] { padding: 0.5em; }
57
+
58
+ .feature_new input.symbol, .feature_edit input.symbol {
59
+ background: transparent url('/images/semicolon.png') no-repeat 3px 4px;
60
+ font-family: "DejaVu Sans Mono", "Droid Sans Mono", "Mondale", monospace;
61
+ padding-left: 9px;
62
+ }
63
+
64
+ .feature_new .errors, .feature_edit .errors { color: red; }
65
+ .feature_new :invalid { border-color: red; }
66
+
67
+ .feature_new footer, .feature_edit footer { margin-top: 2em; }
@@ -0,0 +1,23 @@
1
+ if (typeof(jQuery) === 'function') {
2
+ jQuery.arturo = {
3
+ agentSupportsHTML5Output: ('for' in jQuery('<output />')),
4
+
5
+ linkAndShowOutputs: function() {
6
+ if (jQuery.arturo.agentSupportsHTML5Output) {
7
+ jQuery('.features output,.feature_new output,.feature_edit output').each(function(i, output) {
8
+ var output = jQuery(output);
9
+ var input = jQuery('#' + output.attr('for'));
10
+ input.change(function() {
11
+ console.log('input value changed to ' + input.val());
12
+ output.val(input.val());
13
+ });
14
+ output.removeClass('no_js');
15
+ });
16
+ }
17
+ }
18
+ };
19
+
20
+ jQuery(function() {
21
+ jQuery.arturo.linkAndShowOutputs();
22
+ });
23
+ }
@@ -0,0 +1 @@
1
+ /* Make any customizations to the Arturo styles here */
@@ -0,0 +1,29 @@
1
+ require 'arturo'
2
+
3
+ # Configure who may manage features here.
4
+ # The following is the default implementation.
5
+ # Arturo::FeatureManagement.class_eval do
6
+ # def may_manage_features?
7
+ # current_user.present? && current_user.admin?
8
+ # end
9
+ # end
10
+
11
+ # Configure what receives features here.
12
+ # The following is the default implementation.
13
+ # Arturo::FeatureAvailability.class_eval do
14
+ # def feature_recipient
15
+ # current_user
16
+ # end
17
+ # end
18
+
19
+ # Whitelists and Blacklists:
20
+ #
21
+ # Enable feature one for all admins:
22
+ # Arturo::Feature.whitelist(:feature_one) do |user|
23
+ # user.admin?
24
+ # end
25
+ #
26
+ # Disable feature two for all small accounts:
27
+ # Arturo::Feature.blacklist(:feature_two) do |user|
28
+ # user.account.small?
29
+ # end
@@ -0,0 +1,17 @@
1
+ require 'active_support/core_ext'
2
+
3
+ class CreateFeatures < ActiveRecord::Migration
4
+ def self.up
5
+ create_table :features do |t|
6
+ t.string :symbol, :null => false
7
+ t.integer :deployment_percentage, :null => false
8
+ #Any additional fields here
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+
14
+ def self.down
15
+ drop_table :features
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,184 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: arturo
3
+ version: !ruby/object:Gem::Version
4
+ hash: 81
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 2
9
+ - 3
10
+ - 1
11
+ version: 0.2.3.1
12
+ platform: ruby
13
+ authors:
14
+ - James A. Rosen
15
+ autorequire:
16
+ bindir: bin
17
+ cert_chain: []
18
+
19
+ date: 2010-10-31 00:00:00 -07:00
20
+ default_executable:
21
+ dependencies:
22
+ - !ruby/object:Gem::Dependency
23
+ name: rails
24
+ prerelease: false
25
+ requirement: &id001 !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ~>
29
+ - !ruby/object:Gem::Version
30
+ hash: 19
31
+ segments:
32
+ - 2
33
+ - 3
34
+ - 8
35
+ version: 2.3.8
36
+ type: :runtime
37
+ version_requirements: *id001
38
+ - !ruby/object:Gem::Dependency
39
+ name: mocha
40
+ prerelease: false
41
+ requirement: &id002 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ hash: 3
47
+ segments:
48
+ - 0
49
+ version: "0"
50
+ type: :development
51
+ version_requirements: *id002
52
+ - !ruby/object:Gem::Dependency
53
+ name: rake
54
+ prerelease: false
55
+ requirement: &id003 !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ hash: 3
61
+ segments:
62
+ - 0
63
+ version: "0"
64
+ type: :development
65
+ version_requirements: *id003
66
+ - !ruby/object:Gem::Dependency
67
+ name: redgreen
68
+ prerelease: false
69
+ requirement: &id004 !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ~>
73
+ - !ruby/object:Gem::Version
74
+ hash: 11
75
+ segments:
76
+ - 1
77
+ - 2
78
+ version: "1.2"
79
+ type: :development
80
+ version_requirements: *id004
81
+ - !ruby/object:Gem::Dependency
82
+ name: sqlite3-ruby
83
+ prerelease: false
84
+ requirement: &id005 !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ~>
88
+ - !ruby/object:Gem::Version
89
+ hash: 9
90
+ segments:
91
+ - 1
92
+ - 3
93
+ version: "1.3"
94
+ type: :development
95
+ version_requirements: *id005
96
+ - !ruby/object:Gem::Dependency
97
+ name: factory_girl
98
+ prerelease: false
99
+ requirement: &id006 !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ~>
103
+ - !ruby/object:Gem::Version
104
+ hash: 9
105
+ segments:
106
+ - 1
107
+ - 3
108
+ version: "1.3"
109
+ type: :development
110
+ version_requirements: *id006
111
+ description: Deploy features incrementally to your users
112
+ email: james.a.rosen@gmail.com
113
+ executables: []
114
+
115
+ extensions: []
116
+
117
+ extra_rdoc_files: []
118
+
119
+ files:
120
+ - lib/arturo/controller_filters.rb
121
+ - lib/arturo/engine.rb
122
+ - lib/arturo/feature_availability.rb
123
+ - lib/arturo/feature_factories.rb
124
+ - lib/arturo/feature_management.rb
125
+ - lib/arturo/range_form_support.rb
126
+ - lib/arturo/special_handling.rb
127
+ - lib/arturo.rb
128
+ - lib/generators/arturo/arturo_generator.rb
129
+ - lib/generators/arturo/templates/arturo.css
130
+ - lib/generators/arturo/templates/arturo.js
131
+ - lib/generators/arturo/templates/arturo_customizations.css
132
+ - lib/generators/arturo/templates/initializer.rb
133
+ - lib/generators/arturo/templates/migration.rb
134
+ - lib/generators/arturo/templates/semicolon.png
135
+ - app/controllers/arturo/features_controller.rb
136
+ - app/helpers/arturo/features_helper.rb
137
+ - app/models/arturo/feature.rb
138
+ - app/views/arturo/features/_feature.html.erb
139
+ - app/views/arturo/features/_form.html.erb
140
+ - app/views/arturo/features/edit.html.erb
141
+ - app/views/arturo/features/forbidden.html.erb
142
+ - app/views/arturo/features/index.html.erb
143
+ - app/views/arturo/features/new.html.erb
144
+ - app/views/arturo/features/show.html.erb
145
+ - config/locales/en.yml
146
+ - README.md
147
+ - HISTORY.md
148
+ has_rdoc: true
149
+ homepage: http://github.com/jamesarosen/arturo
150
+ licenses: []
151
+
152
+ post_install_message:
153
+ rdoc_options: []
154
+
155
+ require_paths:
156
+ - .
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ none: false
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ hash: 3
164
+ segments:
165
+ - 0
166
+ version: "0"
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ none: false
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ hash: 3
173
+ segments:
174
+ - 0
175
+ version: "0"
176
+ requirements: []
177
+
178
+ rubyforge_project:
179
+ rubygems_version: 1.3.7
180
+ signing_key:
181
+ specification_version: 2
182
+ summary: Feature sliders, wrapped up in an engine
183
+ test_files: []
184
+