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.
@@ -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