eaternet 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +25 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +202 -0
- data/README.md +78 -0
- data/Rakefile +10 -0
- data/eaternet.gemspec +32 -0
- data/lib/eaternet.rb +9 -0
- data/lib/eaternet/agencies/nyc.rb +184 -0
- data/lib/eaternet/agencies/snhd.rb +118 -0
- data/lib/eaternet/agencies/snhd_config.rb +61 -0
- data/lib/eaternet/framework/lives_1_0.rb +232 -0
- data/lib/eaternet/framework/prototype.rb +109 -0
- data/lib/eaternet/util.rb +38 -0
- data/lib/eaternet/version.rb +3 -0
- data/lib/ext/lazy.rb +13 -0
- data/test/fixtures/morris-park-bake-shop.csv +23 -0
- data/test/lives_1_0/business_test.rb +84 -0
- data/test/lives_1_0/feed_info_test.rb +70 -0
- data/test/lives_1_0/inspection_test.rb +100 -0
- data/test/lives_1_0/legend_test.rb +152 -0
- data/test/nyc_adapter_test.rb +96 -0
- data/test/snhd_adapter_test.rb +99 -0
- data/test/test_helper.rb +14 -0
- metadata +203 -0
@@ -0,0 +1,118 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'eaternet/framework/prototype'
|
3
|
+
require 'eaternet/agencies/snhd_config'
|
4
|
+
require 'eaternet/util'
|
5
|
+
|
6
|
+
include Eaternet
|
7
|
+
include Eaternet::Framework::Prototype
|
8
|
+
|
9
|
+
module Eaternet
|
10
|
+
module Agencies
|
11
|
+
# An adapter for the Southern Nevada Health District (Las Vegas).
|
12
|
+
#
|
13
|
+
# @example Print the names of all the Las Vegas businesses
|
14
|
+
# require 'eaternet'
|
15
|
+
#
|
16
|
+
# agency = Snhd.new
|
17
|
+
# agency.businesses
|
18
|
+
# .select { |biz| biz.city == 'Las Vegas' }
|
19
|
+
# .each { |biz| puts biz.name }
|
20
|
+
#
|
21
|
+
# The SNHD makes their restaurant inspection data available via a zip file for download.
|
22
|
+
# The file contains several Mysql tables converted to CSV. And so, this is an example
|
23
|
+
# for how to code adapters for other agencies which publish their data in a similar way.
|
24
|
+
#
|
25
|
+
# This code downloads the latest zip file, extracts it, parses the csv, and provides
|
26
|
+
# enumerators of Businesses, Inspections, Violations, and ViolationKinds. (These are
|
27
|
+
# specified in the Eaternet::Framework::Framework module.)
|
28
|
+
#
|
29
|
+
# SNHD's CSV format seems to be non-standard, e.g. using the double-quote character
|
30
|
+
# in fields without escaping it. We need to find out what their quote character actually is.
|
31
|
+
# In the meantime, the files can be parsed by setting the quote character to something else
|
32
|
+
# that doesn't appear in the text, such as a pipe symbol.
|
33
|
+
#
|
34
|
+
# @see http://southernnevadahealthdistrict.org/restaurants/inspect-downloads.php
|
35
|
+
# The SNHD Developer information page
|
36
|
+
# @see Eaternet::Framework::Prototype Framework module
|
37
|
+
class Snhd
|
38
|
+
include Prototype::AbstractAdapter
|
39
|
+
|
40
|
+
# (see Eaternet::Framework::Framework::AbstractAdapter#businesses)
|
41
|
+
def businesses
|
42
|
+
lazy_csv_map('restaurant_establishments.csv') do |row|
|
43
|
+
BusinessData.new(
|
44
|
+
orig_key: row['permit_number'],
|
45
|
+
name: row['restaurant_name'],
|
46
|
+
address: row['address'],
|
47
|
+
city: row['city_name'],
|
48
|
+
zipcode: row['zip_code']
|
49
|
+
)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# (see Eaternet::Framework::Framework::AbstractAdapter#inspections)
|
54
|
+
def inspections
|
55
|
+
lazy_csv_map('restaurant_inspections.csv') do |row|
|
56
|
+
InspectionData.new(
|
57
|
+
orig_key: row['serial_number'],
|
58
|
+
business_orig_key: row['permit_number'],
|
59
|
+
score: row['inspection_grade'],
|
60
|
+
date: row['inspection_date']
|
61
|
+
)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# (see Eaternet::Framework::Framework::AbstractAdapter#violations)
|
66
|
+
def violations
|
67
|
+
lazy_csv_map('restaurant_inspection_violations.csv') do |row|
|
68
|
+
ViolationData.new(
|
69
|
+
orig_key: row['inspection_violation_id'],
|
70
|
+
inspection_id: row['inspection_id'],
|
71
|
+
violation_kind_id: row['inspection_violation']
|
72
|
+
)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# @return [Enumerable<ViolationKindData>]
|
77
|
+
# @todo Add to AbstractAdapter
|
78
|
+
def violation_kinds
|
79
|
+
lazy_csv_map('restaurant_violations.csv') do |row|
|
80
|
+
ViolationKindData.new(
|
81
|
+
orig_key: row['violation_id'],
|
82
|
+
code: row['violation_code'],
|
83
|
+
demerits: row['violation_demerits'],
|
84
|
+
description: row['violation_description']
|
85
|
+
)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def lazy_csv_map(filename, &block)
|
92
|
+
csv_rows(filename).lazy.map { |row| block.call(row) }
|
93
|
+
end
|
94
|
+
|
95
|
+
def csv_rows(filename)
|
96
|
+
csv_reader(
|
97
|
+
path: File.join(zip_dir, filename),
|
98
|
+
headers: SnhdConfig::CSV_SCHEMA[filename]
|
99
|
+
)
|
100
|
+
end
|
101
|
+
|
102
|
+
def csv_reader(path:, headers:)
|
103
|
+
file = open(path)
|
104
|
+
file.readline # Skip the non-standard header line
|
105
|
+
CSV.new(
|
106
|
+
file,
|
107
|
+
headers: headers,
|
108
|
+
col_sep: SnhdConfig::COLUMN_SEPARATOR,
|
109
|
+
quote_char: SnhdConfig::QUOTE_CHARACTER
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
def zip_dir
|
114
|
+
@zip_dir ||= Util.download_and_extract_zipfile(SnhdConfig::DOWNLOAD_URL)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Eaternet
|
2
|
+
module Agencies
|
3
|
+
class SnhdConfig
|
4
|
+
DOWNLOAD_URL = 'http://southernnevadahealthdistrict.org/restaurants/download/restaurants.zip'
|
5
|
+
COLUMN_SEPARATOR = ';'
|
6
|
+
QUOTE_CHARACTER = '|' # TODO: Verify this with SNHD.
|
7
|
+
|
8
|
+
# The SNHD CSV does not have a true header row. And so, this data
|
9
|
+
# structure provides the column names.
|
10
|
+
CSV_SCHEMA = {
|
11
|
+
'restaurant_inspections.csv' => %w(
|
12
|
+
serial_number
|
13
|
+
permit_number
|
14
|
+
inspection_date
|
15
|
+
inspection_time
|
16
|
+
employee_id
|
17
|
+
inspection_type_id
|
18
|
+
inspection_demerits
|
19
|
+
inspection_grade
|
20
|
+
permit_status
|
21
|
+
inspection_result
|
22
|
+
violations
|
23
|
+
record_updated
|
24
|
+
),
|
25
|
+
'restaurant_establishments.csv' => %w(
|
26
|
+
permit_number
|
27
|
+
facility_id
|
28
|
+
PE
|
29
|
+
restaurant_name
|
30
|
+
location_name
|
31
|
+
address
|
32
|
+
latitude
|
33
|
+
longitude
|
34
|
+
city_id
|
35
|
+
city_name
|
36
|
+
zip_code
|
37
|
+
nciaa
|
38
|
+
plan_review
|
39
|
+
record_status
|
40
|
+
current_grade
|
41
|
+
current_demerits
|
42
|
+
date_current
|
43
|
+
previous_grade
|
44
|
+
date_previous
|
45
|
+
),
|
46
|
+
'restaurant_inspection_violations.csv' => %w(
|
47
|
+
inspection_violation_id
|
48
|
+
inspection_id
|
49
|
+
inspection_violation
|
50
|
+
),
|
51
|
+
'restaurant_violations.csv' => %w(
|
52
|
+
violation_id
|
53
|
+
violation_code
|
54
|
+
violation_sort
|
55
|
+
violation_demerits
|
56
|
+
violation_description
|
57
|
+
)
|
58
|
+
}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,232 @@
|
|
1
|
+
module Eaternet
|
2
|
+
module Framework
|
3
|
+
|
4
|
+
# Framework for creating LIVES 1.0 apps.
|
5
|
+
#
|
6
|
+
# The goal is to make it as easy as possible to create
|
7
|
+
# compliant implementations. And so, the Business, Inspection
|
8
|
+
# and Violation data objects use validations to ensure that they
|
9
|
+
# correctly produce the entities listed in the spec.
|
10
|
+
#
|
11
|
+
# @see http://www.yelp.com/healthscores
|
12
|
+
# @see http://yehudakatz.com/2010/01/10/activemodel-make-any-ruby-object-feel-like-activerecord/
|
13
|
+
# @see http://www.rubyinside.com/rails-3-0s-activemodel-how-to-give-ruby-classes-some-activerecord-magic-2937.html
|
14
|
+
module Lives_1_0
|
15
|
+
require 'active_model'
|
16
|
+
|
17
|
+
|
18
|
+
class Adapter
|
19
|
+
# Required.
|
20
|
+
# @return [Enumerable<Business>]
|
21
|
+
def businesses
|
22
|
+
fail 'Override this to return an Enumerable of Business'
|
23
|
+
end
|
24
|
+
|
25
|
+
# Required.
|
26
|
+
# @return [Enumerable<Inspection>]
|
27
|
+
def inspections
|
28
|
+
fail 'Override this to return an Enumerable of Inspection'
|
29
|
+
end
|
30
|
+
|
31
|
+
# Optional.
|
32
|
+
# @return [Enumerable<Violation>]
|
33
|
+
def violations
|
34
|
+
fail 'Optionally override this to return an Enumerable of Violation'
|
35
|
+
end
|
36
|
+
|
37
|
+
# Optional.
|
38
|
+
# @return [FeedInfo]
|
39
|
+
def feed_info
|
40
|
+
fail 'Optionally override this to return a FeedInfo'
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
class DataTransferObject
|
46
|
+
include ActiveModel::Validations
|
47
|
+
|
48
|
+
def initialize(&block)
|
49
|
+
block.call(self)
|
50
|
+
check_validations!
|
51
|
+
end
|
52
|
+
|
53
|
+
def check_validations!
|
54
|
+
fail ArgumentError, errors.messages.inspect if invalid?
|
55
|
+
end
|
56
|
+
|
57
|
+
class TypeValidator < ActiveModel::EachValidator
|
58
|
+
def validate_each(record, attribute, value)
|
59
|
+
record.errors.add attribute, (options[:message] || "is not of class #{options[:with]}") unless
|
60
|
+
value.class == options[:with]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
# @see http://www.yelp.com/healthscores#businesses
|
67
|
+
class Business < DataTransferObject
|
68
|
+
attr_accessor :business_id, :name, :address, :city, :state,
|
69
|
+
:postal_code, :latitude, :longitude, :phone_number
|
70
|
+
|
71
|
+
validates :business_id, :name, :address,
|
72
|
+
type: String,
|
73
|
+
presence: true
|
74
|
+
validates :city, :state, :postal_code, :phone_number,
|
75
|
+
type: String,
|
76
|
+
allow_nil: true
|
77
|
+
validates :latitude,
|
78
|
+
numericality:
|
79
|
+
{
|
80
|
+
greater_than_or_equal_to: -90,
|
81
|
+
less_than_or_equal_to: 90
|
82
|
+
},
|
83
|
+
allow_nil: true
|
84
|
+
validates :longitude,
|
85
|
+
numericality:
|
86
|
+
{
|
87
|
+
greater_than_or_equal_to: -180,
|
88
|
+
less_than_or_equal_to: 180
|
89
|
+
},
|
90
|
+
allow_nil: true
|
91
|
+
|
92
|
+
def ==(other)
|
93
|
+
@business_id == other.business_id
|
94
|
+
end
|
95
|
+
|
96
|
+
def eql?(other)
|
97
|
+
self == other
|
98
|
+
end
|
99
|
+
|
100
|
+
def hash
|
101
|
+
@business_id.hash
|
102
|
+
end
|
103
|
+
|
104
|
+
# @return [String]
|
105
|
+
def to_s
|
106
|
+
"Business #{@business_id}"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
# @see http://www.yelp.com/healthscores#inspections
|
112
|
+
class Inspection < DataTransferObject
|
113
|
+
attr_accessor :business_id, :score, :date, :description, :type
|
114
|
+
|
115
|
+
ZERO_TO_ONE_HUNDRED_AND_BLANK = (0..100).to_a + ['']
|
116
|
+
|
117
|
+
validates :business_id,
|
118
|
+
type: String,
|
119
|
+
presence: true
|
120
|
+
validates :score,
|
121
|
+
inclusion: { in: ZERO_TO_ONE_HUNDRED_AND_BLANK },
|
122
|
+
allow_nil: true
|
123
|
+
validates :date,
|
124
|
+
type: Date,
|
125
|
+
presence: true
|
126
|
+
validates :type,
|
127
|
+
inclusion: { in: %w(initial routine followup complaint) },
|
128
|
+
allow_nil: true
|
129
|
+
|
130
|
+
def score
|
131
|
+
@score.nil? ? '' : @score
|
132
|
+
end
|
133
|
+
|
134
|
+
def to_s
|
135
|
+
"Inspection #{@business_id}/#{@date}/#{@score}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
|
140
|
+
# @see http://www.yelp.com/healthscores#violations
|
141
|
+
class Violation < DataTransferObject
|
142
|
+
attr_accessor :business_id, :date, :code, :description
|
143
|
+
|
144
|
+
validates :business_id,
|
145
|
+
type: String,
|
146
|
+
presence: true
|
147
|
+
validates :date,
|
148
|
+
type: Date,
|
149
|
+
presence: true
|
150
|
+
validates :code, :description,
|
151
|
+
type: String,
|
152
|
+
allow_nil: true
|
153
|
+
end
|
154
|
+
|
155
|
+
|
156
|
+
# @see http://www.yelp.com/healthscores#feed_info
|
157
|
+
class FeedInfo < DataTransferObject
|
158
|
+
attr_accessor :feed_date, :feed_version, :municipality_name,
|
159
|
+
:municipality_url, :contact_email
|
160
|
+
|
161
|
+
HAS_AN_AT_SOMEWHERE_IN_THE_MIDDLE = /\A[^@]+@[^@]+\z/
|
162
|
+
|
163
|
+
validates :feed_date,
|
164
|
+
type: Date,
|
165
|
+
presence: true
|
166
|
+
validates :feed_version,
|
167
|
+
type: String,
|
168
|
+
presence: true
|
169
|
+
validates :municipality_name,
|
170
|
+
type: String,
|
171
|
+
presence: true
|
172
|
+
validates :municipality_url,
|
173
|
+
type: String,
|
174
|
+
format: { with: %r(\Ahttps?:/) },
|
175
|
+
allow_nil: true
|
176
|
+
validates :contact_email,
|
177
|
+
type: String,
|
178
|
+
format: { with: HAS_AN_AT_SOMEWHERE_IN_THE_MIDDLE },
|
179
|
+
allow_nil: true
|
180
|
+
end
|
181
|
+
|
182
|
+
|
183
|
+
# @see http://www.yelp.com/healthscores#legend
|
184
|
+
class Legend < DataTransferObject
|
185
|
+
attr_accessor :minimum_score, :maximum_score, :description
|
186
|
+
|
187
|
+
validates :minimum_score, :maximum_score,
|
188
|
+
inclusion: { in: (0..100) }
|
189
|
+
validates :description,
|
190
|
+
presence: true,
|
191
|
+
type: String
|
192
|
+
end
|
193
|
+
|
194
|
+
|
195
|
+
# A container for all the Legends in the data set. Performs
|
196
|
+
# validation on the whole set of Legends ensure they cover
|
197
|
+
# the entire range of scores and do not overlap.
|
198
|
+
class LegendGroup < DataTransferObject
|
199
|
+
attr_accessor :legends
|
200
|
+
|
201
|
+
# Check that all the items in this LegendGroup:
|
202
|
+
#
|
203
|
+
# 1. Are of the class, Legend
|
204
|
+
# 2. Cover the range of scores from 0-100 without overlap
|
205
|
+
class ComprehensiveValidator < ActiveModel::EachValidator
|
206
|
+
def validate_each(record, attribute, legends)
|
207
|
+
scores = (0..100).to_a
|
208
|
+
legends.each do |legend|
|
209
|
+
unless legend.class == Legend
|
210
|
+
record.errors.add attribute, 'must be a Legend'
|
211
|
+
return
|
212
|
+
end
|
213
|
+
range = (legend.minimum_score..legend.maximum_score)
|
214
|
+
range.each do |score|
|
215
|
+
if scores.delete(score).nil?
|
216
|
+
unless score == legend.minimum_score || score == legend.maximum_score
|
217
|
+
record.errors.add attribute, 'may not overlap'
|
218
|
+
return
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
record.errors.add attribute, 'do not cover entire span from 0–100' unless scores.empty?
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
validates :legends, comprehensive: true
|
228
|
+
end
|
229
|
+
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module Eaternet
|
2
|
+
module Framework
|
3
|
+
# A first pass at a health scores mini-framework which
|
4
|
+
# is denormalized and provides some more information than
|
5
|
+
# LIVES.
|
6
|
+
module Prototype
|
7
|
+
|
8
|
+
module AbstractAdapter
|
9
|
+
# @return [Enumerable<BusinessData>]
|
10
|
+
def businesses
|
11
|
+
fail 'Override this to return an Enumerable of BusinessData objects'
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [Enumerable<InspectionData>]
|
15
|
+
def inspections
|
16
|
+
fail 'Override this to return an Enumerable of InspectionData objects'
|
17
|
+
end
|
18
|
+
|
19
|
+
# @return [Enumerable<ViolationData>]
|
20
|
+
def violations
|
21
|
+
fail 'Override this to return an Enumerable of ViolationData objects'
|
22
|
+
end
|
23
|
+
|
24
|
+
# @return [Enumerable<ViolationData>]
|
25
|
+
def violation_kinds
|
26
|
+
fail 'Override this to return an Enumerable of ViolationKindData objects'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class BusinessData
|
31
|
+
# @return [String]
|
32
|
+
attr_reader :name, :address, :city, :zipcode, :orig_key
|
33
|
+
|
34
|
+
def initialize(name:, address:, city:, zipcode:, orig_key:)
|
35
|
+
@name = name
|
36
|
+
@address = address
|
37
|
+
@city = city
|
38
|
+
@zipcode = zipcode
|
39
|
+
@orig_key = orig_key
|
40
|
+
end
|
41
|
+
|
42
|
+
def ==(other)
|
43
|
+
@orig_key == other.orig_key
|
44
|
+
end
|
45
|
+
|
46
|
+
def eql?(other)
|
47
|
+
self == other
|
48
|
+
end
|
49
|
+
|
50
|
+
def hash
|
51
|
+
@orig_key.hash
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [String]
|
55
|
+
def to_s
|
56
|
+
"Business #{@orig_key}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class InspectionData
|
61
|
+
# @return [String]
|
62
|
+
attr_reader :orig_key, :business_orig_key, :score, :date
|
63
|
+
|
64
|
+
def initialize(orig_key:, business_orig_key:, score:, date:)
|
65
|
+
@orig_key = orig_key
|
66
|
+
@business_orig_key = business_orig_key
|
67
|
+
@score = score
|
68
|
+
@date = date
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_s
|
72
|
+
"Inspection #{@orig_key}"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class ViolationData
|
77
|
+
# @return [String]
|
78
|
+
attr_reader :orig_key, :inspection_id, :violation_kind_id
|
79
|
+
|
80
|
+
def initialize(orig_key:, inspection_id:, violation_kind_id:)
|
81
|
+
@orig_key = orig_key
|
82
|
+
@inspection_id = inspection_id
|
83
|
+
@violation_kind_id = violation_kind_id
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_s
|
87
|
+
"Violation #{@orig_key}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class ViolationKindData
|
92
|
+
# @return [String]
|
93
|
+
attr_reader :orig_key, :code, :demerits, :description
|
94
|
+
|
95
|
+
def initialize(orig_key:, code:, demerits:, description:)
|
96
|
+
@orig_key = orig_key
|
97
|
+
@code = code
|
98
|
+
@demerits = demerits
|
99
|
+
@description = description
|
100
|
+
end
|
101
|
+
|
102
|
+
def to_s
|
103
|
+
"ViolationKind #{@orig_key}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|