eaternet 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 90ec97b301bb75adbb97b5a7fb23cf99c03ac340
4
- data.tar.gz: 6e2eb97e0e443a47719d0f3f1a761ce6e680ab92
3
+ metadata.gz: 22ee77b16cad4131bcf6dfa1c970507b39569ba6
4
+ data.tar.gz: 8649494e45fedc9defa460d1423c2b1c83eee345
5
5
  SHA512:
6
- metadata.gz: 0937aa8f4e654f9bec8439ea39512cccf98a18067027a8fb0bfec7f21aff096b451e50c0ac33ddb01408050e39eda1e9d1a045c95b2ea2428179910ae5278b09
7
- data.tar.gz: 5fe045bf8704dc53721a94d66bed6e9c4ca638afa0d8c09f324aba00d46855771b9b9662d1d1ad650a5d6ca60185b3b451509bd55b39333a35a6cb5d3d9d3563
6
+ metadata.gz: d64e281aab4dada323e6fd72b5facf8b4287c6318b5132fa231b44e71ca0281e5c10c52a221577a35386d2bfc5d5dbcdd294a88c00d07da19fe8006f54d3247d
7
+ data.tar.gz: 0d1ed8e4684cb36c2af320556e5efca4805fc3647216c34c83770c9c086972c89b4404a23dbe457120ead76d0048e5eea96e439d81e99bba00060300bea9260d
data/.gitignore CHANGED
@@ -22,4 +22,3 @@ mkmf.log
22
22
  *.komodoproject
23
23
  /.idea
24
24
  *.sublime*
