arturo 0.2.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+