eaternet 0.2.0

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