25
- >>>>>>> master
data/.inch.yml ADDED
@@ -0,0 +1,5 @@
1
+ files:
2
+ excluded:
3
+ - lib/eaternet/prototype.rb
4
+
5
+
data/Guardfile ADDED
@@ -0,0 +1,36 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec features)
6
+
7
+ ## Uncomment to clear the screen before every task
8
+ # clearing :on
9
+
10
+ ## Guard internally checks for changes in the Guardfile and exits.
11
+ ## If you want Guard to automatically start up again, run guard in a
12
+ ## shell loop, e.g.:
13
+ ##
14
+ ## $ while bundle exec guard; do echo "Restarting Guard..."; done
15
+ ##
16
+ ## Note: if you are using the `directories` clause above and you are not
17
+ ## watching the project directory ('.'), then you will want to move
18
+ ## the Guardfile to a watched dir and symlink it back, e.g.
19
+ #
20
+ # $ mkdir config
21
+ # $ mv Guardfile config/
22
+ # $ ln -s config/Guardfile .
23
+ #
24
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
25
+
26
+ guard :minitest do
27
+ # with Minitest::Unit
28
+ watch(%r{^test/(.*)\/?test_(.*)\.rb$})
29
+ watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" }
30
+ watch(%r{^test/test_helper\.rb$}) { 'test' }
31
+
32
+ # with Minitest::Spec
33
+ # watch(%r{^spec/(.*)_spec\.rb$})
34
+ # watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
35
+ # watch(%r{^spec/spec_helper\.rb$}) { 'spec' }
36
+ end
data/README.md CHANGED
@@ -1,18 +1,21 @@
1
1
  [![Build Status](https://travis-ci.org/eaternet/adapters-ruby.svg?branch=master)](https://travis-ci.org/eaternet/adapters-ruby)
2
2
  [![Code Climate](https://codeclimate.com/github/eaternet/adapters-ruby/badges/gpa.svg)](https://codeclimate.com/github/eaternet/adapters-ruby)
3
3
  [![Gem Version](https://badge.fury.io/rb/eaternet.svg)](http://badge.fury.io/rb/eaternet)
4
+ [![Inline docs](http://inch-ci.org/github/eaternet/adapters-ruby.svg?branch=master)](http://inch-ci.org/github/eaternet/adapters-ruby)
4
5
 
5
6
  # Eaternet Health Score Adapters
6
7
 
7
- **We're bringing the world's restaurant health inspections online,**
8
- into as many sites and apps as possible.
8
+ **We're bringing the world's restaurant health inspections online,** into as
9
+ many sites and apps as possible.
9
10
 
10
11
  Use this repo to publish your local restaurant health scores.
11
12
 
12
- **In a nutshell:** each agency which inspects restaurants can have an "adapter" which is a Ruby class.
13
- Its adapter converts its source format into a simple, standard one. Anybody can then use these adapters to get the
14
- data and do analysis and work with it how they like. [Eaternet](http://eaternet.io/), in particular, will use the production-ready
15
- adapters to pull and publish the information daily on our website and in feeds to partner organizations.
13
+ **In a nutshell:** each agency which inspects restaurants can have an
14
+ "adapter" which is a Ruby class. Its adapter converts its source format into a
15
+ simple, standard one. Anybody can then use these adapters to get the data and
16
+ do analysis and work with it how they like. [Eaternet](http://eaternet.io/),
17
+ in particular, will use the production-ready adapters to pull and publish the
18
+ information daily on our website and in feeds to partner organizations.
16
19
 
17
20
 
18
21
  ## Available Adapters
@@ -26,13 +29,13 @@ adapters to pull and publish the information daily on our website and in feeds t
26
29
 
27
30
  ## Usage
28
31
 
29
- Example: print all the restaurant names in the Southern Nevada Health District
32
+ Example: print all the restaurant names in New York City.
30
33
 
31
34
  ```ruby
32
35
  require 'eaternet'
33
36
 
34
- agency = Snhd.new
35
- agency.businesses.each { |biz| puts biz.name }
37
+ nyc = Eaternet::Nyc.new
38
+ nyc.businesses.each { |biz| puts biz.name }
36
39
  ```
37
40
 
38
41
  Each adapter provides [a small set of enumerators](https://github.com/eaternet/adapters-ruby/blob/master/lib/eaternet/adapters/framework.rb#L27-L39) such as `businesses` and `inspections`. See the [Las Vegas tests](https://github.com/eaternet/adapters-ruby/blob/master/test/snhd_adapter_test.rb) to learn how an application would use this gem. The framework also includes [standardized data transfer objects](https://github.com/eaternet/adapters-ruby/blob/master/lib/eaternet/adapters/framework.rb#L42-L103) which these enumerators return.
@@ -57,22 +60,19 @@ Or install it yourself as:
57
60
 
58
61
  ## Roadmap
59
62
 
60
- See the [eaternet/adapters](https://github.com/eaternet/adapters#roadmap) repo for the project roadmap.
63
+ See the [eaternet/adapters](https://github.com/eaternet/adapters#roadmap) repo
64
+ for the project roadmap.
61
65
 
62
66
  ## Contributing
63
67
 
64
- Write an adapter for your city's restaurant health scores. Or do a small refactor or add
65
- documentation. We're happy to get the help.
68
+ Write an adapter for your city's restaurant health scores. Or do a small
69
+ refactor or add documentation. We're happy to get the help.
66
70
 
67
- To add your local health scores, you just need to create a class which `include`'s `AbstractAdapter`.
68
- We like good tests, as well. Submit a pull requests to start a conversation, and we'll work with you.
71
+ To add your local health scores, you just need to create a class which
72
+ `include`'s `AbstractAdapter`. We like good tests, as well. Submit a pull
73
+ requests to start a conversation, and we'll work with you.
69
74
 
70
- Once a new adapter is production-ready, we'll plug it into the Eaternet system, and your adapter will be
71
- put into daily production. We're working with several organizations to publish the information. Yelp was
72
- the first to work with us, and we have new partnerships in the works which we'll announce soon.
73
-
74
- 1. Fork it ( https://github.com/eaternet/adapters/fork )
75
- 2. Create your feature branch (`git checkout -b my-new-feature`)
76
- 3. Commit your changes (`git commit -am 'Add some feature'`)
77
- 4. Push to the branch (`git push origin my-new-feature`)
78
- 5. Create a new Pull Request
75
+ Once a new adapter is production-ready, we'll plug it into the Eaternet
76
+ system, and your adapter will be put into daily production. We're working with
77
+ several organizations to publish the information. Yelp was the first to work
78
+ with us, and we have new partnerships in the works which we'll announce soon.
data/Rakefile CHANGED
@@ -7,4 +7,4 @@ Rake::TestTask.new do |t|
7
7
  t.verbose = false
8
8
  end
9
9
 
10
- task :default => :test
10
+ task default: :test
data/eaternet.gemspec CHANGED
@@ -4,26 +4,28 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  require 'eaternet/version'
5
5
 
6
6
  Gem::Specification.new do |spec|
7
- spec.name = "eaternet"
7
+ spec.name = 'eaternet'
8
8
  spec.version = Eaternet::VERSION
9
- spec.authors = ["Robb Shecter"]
10
- spec.email = ["robb@eaternet.io"]
11
- spec.summary = %q{Regional adapters for restaurant health scores}
12
- spec.description = %q{A framework for easily retrieving a region's health scores in a standardized way.}
13
- spec.homepage = "https://github.com/eaternet/adapters-ruby"
14
- spec.license = "Apache"
9
+ spec.authors = ['Robb Shecter']
10
+ spec.email = ['robb@eaternet.io']
11
+ spec.summary = 'Regional adapters for restaurant health scores'
12
+ spec.description = "A framework for easily retrieving a region's health scores in a standardized way."
13
+ spec.homepage = 'https://github.com/eaternet/adapters-ruby'
14
+ spec.license = 'Apache'
15
15
 
16
- spec.files = `git ls-files -z`.split("\x0").reject{ |f| f =~ /vcr_cassettes/ }
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f =~ /vcr_cassettes/ }
17
17
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
- spec.require_paths = ["lib"]
19
+ spec.require_paths = ['lib']
20
20
  spec.required_ruby_version = '>= 2.1.0'
21
21
 
22
- spec.add_development_dependency "bundler", "~> 1"
22
+ spec.add_development_dependency 'bundler', '~> 1'
23
+ spec.add_development_dependency 'guard'
24
+ spec.add_development_dependency 'guard-minitest'
23
25
  spec.add_development_dependency 'minitest'
24
26
  spec.add_development_dependency 'minitest-utils'
25
27
  spec.add_development_dependency 'pry'
26
- spec.add_development_dependency "rake", "~> 10"
28
+ spec.add_development_dependency 'rake', '~> 10'
27
29
  spec.add_development_dependency 'vcr'
28
30
  spec.add_development_dependency 'webmock'
29
31
 
data/lib/eaternet.rb CHANGED
@@ -1,9 +1,13 @@
1
- require 'eaternet/version'
2
1
  require 'eaternet/agencies/snhd'
3
2
  require 'eaternet/agencies/nyc'
4
3
 
5
- # Make the actual adapters easily available
6
- # to the application-level
7
- include Eaternet::Agencies
4
+ module Eaternet
5
+ # Make the actual adapters easily available
6
+ # to the application-level as:
7
+ #
8
+ # * `Eaternet::Nyc`
9
+ # * `Eaternet::Snhd`
10
+ include Eaternet::Agencies
11
+ end
8
12
 
9
13
  Zip.warn_invalid_date = false if ENV['RUBYZIP_NO_WARN_INVALID_DATE']
@@ -6,16 +6,29 @@ require 'set'
6
6
  require 'tempfile'
7
7
 
8
8
  require 'ext/lazy'
9
- require 'eaternet/framework/lives_1_0'
9
+
10
+ require 'eaternet/lives_1_0'
11
+ require 'eaternet/loggable'
12
+
10
13
 
11
14
  module Eaternet
12
15
  module Agencies
13
- # First-level parsing of New York City restaurant inspections
16
+ # A LIVES 1.0 data source for New York City.
17
+ #
18
+ # Example: print all the restaurant names in New York City.
19
+ #
20
+ # ```ruby
21
+ # require 'eaternet'
22
+ #
23
+ # nyc = Eaternet::Nyc.new
24
+ # nyc.businesses.each { |biz| puts biz.name }
25
+ # ```
14
26
  #
15
27
  # @see https://data.cityofnewyork.us/Health/DOHMH-New-York-City-Restaurant-Inspection-Results/xx67-kt59
16
- # @todo This is a very long namespace. Can this be improved?
17
- class Nyc < Eaternet::Framework::Lives_1_0::Adapter
18
- include Eaternet::Framework::Lives_1_0
28
+ # @todo This is a somewhat long namespace. Can this be improved?
29
+ class Nyc < Eaternet::Lives_1_0::Adapter
30
+ include Eaternet::Lives_1_0
31
+ include Eaternet::Loggable
19
32
 
20
33
  DOMAIN = 'data.cityofnewyork.us'
21
34
  DATASET = 'xx67-kt59'
@@ -26,14 +39,12 @@ module Eaternet
26
39
  # @param csv_path for unit testing
27
40
  def initialize(csv_path: nil)
28
41
  @table_file = csv_path
29
- @logger = Logger.new(STDERR)
30
- @logger.datetime_format = '%Y-%m-%d %H:%M:%S'
31
42
  end
32
43
 
33
44
  def businesses
34
45
  map_csv { |row| try_to_create_business(row) }
35
- .compact
36
- .unique
46
+ .compact
47
+ .unique
37
48
  end
38
49
 
39
50
  def inspections
@@ -43,7 +54,7 @@ module Eaternet
43
54
  nil
44
55
  else
45
56
  save_to_inspection_cache(cache, row)
46
- inspection(row)
57
+ try_to_create_inspection(row)
47
58
  end
48
59
  end.compact
49
60
  end
@@ -80,7 +91,7 @@ module Eaternet
80
91
  l.minimum_score = 0
81
92
  l.maximum_score = 72
82
93
  l.description = 'C'
83
- end,
94
+ end
84
95
  ]
85
96
  end
86
97
  end
@@ -89,14 +100,21 @@ module Eaternet
89
100
  private
90
101
 
91
102
  def try_to_create_business(row)
92
- begin
93
- business(row)
94
- rescue ArgumentError => e
95
- @logger.warn('Eaternet') do
96
- "Could not create a NYC Business from \"#{row.to_s.strip}\": #{e}"
97
- end
98
- nil
103
+ business(row)
104
+ rescue ArgumentError => e
105
+ logger.warn('Eaternet') do
106
+ "Could not create a NYC Business from #{row.inspect}: #{e}"
99
107
  end
108
+ nil
109
+ end
110
+
111
+ def try_to_create_inspection(row)
112
+ inspection(row)
113
+ rescue ArgumentError => e
114
+ logger.warn('Eaternet') do
115
+ "Could not create a NYC Inspection from #{row.inspect}: #{e}"
116
+ end
117
+ nil
100
118
  end
101
119
 
102
120
  def business(row)
@@ -154,11 +172,17 @@ module Eaternet
154
172
  end
155
173
 
156
174
  def transfat_inspection?(row)
157
- row['INSPECTION TYPE'].include?('Trans Fat')
175
+ if row['INSPECTION TYPE']
176
+ row['INSPECTION TYPE'].include?('Trans Fat')
177
+ else
178
+ false
179
+ end
158
180
  end
159
181
 
160
182
  def map_csv(&block)
161
- CSV.new(open(table_file), headers: true).lazy.map { |row| block.call(row) }
183
+ CSV.new(open(table_file, encoding: 'utf-8'), headers: true)
184
+ .lazy
185
+ .map { |row| block.call(row) }
162
186
  end
163
187
 
164
188
  def unique(objects)
@@ -1,6 +1,6 @@
1
1
  require 'csv'
2
2
  require 'eaternet/agencies/snhd_config'
3
- require 'eaternet/framework/prototype'
3
+ require 'eaternet/prototype'
4
4
  require 'eaternet/util'
5
5
 
6
6
  module Eaternet
@@ -10,34 +10,37 @@ module Eaternet
10
10
  # @example Print the names of all the Las Vegas businesses
11
11
  # require 'eaternet'
12
12
  #
13
- # agency = Snhd.new
13
+ # agency = Eaternet::Snhd.new
14
14
  # agency.businesses
15
15
  # .select { |biz| biz.city == 'Las Vegas' }
16
16
  # .each { |biz| puts biz.name }
17
17
  #
18
- # The SNHD makes their restaurant inspection data available via a zip file for download.
19
- # The file contains several Mysql tables converted to CSV. And so, this is an example
20
- # for how to code adapters for other agencies which publish their data in a similar way.
18
+ # The SNHD makes their restaurant inspection data available via a zip file
19
+ # for download. The file contains several Mysql tables converted to CSV.
20
+ # And so, this is an example for how to code adapters for other agencies
21
+ # which publish their data in a similar way.
21
22
  #
22
- # This code downloads the latest zip file, extracts it, parses the csv, and provides
23
- # enumerators of Businesses, Inspections, Violations, and ViolationKinds. (These are
24
- # specified in the Eaternet::Framework::Framework module.)
23
+ # This code downloads the latest zip file, extracts it, parses the csv,
24
+ # and provides enumerators of Businesses, Inspections, Violations, and
25
+ # ViolationKinds. (These are specified in the
26
+ # Eaternet::Prototype module.)
25
27
  #
26
- # SNHD's CSV format seems to be non-standard, e.g. using the double-quote character
27
- # in fields without escaping it. We need to find out what their quote character actually is.
28
- # In the meantime, the files can be parsed by setting the quote character to something else
29
- # that doesn't appear in the text, such as a pipe symbol.
28
+ # SNHD's CSV format seems to be non-standard, e.g. using the double-quote
29
+ # character in fields without escaping it. We need to find out what their
30
+ # quote character actually is. In the meantime, the files can be parsed by
31
+ # setting the quote character to something else that doesn't appear in the
32
+ # text, such as a pipe symbol.
30
33
  #
31
34
  # @see http://southernnevadahealthdistrict.org/restaurants/inspect-downloads.php
32
35
  # The SNHD Developer information page
33
- # @see Eaternet::Framework::Prototype Framework module
36
+ # @see Eaternet::Prototype Framework module
34
37
  class Snhd
35
- include Eaternet::Framework::Prototype::AbstractAdapter
38
+ include Eaternet::Prototype::AbstractAdapter
36
39
 
37
- # (see Eaternet::Framework::Framework::AbstractAdapter#businesses)
40
+ # (see Eaternet::Prototype::AbstractAdapter#businesses)
38
41
  def businesses
39
42
  lazy_csv_map('restaurant_establishments.csv') do |row|
40
- Eaternet::Framework::Prototype::BusinessData.new(
43
+ Eaternet::Prototype::BusinessData.new(
41
44
  orig_key: row['permit_number'],
42
45
  name: row['restaurant_name'],
43
46
  address: row['address'],
@@ -47,10 +50,10 @@ module Eaternet
47
50
  end
48
51
  end
49
52
 
50
- # (see Eaternet::Framework::Framework::AbstractAdapter#inspections)
53
+ # (see Eaternet::Prototype::AbstractAdapter#inspections)
51
54
  def inspections
52
55
  lazy_csv_map('restaurant_inspections.csv') do |row|
53
- Eaternet::Framework::Prototype::InspectionData.new(
56
+ Eaternet::Prototype::InspectionData.new(
54
57
  orig_key: row['serial_number'],
55
58
  business_orig_key: row['permit_number'],
56
59
  score: row['inspection_grade'],
@@ -59,10 +62,10 @@ module Eaternet
59
62
  end
60
63
  end
61
64
 
62
- # (see Eaternet::Framework::Framework::AbstractAdapter#violations)
65
+ # (see Eaternet::Prototype::AbstractAdapter#violations)
63
66
  def violations
64
67
  lazy_csv_map('restaurant_inspection_violations.csv') do |row|
65
- Eaternet::Framework::Prototype::ViolationData.new(
68
+ Eaternet::Prototype::ViolationData.new(
66
69
  orig_key: row['inspection_violation_id'],
67
70
  inspection_id: row['inspection_id'],
68
71
  violation_kind_id: row['inspection_violation']
@@ -74,7 +77,7 @@ module Eaternet
74
77
  # @todo Add to AbstractAdapter
75
78
  def violation_kinds
76
79
  lazy_csv_map('restaurant_violations.csv') do |row|
77
- Eaternet::Framework::Prototype::ViolationKindData.new(
80
+ Eaternet::Prototype::ViolationKindData.new(
78
81
  orig_key: row['violation_id'],
79
82
  code: row['violation_code'],
80
83
  demerits: row['violation_demerits'],
@@ -0,0 +1,240 @@
1
+ # coding: utf-8
2
+ module Eaternet
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 ValidatedObject
46
+ include ActiveModel::Validations
47
+
48
+ def initialize(&block)
49
+ block.call(self)
50
+ check_validations!
51
+ end
52
+
53
+
54
+ private
55
+
56
+ def check_validations!
57
+ fail ArgumentError, errors.messages.inspect if invalid?
58
+ end
59
+
60
+ class TypeValidator < ActiveModel::EachValidator
61
+ def validate_each(record, attribute, value)
62
+ record.errors.add attribute, (options[:message] || "is not of class #{options[:with]}") unless
63
+ value.class == options[:with]
64
+ end
65
+ end
66
+ end
67
+
68
+
69
+ # A food service establishment, e.g. a restaurant.
70
+ # @see http://www.yelp.com/healthscores#businesses
71
+ class Business < ValidatedObject
72
+ attr_accessor :business_id, :name, :address, :city, :state,
73
+ :postal_code, :latitude, :longitude, :phone_number
74
+
75
+ validates :business_id, :name, :address,
76
+ type: String,
77
+ presence: true
78
+ validates :city, :state, :postal_code, :phone_number,
79
+ type: String,
80
+ allow_nil: true
81
+ validates :latitude,
82
+ numericality:
83
+ {
84
+ greater_than_or_equal_to: -90,
85
+ less_than_or_equal_to: 90
86
+ },
87
+ allow_nil: true
88
+ validates :longitude,
89
+ numericality:
90
+ {
91
+ greater_than_or_equal_to: -180,
92
+ less_than_or_equal_to: 180
93
+ },
94
+ allow_nil: true
95
+
96
+ def ==(other)
97
+ @business_id == other.business_id
98
+ end
99
+
100
+ def eql?(other)
101
+ self == other
102
+ end
103
+
104
+ def hash
105
+ @business_id.hash
106
+ end
107
+
108
+ # :nodoc:
109
+ # @return [String]
110
+ def to_s
111
+ "Business #{@business_id}"
112
+ end
113
+ end
114
+
115
+
116
+ # @see http://www.yelp.com/healthscores#inspections
117
+ class Inspection < ValidatedObject
118
+ attr_accessor :business_id, :score, :date, :description, :type
119
+
120
+ ZERO_TO_ONE_HUNDRED_AND_BLANK = (0..100).to_a + ['']
121
+
122
+ validates :business_id,
123
+ type: String,
124
+ presence: true
125
+ validates :score,
126
+ inclusion: { in: ZERO_TO_ONE_HUNDRED_AND_BLANK },
127
+ allow_nil: true
128
+ validates :date,
129
+ type: Date,
130
+ presence: true
131
+ validates :type,
132
+ inclusion: { in: %w(initial routine followup complaint) },
133
+ allow_nil: true
134
+
135
+ def score
136
+ @score.nil? ? '' : @score
137
+ end
138
+
139
+ def to_s
140
+ "Inspection #{@business_id}/#{@date}/#{@score}"
141
+ end
142
+ end
143
+
144
+
145
+ # @see http://www.yelp.com/healthscores#violations
146
+ class Violation < ValidatedObject
147
+ attr_accessor :business_id, :date, :code, :description
148
+
149
+ validates :business_id,
150
+ type: String,
151
+ presence: true
152
+ validates :date,
153
+ type: Date,
154
+ presence: true
155
+ validates :code, :description,
156
+ type: String,
157
+ allow_nil: true
158
+ end
159
+
160
+
161
+ # @see http://www.yelp.com/healthscores#feed_info
162
+ class FeedInfo < ValidatedObject
163
+ attr_accessor :feed_date, :feed_version, :municipality_name,
164
+ :municipality_url, :contact_email
165
+
166
+ # See http://railscasts.com/episodes/211-validations-in-rails-3
167
+ EMAIL_REGEX = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i
168
+ URL_REGEX = %r{\Ahttps?:/}
169
+
170
+ validates :feed_date,
171
+ type: Date,
172
+ presence: true
173
+ validates :feed_version,
174
+ type: String,
175
+ presence: true
176
+ validates :municipality_name,
177
+ type: String,
178
+ presence: true
179
+ validates :municipality_url,
180
+ type: String,
181
+ format: { with: URL_REGEX },
182
+ allow_nil: true
183
+ validates :contact_email,
184
+ type: String,
185
+ format: { with: EMAIL_REGEX },
186
+ allow_nil: true
187
+ end
188
+
189
+
190
+ # @see http://www.yelp.com/healthscores#legend
191
+ class Legend < ValidatedObject
192
+ attr_accessor :minimum_score, :maximum_score, :description
193
+
194
+ validates :minimum_score, :maximum_score,
195
+ inclusion: { in: (0..100) }
196
+ validates :description,
197
+ presence: true,
198
+ type: String
199
+ end
200
+
201
+
202
+ # A container for all the Legends in the data set. Performs
203
+ # validation on the whole set of Legends ensure they cover
204
+ # the entire range of scores and do not overlap.
205
+ class LegendGroup < ValidatedObject
206
+ attr_accessor :legends
207
+
208
+ # Check that all the items in this LegendGroup:
209
+ #
210
+ # 1. Are of the class, Legend
211
+ # 2. Cover the range of scores from 0-100 without overlap
212
+ class ComprehensiveValidator < ActiveModel::EachValidator
213
+ def validate_each(record, attribute, legends)
214
+ scores = (0..100).to_a
215
+ legends.each do |legend|
216
+ unless legend.class == Legend
217
+ record.errors.add attribute, 'must be a Legend'
218
+ return
219
+ end
220
+ range = (legend.minimum_score..legend.maximum_score)
221
+ range.each do |score|
222
+ if scores.delete(score).nil?
223
+ unless score == legend.minimum_score || score == legend.maximum_score
224
+ record.errors.add attribute, 'may not overlap'
225
+ return
226
+ end
227
+ end
228
+ end
229
+ end
230
+ unless scores.empty?
231
+ record.errors.add attribute, 'do not cover entire span from 0–100'
232
+ end
233
+ end
234
+ end
235
+
236
+ validates :legends, comprehensive: true
237
+ end
238
+
239
+ end
240
+ end