blackbeard 0.0.4.0 → 0.0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +44 -16
- data/TODO.md +3 -1
- data/dashboard/routes/cohorts.rb +25 -0
- data/dashboard/routes/features.rb +7 -4
- data/dashboard/routes/groups.rb +6 -3
- data/dashboard/routes/metrics.rb +25 -5
- data/dashboard/routes/tests.rb +6 -3
- data/dashboard/views/cohorts/index.erb +22 -0
- data/dashboard/views/cohorts/show.erb +41 -0
- data/dashboard/views/groups/show.erb +2 -2
- data/dashboard/views/layout.erb +1 -0
- data/dashboard/views/metrics/show.erb +37 -10
- data/dashboard/views/shared/_charts.erb +20 -0
- data/lib/blackbeard/chart.rb +65 -0
- data/lib/blackbeard/chartable.rb +47 -0
- data/lib/blackbeard/cohort.rb +48 -0
- data/lib/blackbeard/cohort_data.rb +72 -0
- data/lib/blackbeard/cohort_metric.rb +47 -0
- data/lib/blackbeard/context.rb +11 -2
- data/lib/blackbeard/dashboard.rb +2 -3
- data/lib/blackbeard/dashboard_helpers.rb +0 -22
- data/lib/blackbeard/errors.rb +2 -0
- data/lib/blackbeard/group.rb +3 -0
- data/lib/blackbeard/group_metric.rb +39 -0
- data/lib/blackbeard/metric.rb +30 -14
- data/lib/blackbeard/metric_data/base.rb +18 -35
- data/lib/blackbeard/metric_data/total.rb +2 -1
- data/lib/blackbeard/metric_data/uid_generator.rb +38 -0
- data/lib/blackbeard/metric_data/unique.rb +1 -1
- data/lib/blackbeard/metric_date.rb +10 -0
- data/lib/blackbeard/metric_hour.rb +8 -0
- data/lib/blackbeard/pirate.rb +18 -0
- data/lib/blackbeard/redis_store.rb +10 -1
- data/lib/blackbeard/storable.rb +1 -0
- data/lib/blackbeard/version.rb +1 -1
- data/spec/chart_spec.rb +38 -0
- data/spec/chartable_spec.rb +56 -0
- data/spec/cohort_data_spec.rb +142 -0
- data/spec/cohort_metric_spec.rb +26 -0
- data/spec/cohort_spec.rb +31 -0
- data/spec/context_spec.rb +9 -1
- data/spec/dashboard/cohorts_spec.rb +43 -0
- data/spec/dashboard/groups_spec.rb +0 -7
- data/spec/dashboard/metrics_spec.rb +35 -0
- data/spec/group_metric_spec.rb +26 -0
- data/spec/metric_data/base_spec.rb +0 -16
- data/spec/metric_data/uid_generator_spec.rb +40 -0
- data/spec/metric_spec.rb +23 -12
- data/spec/pirate_spec.rb +22 -1
- data/spec/redis_store_spec.rb +8 -2
- data/spec/storable_spec.rb +3 -0
- metadata +29 -3
- data/dashboard/views/metrics/_metric_data.erb +0 -59
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 07e955101ffdd9c1bfe155e1916ebfc0a9911f4d
|
4
|
+
data.tar.gz: eaab8a527217bc44eaadf13665a230cd46e8ae65
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d16393da029f3e584a1b632332582b20645dd1857b7b845012546bd0b939a99251969c6ec5e658fb425e61cb5cdf4ad2588461883a5128541f0a18ea66ca54d8
|
7
|
+
data.tar.gz: 5f5154181ac58db415657199eddb3f6e19ff236e999bf21bd74fc597903ab63350f783face5647bd534b5dd1b6c8580ffbe439616a5b812fc77320af3325e838
|
data/README.md
CHANGED
@@ -109,7 +109,7 @@ $pirate.context(user).add_metric(:referral)
|
|
109
109
|
|
110
110
|
If a context does not exist, `$pirate` will silently ignore all actions. This is useful for dealing with bots.
|
111
111
|
|
112
|
-
If the user is
|
112
|
+
If the user is unidentified set user to nil or false. If your app can return a Guest object for unidentified users, see the guest configuration setting.
|
113
113
|
|
114
114
|
### Collecting Metrics
|
115
115
|
|
@@ -215,33 +215,61 @@ if $pirate.context(user).feature_active?(:friend_feed){ ... }
|
|
215
215
|
|
216
216
|
### Defining groups
|
217
217
|
|
218
|
+
Group definitions are part of the configuration and should be defined
|
219
|
+
in an initializer.
|
220
|
+
|
218
221
|
```ruby
|
219
|
-
$pirate.
|
220
|
-
|
221
|
-
|
222
|
+
$pirate = Blackbeard.pirate do |config|
|
223
|
+
config.define_group(:admin) do |user, controller|
|
224
|
+
user && user.admin? # true, false
|
225
|
+
end
|
222
226
|
|
223
|
-
|
224
|
-
|
225
|
-
end
|
227
|
+
config.define_group(:seo_traffic) do |user, controller|
|
228
|
+
controller && controller.session.refer =~ /google.com/ # remember to store refer in sessions
|
229
|
+
end
|
226
230
|
|
227
|
-
|
228
|
-
|
229
|
-
end
|
231
|
+
config.define_group(:seo_traffic) do |user, controller|
|
232
|
+
controller && controller.session.refer =~ /google.com/ # remember to store refer in sessions
|
233
|
+
end
|
230
234
|
|
231
|
-
|
232
|
-
|
235
|
+
config.define_group(:purchasers) do |user, controller|
|
236
|
+
users && user.purchases.any?
|
237
|
+
end
|
233
238
|
end
|
234
239
|
```
|
240
|
+
The user will be nil for non-logged in visitors. The controller
|
241
|
+
will be nil if not defined by the context (e.g. outside a web context).
|
235
242
|
|
236
|
-
If your group is
|
243
|
+
If your group is segmented (doesn't return true or false), include a list possible segments.
|
237
244
|
|
238
245
|
```ruby
|
239
|
-
$pirate
|
240
|
-
|
246
|
+
$pirate = Blackbeard.pirate do |config|
|
247
|
+
...
|
248
|
+
config.define_group(:medalist, [:bronze, :silver, :gold]) do |user, context|
|
249
|
+
user.engagement_level # nil, :bronze, :silver, :gold
|
250
|
+
end
|
241
251
|
end
|
242
252
|
```
|
243
253
|
|
244
|
-
If your group definition block returns an uninitialized segment, it wil
|
254
|
+
If your group definition block returns an uninitialized segment, it wil
|
255
|
+
be initialized automatically.
|
256
|
+
|
257
|
+
### Defining Cohorts
|
258
|
+
|
259
|
+
Groups and cohorts in the dictionary sense are identical. For our purposes,
|
260
|
+
cohorts are participants that experienced something at the same time. A participant
|
261
|
+
can only be in one cohort at a time, but can switch cohorts.
|
262
|
+
|
263
|
+
This will add the current user/visitor and current timestamp to the cohort.
|
264
|
+
|
265
|
+
```ruby
|
266
|
+
$pirate.add_to_cohort(:joined_at) # returns false if already in cohort, otherwise true
|
267
|
+
$pirate.add_to_cohort!(:bought_at) # always returns true. If user is already in a cohort, the timestamp is updated.
|
268
|
+
$pirate.add_to_cohort(:joined_at, user.created_at) # You can optional pass in the timestamp
|
269
|
+
```
|
270
|
+
|
271
|
+
You'll can add cohorts to metrics compare how members who joined one day against
|
272
|
+
members who joined another day.
|
245
273
|
|
246
274
|
## Contributing
|
247
275
|
|
data/TODO.md
CHANGED
@@ -48,9 +48,11 @@ $pirate.funnel(:checkout, 'Confirm') # User reached step 3 of funnel (C
|
|
48
48
|
```
|
49
49
|
|
50
50
|
|
51
|
+
```ruby
|
51
52
|
$pirate.define_group(:achieve_pirate_style) do |user, context|
|
52
53
|
$pirate.metric(:pirate_style).achieved?
|
53
54
|
end
|
55
|
+
```
|
54
56
|
|
55
57
|
|
56
|
-
http://blog.sourcing.io/structuring-sinatra
|
58
|
+
[Structuring Sinatra applications](http://blog.sourcing.io/structuring-sinatra)
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Blackbeard
|
2
|
+
module DashboardRoutes
|
3
|
+
class Cohorts < Base
|
4
|
+
get '/cohorts' do
|
5
|
+
@cohorts = Cohort.all
|
6
|
+
erb 'cohorts/index'.to_sym
|
7
|
+
end
|
8
|
+
|
9
|
+
get '/cohorts/:id' do
|
10
|
+
ensure_cohort
|
11
|
+
erb 'cohorts/show'.to_sym
|
12
|
+
end
|
13
|
+
|
14
|
+
post "/cohorts/:id" do
|
15
|
+
ensure_cohort.update_attributes(params)
|
16
|
+
"OK"
|
17
|
+
end
|
18
|
+
|
19
|
+
def ensure_cohort
|
20
|
+
@cohort = Cohort.find(params[:id]) or pass
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -8,24 +8,27 @@ module Blackbeard
|
|
8
8
|
end
|
9
9
|
|
10
10
|
get "/features/:id" do
|
11
|
-
|
11
|
+
ensure_feature
|
12
12
|
@groups = Group.all
|
13
13
|
erb 'features/show'.to_sym
|
14
14
|
end
|
15
15
|
|
16
16
|
post "/features/:id" do
|
17
|
-
|
18
|
-
@feature.update_attributes(params)
|
17
|
+
ensure_feature.update_attributes(params)
|
19
18
|
"OK"
|
20
19
|
end
|
21
20
|
|
22
21
|
post "/features/:id/groups/:group_id" do
|
23
|
-
|
22
|
+
ensure_feature
|
24
23
|
@feature.set_segments_for(params[:group_id], params[:segments])
|
25
24
|
@feature.save
|
26
25
|
"OK"
|
27
26
|
end
|
28
27
|
|
28
|
+
def ensure_feature
|
29
|
+
@feature = Feature.find(params[:id]) or pass
|
30
|
+
end
|
31
|
+
|
29
32
|
end
|
30
33
|
end
|
31
34
|
end
|
data/dashboard/routes/groups.rb
CHANGED
@@ -7,16 +7,19 @@ module Blackbeard
|
|
7
7
|
end
|
8
8
|
|
9
9
|
get '/groups/:id' do
|
10
|
-
|
10
|
+
ensure_group
|
11
11
|
erb 'groups/show'.to_sym
|
12
12
|
end
|
13
13
|
|
14
14
|
post "/groups/:id" do
|
15
|
-
|
16
|
-
@group.update_attributes(params)
|
15
|
+
ensure_group.update_attributes(params)
|
17
16
|
"OK"
|
18
17
|
end
|
19
18
|
|
19
|
+
def ensure_group
|
20
|
+
@group = Group.find(params[:id]) or pass
|
21
|
+
end
|
22
|
+
|
20
23
|
end
|
21
24
|
end
|
22
25
|
end
|
data/dashboard/routes/metrics.rb
CHANGED
@@ -7,24 +7,44 @@ module Blackbeard
|
|
7
7
|
end
|
8
8
|
|
9
9
|
get "/metrics/:type/:type_id" do
|
10
|
-
|
11
|
-
@group = Group.find(params[:group_id]) if params[:group_id]
|
10
|
+
ensure_metric; find_group; find_cohort; ensure_charts
|
12
11
|
erb 'metrics/show'.to_sym
|
13
12
|
end
|
14
13
|
|
15
14
|
post "/metrics/:type/:type_id" do
|
16
|
-
|
15
|
+
ensure_metric
|
17
16
|
@metric.update_attributes(params)
|
18
17
|
"OK"
|
19
18
|
end
|
20
19
|
|
21
20
|
post "/metrics/:type/:type_id/groups" do
|
22
|
-
|
23
|
-
@group = Group.find(params[:group_id])
|
21
|
+
ensure_metric; find_group
|
24
22
|
@metric.add_group(@group) if @group
|
25
23
|
redirect url("metrics/#{@metric.type}/#{@metric.type_id}?group_id=#{@group.id}")
|
26
24
|
end
|
27
25
|
|
26
|
+
post "/metrics/:type/:type_id/cohorts" do
|
27
|
+
ensure_metric; find_cohort
|
28
|
+
@metric.add_cohort(@cohort) if @cohort
|
29
|
+
redirect url("metrics/#{@metric.type}/#{@metric.type_id}?cohort_id=#{@cohort.id}")
|
30
|
+
end
|
31
|
+
|
32
|
+
def ensure_metric
|
33
|
+
@metric = Metric.find(params[:type], params[:type_id]) or pass
|
34
|
+
end
|
35
|
+
|
36
|
+
def find_group
|
37
|
+
@group = Group.find(params[:group_id]) if params[:group_id]
|
38
|
+
end
|
39
|
+
|
40
|
+
def find_cohort
|
41
|
+
@cohort = Cohort.find(params[:cohort_id]) if params[:cohort_id]
|
42
|
+
end
|
43
|
+
|
44
|
+
def ensure_charts
|
45
|
+
@charts = []
|
46
|
+
end
|
47
|
+
|
28
48
|
end
|
29
49
|
end
|
30
50
|
end
|
data/dashboard/routes/tests.rb
CHANGED
@@ -8,16 +8,19 @@ module Blackbeard
|
|
8
8
|
end
|
9
9
|
|
10
10
|
get "/tests/:id" do
|
11
|
-
|
11
|
+
ensure_test
|
12
12
|
erb 'tests/show'.to_sym
|
13
13
|
end
|
14
14
|
|
15
15
|
post "/tests/:id" do
|
16
|
-
|
17
|
-
@test.update_attributes(params)
|
16
|
+
ensure_test.update_attributes(params)
|
18
17
|
"OK"
|
19
18
|
end
|
20
19
|
|
20
|
+
def ensure_test
|
21
|
+
@test = Test.find(params[:id]) or pass
|
22
|
+
end
|
23
|
+
|
21
24
|
end
|
22
25
|
end
|
23
26
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
<div class="page-header">
|
2
|
+
<h1>Cohorts</h1>
|
3
|
+
<p>Are groupings of visitors that experience a certain event at the same time.</p>
|
4
|
+
</div>
|
5
|
+
|
6
|
+
<% if @cohorts.any? %>
|
7
|
+
<div class="panel panel-primary">
|
8
|
+
<div class="panel-heading">
|
9
|
+
<h3 class="panel-title">Active Cohorts</h3>
|
10
|
+
</div>
|
11
|
+
<ul class="list-group">
|
12
|
+
<% @cohorts.each do |cohort| %>
|
13
|
+
<li class="list-group-item"><a href="<%= url("cohorts/#{cohort.id}") %>"><%= cohort.name %></a></li>
|
14
|
+
<% end %>
|
15
|
+
</ul>
|
16
|
+
</div>
|
17
|
+
|
18
|
+
<% else %>
|
19
|
+
<div class="alert alert-danger">
|
20
|
+
<strong>No cohorts!</strong> Something may be wrong or you may just need to define some cohorts.
|
21
|
+
</div>
|
22
|
+
<% end %>
|
@@ -0,0 +1,41 @@
|
|
1
|
+
<div class="page-header">
|
2
|
+
<h1><span id="editable-name"><%= @cohort.name %></span> <small>Group</small></h1>
|
3
|
+
<p id="editable-description"><%= @cohort.description %></p>
|
4
|
+
</div>
|
5
|
+
|
6
|
+
<!--Load the AJAX API-->
|
7
|
+
<script src="<%= url('javascripts/jquery-1.10.2.min.js') %>"></script>
|
8
|
+
<script src="<%= url('javascripts/bootstrap.min.js') %>"></script>
|
9
|
+
<script src="<%= url('bootstrap3-editable/js/bootstrap-editable.min.js') %>"></script>
|
10
|
+
<script type="text/javascript">
|
11
|
+
//turn to inline mode
|
12
|
+
$.fn.editable.defaults.send = 'always';
|
13
|
+
$.fn.editable.defaults.params = function(params) {
|
14
|
+
var obj = {};
|
15
|
+
obj[params.name] = params.value;
|
16
|
+
return obj;
|
17
|
+
}
|
18
|
+
|
19
|
+
$(document).ready(function() {
|
20
|
+
$('#editable-name').editable({
|
21
|
+
placement: 'right',
|
22
|
+
type: 'text',
|
23
|
+
url: '<%= url("groups/#{@cohort.id}") %>',
|
24
|
+
name: 'name',
|
25
|
+
title: 'Edit cohort name',
|
26
|
+
validate: function(value) {
|
27
|
+
if($.trim(value) == '') {
|
28
|
+
return 'This field is required';
|
29
|
+
}
|
30
|
+
}
|
31
|
+
});
|
32
|
+
$('#editable-description').editable({
|
33
|
+
placement: 'bottom',
|
34
|
+
type: 'textarea',
|
35
|
+
url: '<%= url("metrics/#{@cohort.id}") %>',
|
36
|
+
name: 'description',
|
37
|
+
title: 'Edit cohort description'
|
38
|
+
});
|
39
|
+
});
|
40
|
+
</script>
|
41
|
+
<%= partial :'shared/charts', :locals => {:charts => [@cohort.recent_hours_chart, @cohort.recent_days_chart]} %>
|
@@ -50,9 +50,9 @@
|
|
50
50
|
$('#editable-description').editable({
|
51
51
|
placement: 'bottom',
|
52
52
|
type: 'textarea',
|
53
|
-
url: '<%= url("
|
53
|
+
url: '<%= url("groups/#{@group.id}") %>',
|
54
54
|
name: 'description',
|
55
|
-
title: 'Edit
|
55
|
+
title: 'Edit group description'
|
56
56
|
});
|
57
57
|
});
|
58
58
|
</script>
|
data/dashboard/views/layout.erb
CHANGED
@@ -33,6 +33,7 @@
|
|
33
33
|
</div>
|
34
34
|
<div class="navbar-collapse collapse">
|
35
35
|
<ul class="nav navbar-nav">
|
36
|
+
<li><a href="<%= url('cohorts') %>">Cohorts</a></li>
|
36
37
|
<li><a href="<%= url('features') %>">Features</a></li>
|
37
38
|
<li><a href="<%= url('groups') %>">Groups</a></li>
|
38
39
|
<li><a href="<%= url('metrics') %>">Metrics</a></li>
|
@@ -3,21 +3,38 @@
|
|
3
3
|
<p id="editable-description"><%= @metric.description %></p>
|
4
4
|
</div>
|
5
5
|
<form id="add_group" action="<%= url("/metrics/#{@metric.type}/#{@metric.type_id}/groups") %>" method="post"><input id="add_group_input" type="hidden" name="group_id"></form>
|
6
|
+
<form id="add_cohort" action="<%= url("/metrics/#{@metric.type}/#{@metric.type_id}/cohorts") %>" method="post"><input id="add_cohort_input" type="hidden" name="cohort_id"></form>
|
6
7
|
<ul class="nav nav-tabs">
|
7
|
-
<% if @group.nil? %><li class="active"><% else %><li><% end %><a href="?">All</a></li>
|
8
|
+
<% if @group.nil? && @cohort.nil? %><li class="active"><% else %><li><% end %><a href="?">All</a></li>
|
8
9
|
<% @metric.groups.each do |group| %>
|
9
10
|
<% if @group == group %><li class="active"><% else %><li><% end %><a href="?group_id=<%= group.id %>"><%= group.name %></a></li>
|
10
11
|
<% end %>
|
11
|
-
<%
|
12
|
+
<% @metric.cohorts.each do |cohort| %>
|
13
|
+
<% if @cohort == cohort %><li class="active"><% else %><li><% end %><a href="?cohort_id=<%= cohort.id %>"><%= cohort.name %></a></li>
|
14
|
+
<% end %>
|
15
|
+
|
12
16
|
<li class="dropdown">
|
13
|
-
<a id="drop5" role="button" data-toggle="dropdown" href="#">
|
17
|
+
<a id="drop5" role="button" data-toggle="dropdown" href="#">Add <b class="caret"></b></a>
|
14
18
|
<ul id="menu2" class="dropdown-menu" role="menu" aria-labelledby="drop5">
|
15
|
-
<% @metric.addable_groups.
|
16
|
-
|
19
|
+
<% if @metric.addable_groups.any? %>
|
20
|
+
<li role="presentation" class="dropdown-header">Group Metrics</li>
|
21
|
+
<% @metric.addable_groups.each do |group| %>
|
22
|
+
<li role="presentation" data-group-id="<%= group.id %>" data-group-name="<%= group.name %>" class="add-group"><a role="menuitem" tabindex="-1" href="#"><%= group.name %></a></li>
|
23
|
+
<% end %>
|
24
|
+
<% else %>
|
25
|
+
<li role="presentation" class="dropdown-header">No Group Metrics</li>
|
26
|
+
<% end %>
|
27
|
+
<li role="presentation" class="divider"></li>
|
28
|
+
<% if @metric.addable_cohorts.any? %>
|
29
|
+
<li role="presentation" class="dropdown-header">Cohort Metrics</li>
|
30
|
+
<% @metric.addable_cohorts.each do |cohort| %>
|
31
|
+
<li role="presentation" data-cohort-id="<%= cohort.id %>" data-cohort-name="<%= cohort.name %>" class="add-cohort"><a role="menuitem" tabindex="-1" href="#"><%= cohort.name %></a></li>
|
32
|
+
<% end %>
|
33
|
+
<% else %>
|
34
|
+
<li role="presentation" class="dropdown-header">No Cohort Metrics</li>
|
17
35
|
<% end %>
|
18
36
|
</ul>
|
19
37
|
</li>
|
20
|
-
<% end %>
|
21
38
|
</ul>
|
22
39
|
|
23
40
|
<!--Load the AJAX API-->
|
@@ -37,6 +54,19 @@
|
|
37
54
|
}
|
38
55
|
});
|
39
56
|
|
57
|
+
$('ul.nav-tabs').on("click", ".add-cohort", function (e) {
|
58
|
+
e.preventDefault();
|
59
|
+
var cohortId = $(this).data("cohort-id");
|
60
|
+
var cohortName = $(this).data("cohort-name");
|
61
|
+
|
62
|
+
if(confirm("Start collecting data by cohort "+cohortName+"?")) {
|
63
|
+
$('#add_cohort_input').val(cohortId);
|
64
|
+
$('#add_cohort').submit();
|
65
|
+
}
|
66
|
+
});
|
67
|
+
|
68
|
+
|
69
|
+
|
40
70
|
//turn to inline mode
|
41
71
|
$.fn.editable.defaults.send = 'always';
|
42
72
|
$.fn.editable.defaults.params = function(params) {
|
@@ -67,7 +97,4 @@
|
|
67
97
|
});
|
68
98
|
});
|
69
99
|
</script>
|
70
|
-
<%= partial :'
|
71
|
-
|
72
|
-
|
73
|
-
|
100
|
+
<%= partial :'shared/charts', :locals => {:charts => @charts } %>
|
@@ -0,0 +1,20 @@
|
|
1
|
+
<script type="text/javascript" src="https://www.google.com/jsapi"></script>
|
2
|
+
<script type="text/javascript">
|
3
|
+
// Load the Visualization API and the piechart package.
|
4
|
+
google.load('visualization', '1.0', {'packages':['corechart']});
|
5
|
+
</script>
|
6
|
+
|
7
|
+
<% charts.each do |chart| %>
|
8
|
+
<div id="<%= chart.dom_id %>"></div>
|
9
|
+
<script type="text/javascript">
|
10
|
+
// Set a callback to run when the Google Visualization API is loaded.
|
11
|
+
google.setOnLoadCallback(
|
12
|
+
function(){
|
13
|
+
var data = new google.visualization.DataTable(jQuery.parseJSON('<%= chart.data.to_json %>'));
|
14
|
+
var options = jQuery.parseJSON('<%= chart.options.to_json %>');
|
15
|
+
var div = document.getElementById('<%= chart.dom_id %>');
|
16
|
+
new google.visualization.LineChart(div).draw(data, options);
|
17
|
+
});
|
18
|
+
|
19
|
+
</script>
|
20
|
+
<% end %>
|