eaternet 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|