padrino-admin 0.2.9 → 0.4.5
Sign up to get free protection for your applications and to get access to all the features.
- data/Rakefile +9 -8
- data/VERSION +1 -1
- data/lib/padrino-admin.rb +11 -2
- data/lib/padrino-admin/access_control.rb +353 -0
- data/lib/padrino-admin/access_control/helpers.rb +81 -0
- data/lib/padrino-admin/adapters.rb +82 -0
- data/lib/padrino-admin/adapters/ar.rb +75 -0
- data/lib/padrino-admin/adapters/dm.rb +131 -0
- data/lib/padrino-admin/adapters/mm.rb +49 -0
- data/lib/padrino-admin/ext_js/config.rb +153 -151
- data/lib/padrino-admin/ext_js/controller.rb +167 -0
- data/lib/padrino-admin/generators/backend.rb +1 -1
- data/lib/padrino-admin/locale/en.yml +7 -0
- data/lib/padrino-admin/support.rb +12 -0
- data/lib/padrino-admin/utils/crypt.rb +29 -0
- data/padrino-admin.gemspec +27 -6
- data/test/fixtures/active_record.rb +17 -0
- data/test/fixtures/data_mapper.rb +36 -0
- data/test/fixtures/mongo_mapper.rb +12 -0
- data/test/helper.rb +44 -48
- data/test/test_access_control.rb +98 -0
- data/test/test_active_record.rb +28 -0
- data/test/test_admin_application.rb +38 -0
- data/test/test_controller.rb +28 -0
- data/test/test_data_mapper.rb +32 -0
- data/test/test_mongo_mapper.rb +28 -0
- data/test/test_parsing.rb +12 -12
- metadata +33 -5
- data/test/test_padrino_admin.rb +0 -7
@@ -0,0 +1,167 @@
|
|
1
|
+
module Padrino
|
2
|
+
module ExtJs
|
3
|
+
# Return column config, and store config/data for ExtJS ColumnModel and Store
|
4
|
+
#
|
5
|
+
# Examples:
|
6
|
+
#
|
7
|
+
# # app/controllers/backend/debtors_controller.rb
|
8
|
+
# def index
|
9
|
+
# @column_store = column_store_for Debtor do |cm|
|
10
|
+
# cm.add :id
|
11
|
+
# cm.add "full_name_or_company.upcase", "Full Name", :sortable => true, :dataIndex => :company
|
12
|
+
# cm.add :surname # Header will be autogenerated
|
13
|
+
# cm.add :email, "Email", :sortable => true
|
14
|
+
# cm.add :piva, "Piva", :sortable => true
|
15
|
+
# cm.add :created_at, "Creato il", :sortable => true, :renderer => :date, :align => :right
|
16
|
+
# cm.add :updated_at, "Aggiornato il", :sortable => true, :renderer => :datetime, :align => :right
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# respond_to do |format|
|
20
|
+
# format.js
|
21
|
+
# format.json do
|
22
|
+
# render :json => @column_store.store_data(params)
|
23
|
+
#
|
24
|
+
# # or you can manually do:
|
25
|
+
# # debtors = Debtor.search(params)
|
26
|
+
# # debtors_count = debtors.size
|
27
|
+
# # debtors_paginated = debtors.paginate(params)
|
28
|
+
# # render :json => { :results => @column_store.store_data_from(debtors_paginated), :count => debtors_count }
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# # app/views/backend/index.rjs
|
34
|
+
# page.grid do |grid|
|
35
|
+
# grid.id "debtors-grid" # If you don't set this columns are not saved in cookies
|
36
|
+
# grid.title "List al debtors"
|
37
|
+
# grid.base_path "/backend/debtors"
|
38
|
+
# grid.forgery_protection_token request_forgery_protection_token
|
39
|
+
# grid.authenticity_token form_authenticity_token
|
40
|
+
# grid.tbar :default
|
41
|
+
# grid.store do |store|
|
42
|
+
# store.url "/backend/debtors.json"
|
43
|
+
# store.fields @column_store.store_fields
|
44
|
+
# end
|
45
|
+
# grid.columns do |columns|
|
46
|
+
# columns.fields @column_store.column_fields
|
47
|
+
# end
|
48
|
+
# grid.bbar :store => grid.get_store, :pageSize => params[:limit] # Remember to add after defining store!
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
module Controller
|
52
|
+
def self.column_store_for(model, &block)
|
53
|
+
ColumnStore.new(model, &block)
|
54
|
+
end
|
55
|
+
|
56
|
+
class ColumnStore #:nodoc:
|
57
|
+
attr_reader :data
|
58
|
+
|
59
|
+
def initialize(model, &block) #:nodoc
|
60
|
+
@model = model
|
61
|
+
@data = []
|
62
|
+
yield self
|
63
|
+
end
|
64
|
+
|
65
|
+
# Method for add columns to the Column Model
|
66
|
+
def add(*args)
|
67
|
+
|
68
|
+
# First we need to check that our method is a symbol
|
69
|
+
raise Padrino::ExtJs::ConfigError, "First args must be a symbol like: :name or :account.name" unless args[0].is_a?(Symbol)
|
70
|
+
|
71
|
+
# Construct our options
|
72
|
+
options = { :method => args[0] }
|
73
|
+
|
74
|
+
# If we have a second args that is not an hash maybe an header
|
75
|
+
options[:header] = args[1].is_a?(String) || args[1].is_a?(Symbol) ? args[1].to_s : nil
|
76
|
+
|
77
|
+
args.each { |a| options.merge!(a) if a.is_a?(Hash) }
|
78
|
+
|
79
|
+
# Add some defaults
|
80
|
+
options[:header] ||= options[:method].to_s
|
81
|
+
options[:sortable] = options[:sortable].nil? ? true : options[:sortable]
|
82
|
+
|
83
|
+
# Try to translate header
|
84
|
+
options[:header] = @model.human_attribute_name(options[:header].to_s)
|
85
|
+
|
86
|
+
# Reformat DataIndex
|
87
|
+
#
|
88
|
+
# If we don't have nothing we use the method
|
89
|
+
options[:dataIndex] ||= options[:method]
|
90
|
+
|
91
|
+
data_indexes = Array(options[:dataIndex]).collect do |data_index|
|
92
|
+
case data_index
|
93
|
+
when String then data_index
|
94
|
+
when Symbol
|
95
|
+
if data_index.missing_methods.size == 1
|
96
|
+
options[:name] ||= "#{@model.table_name.singularize}[#{data_index}]"
|
97
|
+
data_index = "#{@model.table_name}.#{data_index}"
|
98
|
+
else
|
99
|
+
columns = data_index.missing_methods
|
100
|
+
options[:name] ||= columns.at(0) + "[" + columns[1..-1].collect(&:to_s).join("][") + "]"
|
101
|
+
data_index = columns[0..-2].collect { |c| c.to_s.pluralize }.join(".") + "." + columns.at(-1).to_s
|
102
|
+
end
|
103
|
+
end
|
104
|
+
data_index
|
105
|
+
end
|
106
|
+
|
107
|
+
options[:dataIndex] = data_indexes.compact.uniq.join(",")
|
108
|
+
|
109
|
+
# Reformat mapping like a div id
|
110
|
+
options[:mapping] ||= options[:name].sub(/\[/,"_").sub(/\]$/, "").sub(/\]\[/,"_")
|
111
|
+
|
112
|
+
# Now is necessary for our columns an ID
|
113
|
+
# TODO: check duplicates here
|
114
|
+
options[:id] = options[:mapping]
|
115
|
+
|
116
|
+
@data << options
|
117
|
+
end
|
118
|
+
|
119
|
+
# Return an array config for build an Ext.grid.ColumnModel() config
|
120
|
+
def column_fields
|
121
|
+
@data.map do |data|
|
122
|
+
data.delete(:method)
|
123
|
+
data.delete(:mapping)
|
124
|
+
data
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Return an array config for build an Ext.data.GroupingStore()
|
129
|
+
def store_fields
|
130
|
+
@data.map do |data|
|
131
|
+
hash = { :name => data[:dataIndex], :mapping => data[:mapping] }
|
132
|
+
hash.merge!(:type => data[:renderer]) if data[:renderer] && [:date, :datetime, :time_to_date].include?(data[:renderer])
|
133
|
+
hash
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Return data for a custom collection for the ExtJS Ext.data.GroupingStore() json
|
138
|
+
def store_data_from(collection)
|
139
|
+
collection.map do |c|
|
140
|
+
@data.dup.inject({ "id" => c.id }) do |options, data|
|
141
|
+
options[data[:mapping]] = (c.instance_eval(data[:method].to_s) rescue I18n.t("padrino.admin.labels.not_found"))
|
142
|
+
options
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Return a searched and paginated data collection for the ExtJS Ext.data.GroupingStore() json
|
148
|
+
# You can pass options like:
|
149
|
+
#
|
150
|
+
# Examples
|
151
|
+
#
|
152
|
+
# store_data(params, :conditions => "found = 1")
|
153
|
+
# store_data(params, :include => :posts)
|
154
|
+
#
|
155
|
+
def store_data(params={}, options={})
|
156
|
+
# Some can tell me that this method made two identical queries one for count one for paginate.
|
157
|
+
# We don't use the select count because in some circumstances require much time than select *.
|
158
|
+
collection = @model.all(options).ext_search(params)
|
159
|
+
collection_count = collection.length
|
160
|
+
collection_paginated = collection.ext_paginate(params)
|
161
|
+
{ :results => store_data_from(collection_paginated), :count => collection_count }
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class Symbol
|
2
|
+
|
3
|
+
def method_missing(method, *args)
|
4
|
+
super and return if method.to_s =~ /table_name/
|
5
|
+
(self.to_s + ".#{method}(#{args.collect(&:inspect).join(",")})").to_sym
|
6
|
+
end
|
7
|
+
|
8
|
+
def missing_methods
|
9
|
+
self.to_s.gsub(/(\(.*\))/,"").split(".")
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Padrino
|
2
|
+
module Admin
|
3
|
+
module Utils
|
4
|
+
# This util it's used for encrypt/decrypt password.
|
5
|
+
# We want password decryptable because generally for our sites we have: password_lost.
|
6
|
+
# We prefer send original password instead reset them.
|
7
|
+
module Crypt
|
8
|
+
# Decrypts the current string using the current key and algorithm specified
|
9
|
+
def decrypt(password)
|
10
|
+
cipher = build_cipher(:decrypt, password)
|
11
|
+
cipher.update(self.unpack('m')[0]) + cipher.final
|
12
|
+
end
|
13
|
+
|
14
|
+
# Encrypts the current string using the current key and algorithm specified
|
15
|
+
def encrypt(password)
|
16
|
+
cipher = build_cipher(:encrypt, password)
|
17
|
+
[cipher.update(self) + cipher.final].pack('m').chomp
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
def build_cipher(type, password) #:nodoc:
|
22
|
+
cipher = OpenSSL::Cipher::Cipher.new("DES-EDE3-CBC").send(type)
|
23
|
+
cipher.pkcs5_keyivgen(password)
|
24
|
+
cipher
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/padrino-admin.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{padrino-admin}
|
8
|
-
s.version = "0.
|
8
|
+
s.version = "0.4.5"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Padrino Team", "Nathan Esquenazi", "Davide D'Agostino", "Arthur Chiu"]
|
12
|
-
s.date = %q{
|
12
|
+
s.date = %q{2010-01-06}
|
13
13
|
s.description = %q{Admin View for Padrino applications}
|
14
14
|
s.email = %q{nesquena@gmail.com}
|
15
15
|
s.extra_rdoc_files = [
|
@@ -23,11 +23,29 @@ Gem::Specification.new do |s|
|
|
23
23
|
"Rakefile",
|
24
24
|
"VERSION",
|
25
25
|
"lib/padrino-admin.rb",
|
26
|
+
"lib/padrino-admin/access_control.rb",
|
27
|
+
"lib/padrino-admin/access_control/helpers.rb",
|
28
|
+
"lib/padrino-admin/adapters.rb",
|
29
|
+
"lib/padrino-admin/adapters/ar.rb",
|
30
|
+
"lib/padrino-admin/adapters/dm.rb",
|
31
|
+
"lib/padrino-admin/adapters/mm.rb",
|
26
32
|
"lib/padrino-admin/ext_js/config.rb",
|
33
|
+
"lib/padrino-admin/ext_js/controller.rb",
|
27
34
|
"lib/padrino-admin/generators/backend.rb",
|
35
|
+
"lib/padrino-admin/locale/en.yml",
|
36
|
+
"lib/padrino-admin/support.rb",
|
37
|
+
"lib/padrino-admin/utils/crypt.rb",
|
28
38
|
"padrino-admin.gemspec",
|
39
|
+
"test/fixtures/active_record.rb",
|
40
|
+
"test/fixtures/data_mapper.rb",
|
41
|
+
"test/fixtures/mongo_mapper.rb",
|
29
42
|
"test/helper.rb",
|
30
|
-
"test/
|
43
|
+
"test/test_access_control.rb",
|
44
|
+
"test/test_active_record.rb",
|
45
|
+
"test/test_admin_application.rb",
|
46
|
+
"test/test_controller.rb",
|
47
|
+
"test/test_data_mapper.rb",
|
48
|
+
"test/test_mongo_mapper.rb",
|
31
49
|
"test/test_parsing.rb"
|
32
50
|
]
|
33
51
|
s.homepage = %q{http://github.com/padrino/padrino-framework/tree/master/padrino-admin}
|
@@ -41,16 +59,18 @@ Gem::Specification.new do |s|
|
|
41
59
|
s.specification_version = 3
|
42
60
|
|
43
61
|
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
44
|
-
s.add_runtime_dependency(%q<
|
62
|
+
s.add_runtime_dependency(%q<json_pure>, [">= 1.2.0"])
|
45
63
|
s.add_runtime_dependency(%q<padrino-core>, [">= 0.1.1"])
|
64
|
+
s.add_development_dependency(%q<dm-core>, [">= 0.10.2"])
|
46
65
|
s.add_development_dependency(%q<haml>, [">= 2.2.1"])
|
47
66
|
s.add_development_dependency(%q<shoulda>, [">= 0"])
|
48
67
|
s.add_development_dependency(%q<mocha>, [">= 0.9.7"])
|
49
68
|
s.add_development_dependency(%q<rack-test>, [">= 0.5.0"])
|
50
69
|
s.add_development_dependency(%q<webrat>, [">= 0.5.1"])
|
51
70
|
else
|
52
|
-
s.add_dependency(%q<
|
71
|
+
s.add_dependency(%q<json_pure>, [">= 1.2.0"])
|
53
72
|
s.add_dependency(%q<padrino-core>, [">= 0.1.1"])
|
73
|
+
s.add_dependency(%q<dm-core>, [">= 0.10.2"])
|
54
74
|
s.add_dependency(%q<haml>, [">= 2.2.1"])
|
55
75
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
56
76
|
s.add_dependency(%q<mocha>, [">= 0.9.7"])
|
@@ -58,8 +78,9 @@ Gem::Specification.new do |s|
|
|
58
78
|
s.add_dependency(%q<webrat>, [">= 0.5.1"])
|
59
79
|
end
|
60
80
|
else
|
61
|
-
s.add_dependency(%q<
|
81
|
+
s.add_dependency(%q<json_pure>, [">= 1.2.0"])
|
62
82
|
s.add_dependency(%q<padrino-core>, [">= 0.1.1"])
|
83
|
+
s.add_dependency(%q<dm-core>, [">= 0.10.2"])
|
63
84
|
s.add_dependency(%q<haml>, [">= 2.2.1"])
|
64
85
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
65
86
|
s.add_dependency(%q<mocha>, [">= 0.9.7"])
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
|
4
|
+
|
5
|
+
ActiveRecord::Schema.define do
|
6
|
+
create_table :accounts, :force => true do |t|
|
7
|
+
t.column :id, :integer
|
8
|
+
t.column :name, :string
|
9
|
+
t.column :role, :string
|
10
|
+
t.column :crypted_password, :string
|
11
|
+
t.column :salt, :string
|
12
|
+
t.column :email, :string
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Account < ActiveRecord::Base; end
|
17
|
+
Padrino::Admin::Adapters.register(:active_record)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'dm-core'
|
2
|
+
require 'dm-validations'
|
3
|
+
|
4
|
+
DataMapper.setup(:default, 'sqlite3::memory:')
|
5
|
+
|
6
|
+
# Fake Category Model
|
7
|
+
class Category
|
8
|
+
include DataMapper::Resource
|
9
|
+
property :id, Serial
|
10
|
+
property :name, String
|
11
|
+
belongs_to :account
|
12
|
+
end
|
13
|
+
|
14
|
+
# Fake Account Model
|
15
|
+
class Account
|
16
|
+
include DataMapper::Resource
|
17
|
+
property :id, Serial
|
18
|
+
property :name, String
|
19
|
+
has n, :categories
|
20
|
+
def self.admin; first(:role => "Admin"); end
|
21
|
+
def self.editor; first(:role => "Editor"); end
|
22
|
+
end
|
23
|
+
|
24
|
+
Padrino::Admin::Adapters.register(:data_mapper)
|
25
|
+
DataMapper.auto_migrate!
|
26
|
+
|
27
|
+
# We build some fake accounts
|
28
|
+
admin = Account.create(:name => "DAddYE", :role => "Admin", :email => "d.dagostino@lipsiasoft.com",
|
29
|
+
:password => "some", :password_confirmation => "some")
|
30
|
+
editor = Account.create(:name => "Dexter", :role => "Editor", :email => "editor@lipsiasoft.com",
|
31
|
+
:password => "some", :password_confirmation => "some")
|
32
|
+
|
33
|
+
%w(News Press HowTo).each do |c|
|
34
|
+
admin.categories.create(:name => c)
|
35
|
+
editor.categories.create(:name => c)
|
36
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'mongo_mapper'
|
2
|
+
|
3
|
+
MongoMapper.connection = Mongo::Connection.new("127.0.0.1")
|
4
|
+
MongoMapper.database = 'test'
|
5
|
+
|
6
|
+
class Account
|
7
|
+
include MongoMapper::Document
|
8
|
+
key :name, String
|
9
|
+
end
|
10
|
+
|
11
|
+
Account.collection.remove
|
12
|
+
Padrino::Admin::Adapters.register(:mongo_mapper)
|
data/test/helper.rb
CHANGED
@@ -1,58 +1,51 @@
|
|
1
|
+
ENV['PADRINO_ENV'] = 'test'
|
2
|
+
PADRINO_ROOT = File.dirname(__FILE__) unless defined? PADRINO_ROOT
|
3
|
+
|
1
4
|
require 'rubygems'
|
2
5
|
require 'test/unit'
|
3
|
-
require 'shoulda'
|
4
|
-
require 'mocha'
|
5
6
|
require 'rack/test'
|
6
|
-
require '
|
7
|
-
|
8
|
-
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
9
|
-
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
10
|
-
|
11
|
-
require 'padrino-gen'
|
7
|
+
require 'rack'
|
8
|
+
require 'shoulda'
|
12
9
|
require 'padrino-admin'
|
13
10
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
11
|
+
module Kernel
|
12
|
+
# Silences the output by redirecting to stringIO
|
13
|
+
# silence_logger { ...commands... } => "...output..."
|
14
|
+
def silence_logger(&block)
|
15
|
+
$stdout = $stderr = log_buffer = StringIO.new
|
16
|
+
block.call
|
17
|
+
$stdout = STDOUT
|
18
|
+
$stderr = STDERR
|
19
|
+
log_buffer.string
|
21
20
|
end
|
21
|
+
alias :silence_stdout :silence_logger
|
22
22
|
|
23
|
-
def
|
24
|
-
|
25
|
-
|
26
|
-
|
23
|
+
def load_fixture(file)
|
24
|
+
Object.send(:remove_const, :Account) if defined?(Account)
|
25
|
+
file += ".rb" if file !~ /.rb$/
|
26
|
+
load File.join(File.dirname(__FILE__), "fixtures", file)
|
27
|
+
# silence_stdout { }
|
27
28
|
end
|
29
|
+
end
|
28
30
|
|
29
|
-
|
30
|
-
#
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
end
|
31
|
+
class Class
|
32
|
+
# Allow assertions in request context
|
33
|
+
include Test::Unit::Assertions
|
34
|
+
end
|
35
|
+
|
36
|
+
class Test::Unit::TestCase
|
37
|
+
include Rack::Test::Methods
|
37
38
|
|
38
|
-
#
|
39
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
raise "Please specify a block!" if html.blank?
|
45
|
-
assert matcher.matches?(html), matcher.failure_message
|
39
|
+
# Sets up a Sinatra::Base subclass defined with the block
|
40
|
+
# given. Used in setup or individual spec methods to establish
|
41
|
+
# the application.
|
42
|
+
def mock_app(base=Padrino::Application, &block)
|
43
|
+
base.use Rack::Session::Cookie # Need this because Sinatra 0.9.4 have use Rack::Session::Cookie if sessions? && !test?
|
44
|
+
@app = Sinatra.new(base, &block)
|
46
45
|
end
|
47
46
|
|
48
|
-
|
49
|
-
|
50
|
-
def silence_logger(&block)
|
51
|
-
orig_stdout = $stdout
|
52
|
-
$stdout = log_buffer = StringIO.new
|
53
|
-
block.call
|
54
|
-
$stdout = orig_stdout
|
55
|
-
log_buffer.rewind && log_buffer.read
|
47
|
+
def app
|
48
|
+
Rack::Lint.new(@app)
|
56
49
|
end
|
57
50
|
|
58
51
|
# Asserts that a file matches the pattern
|
@@ -60,12 +53,15 @@ class Test::Unit::TestCase
|
|
60
53
|
assert File.exist?(file), "File '#{file}' does not exist!"
|
61
54
|
assert_match pattern, File.read(file)
|
62
55
|
end
|
63
|
-
end
|
64
56
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
57
|
+
# Delegate other missing methods to response.
|
58
|
+
def method_missing(name, *args, &block)
|
59
|
+
if response && response.respond_to?(name)
|
60
|
+
response.send(name, *args, &block)
|
61
|
+
else
|
62
|
+
super
|
69
63
|
end
|
70
64
|
end
|
65
|
+
|
66
|
+
alias :response :last_response
|
71
67
|
end
|