geopolitical 3.2.0 → 4.1.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +41 -0
  4. data/app/models/city.rb +9 -10
  5. data/app/models/concerns/geopolitocracy.rb +3 -3
  6. data/app/models/geopolitical/postal/brasilapi.rb +39 -0
  7. data/app/models/geopolitical/postal/geoapify.rb +39 -0
  8. data/app/models/geopolitical/postal/google.rb +64 -0
  9. data/app/models/geopolitical/postal.rb +105 -0
  10. data/app/models/nation.rb +1 -0
  11. data/app/models/region.rb +4 -2
  12. data/app/views/geopolitical/cities/_city.html.erb +6 -0
  13. data/app/views/geopolitical/cities/_form.html.erb +42 -0
  14. data/app/views/geopolitical/cities/edit.html.erb +1 -0
  15. data/app/views/geopolitical/cities/index.html.erb +46 -0
  16. data/app/views/geopolitical/cities/new.html.erb +1 -0
  17. data/app/views/geopolitical/cities/show.html.erb +24 -0
  18. data/app/views/geopolitical/geopolitical/edit.html.erb +1 -0
  19. data/app/views/geopolitical/geopolitical/index.html.erb +24 -0
  20. data/app/views/geopolitical/geopolitical/new.html.erb +1 -0
  21. data/app/views/geopolitical/hoods/_city.html.erb +6 -0
  22. data/app/views/geopolitical/hoods/_form.html.erb +40 -0
  23. data/app/views/geopolitical/hoods/edit.html.erb +1 -0
  24. data/app/views/geopolitical/hoods/index.html.erb +42 -0
  25. data/app/views/geopolitical/hoods/new.html.erb +1 -0
  26. data/app/views/geopolitical/hoods/show.html.erb +20 -0
  27. data/app/views/geopolitical/nations/_form.html.erb +44 -0
  28. data/app/views/geopolitical/nations/_nation.html.erb +7 -0
  29. data/app/views/geopolitical/nations/edit.html.erb +1 -0
  30. data/app/views/geopolitical/nations/index.html.erb +46 -0
  31. data/app/views/geopolitical/nations/new.html.erb +1 -0
  32. data/app/views/geopolitical/nations/show.html.erb +28 -0
  33. data/app/views/geopolitical/regions/_form.html.erb +40 -0
  34. data/app/views/geopolitical/regions/_region.html.erb +6 -0
  35. data/app/views/geopolitical/regions/edit.html.erb +1 -0
  36. data/app/views/geopolitical/regions/index.html.erb +42 -0
  37. data/app/views/geopolitical/regions/new.html.erb +1 -0
  38. data/app/views/geopolitical/regions/show.html.erb +24 -0
  39. data/app/views/layouts/geopolitical.html.erb +25 -0
  40. data/geopolitical.gemspec +2 -3
  41. data/lib/geopolitical/engine.rb +0 -1
  42. data/lib/geopolitical/version.rb +1 -1
  43. data/lib/geopolitical.rb +13 -0
  44. metadata +35 -45
  45. data/app/views/geopolitical/cities/_city.html.haml +0 -4
  46. data/app/views/geopolitical/cities/_form.html.haml +0 -28
  47. data/app/views/geopolitical/cities/edit.html.haml +0 -1
  48. data/app/views/geopolitical/cities/index.html.haml +0 -32
  49. data/app/views/geopolitical/cities/new.html.haml +0 -1
  50. data/app/views/geopolitical/cities/show.html.haml +0 -17
  51. data/app/views/geopolitical/geopolitical/edit.html.haml +0 -1
  52. data/app/views/geopolitical/geopolitical/index.html.haml +0 -19
  53. data/app/views/geopolitical/geopolitical/new.html.haml +0 -1
  54. data/app/views/geopolitical/hoods/_city.html.haml +0 -4
  55. data/app/views/geopolitical/hoods/_form.html.haml +0 -26
  56. data/app/views/geopolitical/hoods/edit.html.haml +0 -1
  57. data/app/views/geopolitical/hoods/index.html.haml +0 -28
  58. data/app/views/geopolitical/hoods/new.html.haml +0 -1
  59. data/app/views/geopolitical/hoods/show.html.haml +0 -14
  60. data/app/views/geopolitical/nations/_form.html.haml +0 -29
  61. data/app/views/geopolitical/nations/_nation.html.haml +0 -5
  62. data/app/views/geopolitical/nations/edit.html.haml +0 -1
  63. data/app/views/geopolitical/nations/index.html.haml +0 -32
  64. data/app/views/geopolitical/nations/new.html.haml +0 -1
  65. data/app/views/geopolitical/nations/show.html.haml +0 -20
  66. data/app/views/geopolitical/regions/_form.html.haml +0 -26
  67. data/app/views/geopolitical/regions/_region.html.haml +0 -4
  68. data/app/views/geopolitical/regions/edit.html.haml +0 -1
  69. data/app/views/geopolitical/regions/index.html.haml +0 -28
  70. data/app/views/geopolitical/regions/new.html.haml +0 -1
  71. data/app/views/geopolitical/regions/show.html.haml +0 -17
  72. data/app/views/layouts/geopolitical.html.haml +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6eab76b546c1fe68a2b74c24193504f349c650ef97a5982cc87ee5fb74769d4e
4
- data.tar.gz: 3301e6d264d6446a0663941819b76b64f956e83f89a16e5bcf70f87644abe616
3
+ metadata.gz: bfe29900897a18f20389e3bcc7107c7934acf4a482594d62cd203d16218baacf
4
+ data.tar.gz: 7502b7cb5e3b66461e849cbb4eac85ac20e05e7e4c77f05d30067363dd99670c
5
5
  SHA512:
6
- metadata.gz: e4b582dacaf75fe5584450b61ee55d05bab5e15565ee7ee31c0104a852fcd1d28853fef45aa11c5de67bc48d9be8f18997789032fed639dd4deba8a5cd8e45ad
7
- data.tar.gz: 1461b8bf452628a21c708f7c884d72301d0ac421079671b554e1aa2d4816064adafde8595c95c988936b52f85b5996c37bd27e08c00ee8e1d31461f88c200e68
6
+ metadata.gz: 7d878011e11bc16a92e2709938930d3a52bb6acabd4735ddcd393c150cd8c179b594cc0b497db50f37d556414f77f59ee235db6e519e41c3fb6aab6bf1c9f689
7
+ data.tar.gz: b6fa32e93cd018cf6487c5930b3e22dcd9ca4eb6c89a5c970c8824f56cb3050c55370c7eb3950d07027166f39c6b958440a32950497ea4b6629d008d582a209f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.0
4
+
5
+ - Engine views are ERB; the `haml` gem is gone.
6
+
7
+ ## 4.0.0
8
+
9
+ - `City#geom` has one index, 2dsphere (`sphere: true`). It had a planar `2d` beside it and `City.nearby` used that one; it is `$nearSphere` now. The `spatial_scope :geom` (`City.closest_to_geom`, planar) is gone. An existing database keeps the old `geom_2d` until `City.remove_undefined_indexes`.
10
+
11
+ - `Geopolitical::Postal.suggest(text, session:)` and `.pick(ref, session:)` — type any address, pick one, get a `Found` you may store. `Geopolitical.postal_provider = :google` (Places API New, Essentials fields only) or `:geoapify`; `Geopolitical.postal_key` holds the key. A `City` the table lacks is made under its state's `Region`, same as a CEP's.
12
+ - `Geopolitical::Postal.find('01311-925')` — a Brazilian CEP in, the street, the neighbourhood and the `City` out (by IBGE code, `City#code`). A city the table lacks is made under its state's `Region`. BrasilAPI v2, one call, cached a day where Rails has a cache; offline, unknown or malformed answers `nil`. `nation:` is `'BR'`, and anything else answers `nil` rather than a guess.
13
+
14
+ ## 3.2.1
15
+
16
+ - Every uniqueness validation now has a unique index behind it, so concurrent writes can't slip a duplicate past the check:
17
+ - `Nation` abbr: unique (the writer upcases, so it is case-insensitive).
18
+ - `City` name: unique on `{nation_id, region_id, name}`, the validation's own scope.
19
+ - `Region` abbr within a nation: partial on `abbr > ""` instead of sparse. Sparse did nothing on a compound index whose `nation_id` is always set, so the second abbr-less region in a nation failed to save.
20
+ - The concern no longer declares `{abbr: 1}`. Mongoid keys a declaration on its fields alone, so the concern's copy silently swallowed `Nation`'s unique one. `Region` keeps its own.
21
+ - `spec/models/indexes_spec.rb` inserts straight into the collection, past the validations, and expects the index to refuse.
22
+
3
23
  ## 3.2.0
4
24
 
5
25
  - Slugs work in every script: `東京` → `東京`, `Санкт-Петербург` → `санкт-петербург`. Non-Latin names were previously invalid (blank slug). Accents are stripped via NFKD (`Hà Nội` → `ha-noi`). Underscores now become hyphens.
@@ -25,4 +45,4 @@
25
45
  - `Nation#lang=` keeps the nations other languages instead of wiping `langs` to a single entry.
26
46
  - `Nation[]` returns `nil` for an unknown abbr instead of raising `DocumentNotFound`.
27
47
 
28
- **Upgrading:** stored slugs are not rewritten until a document is saved again, so existing data is untouched � but region-less cities will pick up a nation suffix the next time they are saved, and `City.create_indexes` will now fail on a database that already holds duplicate city slugs. `geonames_local` depends on `geopolitical > 0.8.4` (no upper bound) and will pick this up on its next bundle.
48
+ **Upgrading:** stored slugs are not rewritten until a document is saved again, so existing data is untouched � but region-less cities will pick up a nation suffix the next time they are saved, and `City.create_indexes` will now fail on a database that already holds duplicate city slugs. `geonames_local` depends on `geopolitical > 0.8.4` (no upper bound) and will pick this up on its next bundle.
data/README.md CHANGED
@@ -109,6 +109,47 @@ City.population.first # biggest city
109
109
  Region.ordered # by name
110
110
  ```
111
111
 
112
+ ### Addresses — `Geopolitical::Postal`
113
+
114
+ Two ways in, one answer out: a `Found` with the street, number, hood, postal
115
+ code and the `City` (found, or made under its state's `Region`).
116
+
117
+ ```
118
+ typing "av paulista 1578" ──suggest──► [Suggestion(ref, text), …] free, nothing kept
119
+ the person picks one ──pick─────► Found one call, yours to store
120
+ a Brazilian CEP ──find─────► Found free, no key
121
+ ```
122
+
123
+ ```ruby
124
+ # config/initializers/geopolitical.rb
125
+ Geopolitical.postal_provider = :google # :google · :geoapify — nil means no typing search
126
+ Geopolitical.postal_key = ENV['GOOGLE_MAPS_KEY']
127
+
128
+ token = SecureRandom.uuid # one per form: Google bills the typing as one session
129
+ Geopolitical::Postal.suggest('av paulista 1578', session: token, nation: 'BR')
130
+ # => [#<data Suggestion ref="ChIJ…" text="Avenida Paulista, 1578 - Bela Vista, São Paulo - SP, Brasil">]
131
+ found = Geopolitical::Postal.pick('ChIJ…', session: token)
132
+ found.street # => "Avenida Paulista"
133
+ found.number # => "1578"
134
+ found.city # => #<City São Paulo/SP>
135
+ found.ref # => "ChIJ…" the provider's id, the one thing Google lets you keep forever
136
+
137
+ Geopolitical::Postal.find('01311-925') # a CEP, masked or not — BrasilAPI, whatever the provider
138
+ Geopolitical::Postal.find('10001', nation: 'US') # => nil — no guessing
139
+ ```
140
+
141
+ | provider | data | store the pick? | free |
142
+ |-------------|-------------------|-----------------|---------------------------------|
143
+ | `:google` | best, Brazil too | yes — the address the person picked ([Places policy](https://developers.google.com/maps/documentation/places/web-service/policies)) | typing unlimited in a session, 10k picks/mo |
144
+ | `:geoapify` | OpenStreetMap | yes | 3,000 requests/day |
145
+ | BrasilAPI | Correios (CEP) | yes | no key, `find` only |
146
+
147
+ Google's pick asks the **Essentials** fields and nothing else
148
+ (`Postal::Google::FIELDS`) — one field more and it bills as Pro. Suggestions
149
+ and picks are never cached; a CEP is cached a day where Rails has a cache.
150
+ Offline, unknown, a bad key: `[]` or `nil`, and the form stays open for a
151
+ person to type.
152
+
112
153
  Slugs are the API. They're stable, unique, URL-safe and they survive scripts
113
154
  you can't transliterate:
114
155
 
data/app/models/city.rb CHANGED
@@ -15,19 +15,18 @@ class City
15
15
 
16
16
  # @!attribute [rw] area
17
17
  # @return [Integer] The square area of the city in square meters (m2).
18
- field :area, type: Integer
18
+ field :area, type: Integer
19
+
19
20
  # @!attribute [rw] geom
20
- # @return [Point] The geographical coordinates (longitude, latitude) of the city.
21
- # Indexed for spatial queries.
22
- field :geom, type: Point, spatial: true
21
+ # @return [Point] [lng, lat], 2dsphere `nearby` is `$nearSphere`.
22
+ #
23
+ field :geom, type: Point, sphere: true
24
+
23
25
  # @!attribute [rw] rbbr
24
26
  # @return [String] Cached abbreviation of the city's region.
25
27
  # Used internally, primarily to construct unique slugs.
26
28
  # Alias: `region_abbr` (writer only, getter is custom).
27
- field :rbbr, type: String, as: :region_abbr
28
-
29
- # Enables spatial queries on the `geom` field.
30
- spatial_scope :geom
29
+ field :rbbr, type: String, as: :region_abbr
31
30
 
32
31
  # @!attribute [rw] region
33
32
  # @return [Region, nil] The region this city belongs to. Optional.
@@ -61,10 +60,10 @@ class City
61
60
 
62
61
  index({ slug: 1 }, unique: true)
63
62
  index({ name: 1, nation_id: 1 }) # For lookups by name within a nation
64
- index({ nation_id: 1 }) # For finding all cities in a nation
63
+ index({ nation_id: 1, region_id: 1, name: 1 }, { unique: true }) # Backs the name uniqueness validation
64
+ index({ nation_id: 1 }) # For finding all cities in a nation
65
65
  index({ region_id: 1 }, sparse: true) # For finding cities in a region, sparse if region is optional
66
66
  index({ souls: -1 }) # For sorting by population count
67
- index({ geom: '2dsphere' }) # Explicit 2dsphere index for geospatial queries
68
67
 
69
68
  # Validates that if a city is associated with a region, that region
70
69
  # belongs to the same nation as the city.
@@ -77,10 +77,10 @@ module Geopolitocracy
77
77
  # @!endgroup
78
78
 
79
79
  # @!group Indexes
80
- # NOTE: no plain slug index here — each model declares its own (unique where it
81
- # can be), and a duplicate key declaration would silently shadow theirs.
80
+ # NOTE: no slug or abbr index here — each model declares its own (unique where
81
+ # it can be). Mongoid keys a declaration on the fields alone, so a second
82
+ # `index({ abbr: 1 }, unique: true)` in a model would be silently ignored.
82
83
  index({ name: 1 }) # For sorting and lookups by name
83
- index({ abbr: 1 }, { sparse: true }) # Sparse as abbr can be nil
84
84
  index({ code: 1 }, { sparse: true }) # Sparse as code can be nil
85
85
  # @!endgroup
86
86
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geopolitical
4
+ module Postal
5
+ #
6
+ # A Brazilian CEP → street, hood and City, off the Correios data.
7
+ #
8
+ # BrasilAPI v2, not ViaCEP: ViaCEP answers {"erro": "true"} for a
9
+ # town-wide CEP like 37958-000, BrasilAPI answers it with the IBGE code.
10
+ # Its coordinates are the city's centroid, not the street, so they are
11
+ # not read. An answer is a suggestion a person may overwrite — the
12
+ # provider even places 99999-999 somewhere.
13
+ #
14
+ # ponytail: one free provider, no key. A second one the day this one is
15
+ # down long enough to notice.
16
+ #
17
+ module Brasilapi
18
+ URL = 'https://brasilapi.com.br/api/cep/v2/%s'
19
+
20
+ module_function
21
+
22
+ def find(code, nation: 'BR')
23
+ return unless nation.to_s.upcase == 'BR'
24
+
25
+ digits = code.to_s.gsub(/\D/, '')
26
+ return unless digits.size == 8
27
+
28
+ body = Postal.cached("geopolitical/postal/#{digits}") { Postal.http(format(URL, digits)) }
29
+ found(body, "#{digits[0, 5]}-#{digits[5, 3]}") if body
30
+ end
31
+
32
+ def found(body, postal)
33
+ city = Postal.city(nation: 'BR', state: body['state'], name: body['city'], code: body.dig('ibge', 'city'))
34
+ Found.new(postal: postal, street: body['street'].presence, number: nil,
35
+ hood: body['neighborhood'].presence, city: city, ref: nil)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geopolitical
4
+ module Postal
5
+ #
6
+ # Geoapify: OpenStreetMap data, 3,000 requests a day free, and a licence
7
+ # that lets you keep what you picked. Address autocomplete while typing,
8
+ # place details on the pick. No sessions — every request is one credit.
9
+ #
10
+ module Geoapify
11
+ AUTOCOMPLETE = 'https://api.geoapify.com/v1/geocode/autocomplete'
12
+ DETAILS = 'https://api.geoapify.com/v2/place-details'
13
+
14
+ module_function
15
+
16
+ def suggest(text, nation:, **)
17
+ query = { text: text, format: 'json', lang: Postal.language,
18
+ filter: ("countrycode:#{nation.to_s.downcase}" if nation.present?),
19
+ apiKey: Geopolitical.postal_key }.compact
20
+ answer = Postal.http("#{AUTOCOMPLETE}?#{URI.encode_www_form(query)}") or return []
21
+
22
+ Array(answer['results']).filter_map do |result|
23
+ Suggestion.new(ref: result['place_id'], text: result['formatted']) if result['place_id']
24
+ end
25
+ end
26
+
27
+ def pick(ref, **)
28
+ query = URI.encode_www_form(id: ref, lang: Postal.language, apiKey: Geopolitical.postal_key)
29
+ answer = Postal.http("#{DETAILS}?#{query}") or return
30
+ place = answer.dig('features', 0, 'properties') or return
31
+
32
+ Found.new(postal: place['postcode'], street: place['street'], number: place['housenumber'],
33
+ hood: place['suburb'] || place['district'],
34
+ city: Postal.city(nation: place['country_code'], state: place['state_code'], name: place['city']),
35
+ ref: place['place_id'] || ref)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geopolitical
4
+ module Postal
5
+ #
6
+ # Google Places API (New). The typing is Autocomplete, and a session token
7
+ # makes it free when the session ends in a pick; the pick is one Place
8
+ # Details call. The address the person picked is theirs to keep (Places
9
+ # policy, "Autocomplete for end user addresses"); the suggestion list is
10
+ # not, and neither is a cache of it.
11
+ #
12
+ module Google
13
+ AUTOCOMPLETE = 'https://places.googleapis.com/v1/places:autocomplete'
14
+ DETAILS = 'https://places.googleapis.com/v1/places/%s'
15
+
16
+ # Place Details Essentials. One field outside that SKU bills every pick
17
+ # as Pro — half the free allowance, three times the price.
18
+ FIELDS = %w[id formattedAddress addressComponents].freeze
19
+
20
+ module_function
21
+
22
+ def suggest(text, session:, nation:)
23
+ body = { input: text, sessionToken: session, languageCode: Postal.language,
24
+ includedRegionCodes: ([nation.to_s.downcase] if nation.present?) }.compact
25
+ answer = Postal.http(AUTOCOMPLETE, body: body, headers: key) or return []
26
+
27
+ Array(answer['suggestions']).filter_map do |suggestion|
28
+ place = suggestion['placePrediction'] or next
29
+ Suggestion.new(ref: place['placeId'], text: place.dig('text', 'text'))
30
+ end
31
+ end
32
+
33
+ def pick(ref, session:)
34
+ query = URI.encode_www_form({ sessionToken: session, languageCode: Postal.language }.compact)
35
+ url = "#{format(DETAILS, URI.encode_www_form_component(ref))}?#{query}"
36
+ place = Postal.http(url, headers: key.merge('X-Goog-FieldMask' => FIELDS.join(','))) or return
37
+
38
+ found(Array(place['addressComponents']), place['id'] || ref)
39
+ end
40
+
41
+ # ponytail: locality first, the city in most countries; Brazil often
42
+ # sends only administrative_area_level_2. Real answers settle the order.
43
+ def found(parts, ref)
44
+ Found.new(postal: part(parts, 'postal_code'), street: part(parts, 'route'),
45
+ number: part(parts, 'street_number'), ref: ref,
46
+ hood: part(parts, 'sublocality_level_1', 'sublocality', 'neighborhood'),
47
+ city: Postal.city(nation: part(parts, 'country', text: 'shortText'),
48
+ state: part(parts, 'administrative_area_level_1', text: 'shortText'),
49
+ name: part(parts, 'locality', 'administrative_area_level_2')))
50
+ end
51
+
52
+ # The first component carrying any of the types, in the order asked.
53
+ def part(parts, *types, text: 'longText')
54
+ types.each do |type|
55
+ component = parts.find { |candidate| Array(candidate['types']).include?(type) }
56
+ return component[text] if component
57
+ end
58
+ nil
59
+ end
60
+
61
+ def key = { 'X-Goog-Api-Key' => Geopolitical.postal_key }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'active_support/core_ext/object/blank'
6
+
7
+ module Geopolitical
8
+ #
9
+ # An address in, a `Found` out — the street, the number, the hood, the
10
+ # postal code and the City (found, or made under its state's Region).
11
+ #
12
+ # suggest(text, session:) the list while a person types free, nothing kept
13
+ # pick(ref, session:) the one they chose one call, yours to store
14
+ # find(cep) a Brazilian CEP BrasilAPI, no key
15
+ #
16
+ # `suggest` and `pick` go to `Geopolitical.postal_provider` (:google or
17
+ # :geoapify) with `Geopolitical.postal_key`; with either unset the search is
18
+ # off and answers [] / nil. Every call leaves through `http`, which answers
19
+ # nil for anything but a 200 with JSON — offline, a bad key, a timeout.
20
+ #
21
+ # Only CEPs are cached: Google's terms keep the picked address yours, but
22
+ # not a cache of its answers.
23
+ #
24
+ module Postal
25
+ Found = Data.define(:postal, :street, :number, :hood, :city, :ref)
26
+ Suggestion = Data.define(:ref, :text)
27
+
28
+ module_function
29
+
30
+ def suggest(text, session: nil, nation: nil)
31
+ text = text.to_s.strip
32
+ api = provider
33
+ return [] if api.nil? || text.size < 3
34
+
35
+ api.suggest(text, session:, nation:)
36
+ end
37
+
38
+ def pick(ref, session: nil)
39
+ api = provider
40
+ return if api.nil? || ref.blank?
41
+
42
+ api.pick(ref.to_s, session:)
43
+ end
44
+
45
+ def find(code, nation: 'BR') = Brasilapi.find(code, nation:)
46
+
47
+ # A typo in the provider's name raises: a search that is silently off is
48
+ # a form nobody knows is broken.
49
+ def provider
50
+ return if Geopolitical.postal_provider.blank? || Geopolitical.postal_key.blank?
51
+
52
+ { google: Google, geoapify: Geoapify }.fetch(Geopolitical.postal_provider.to_sym)
53
+ end
54
+
55
+ def http(url, body: nil, headers: {})
56
+ uri = URI(url)
57
+ request = body ? Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json') : Net::HTTP::Get.new(uri)
58
+ headers.each { |name, value| request[name] = value }
59
+ request.body = JSON.generate(body) if body
60
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 2, read_timeout: 4) do |net|
61
+ net.request(request)
62
+ end
63
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ # By code where the provider has one (IBGE), else by name under the
69
+ # state's Region. One the table lacks is made there: a small town is
70
+ # exactly what a city table is most likely to be missing. No Region for
71
+ # the state, no City — the rest of the address still fills.
72
+ def city(nation:, state:, name:, code: nil)
73
+ known = City.where(code: code).first if code.present?
74
+ return known if known
75
+
76
+ region = region(nation, state)
77
+ return if region.nil? || name.blank?
78
+
79
+ slug = "#{Geopolitocracy.slugify(name)}-#{Geopolitocracy.slugify(region.abbr)}"
80
+ City.where(slug: slug).first || make_city(name, code, region) || City.where(slug: slug).first
81
+ end
82
+
83
+ def region(nation, state)
84
+ country = Nation[nation] or return
85
+ Region.where(nation_id: country.id, abbr: state.to_s.upcase).first
86
+ end
87
+
88
+ # Two people picking the same new town at once: the second insert loses to
89
+ # the unique slug index, and the caller finds the first one's City.
90
+ def make_city(name, code, region)
91
+ made = City.create(name: name, code: code.presence, region: region, nation: region.nation)
92
+ made if made.persisted?
93
+ rescue Mongo::Error::OperationFailure
94
+ nil
95
+ end
96
+
97
+ def language = defined?(I18n) ? I18n.locale.to_s[0, 2] : 'en'
98
+
99
+ def cached(key, &)
100
+ return yield unless defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
101
+
102
+ Rails.cache.fetch(key, expires_in: 86_400, skip_nil: true, &)
103
+ end
104
+ end
105
+ end
data/app/models/nation.rb CHANGED
@@ -61,6 +61,7 @@ class Nation
61
61
 
62
62
  index({ name: 1 }) # Index for sorting by name, common operation
63
63
  index({ slug: 1 }, { unique: true }) # Slugs must be globally unique for nations
64
+ index({ abbr: 1 }, { unique: true }) # abbr is upcased by its writer, so plain unique is case-insensitive
64
65
 
65
66
  # Nation['br'] => the Nation with abbr BR (the abbr is the _id), nil if unknown.
66
67
  # @param abbr [String, Symbol]
data/app/models/region.rb CHANGED
@@ -30,14 +30,16 @@ class Region
30
30
  validates :name, uniqueness: { scope: :nation_id, message: 'must be unique within its nation' }
31
31
  # `abbr` (from Geopolitocracy) should also be unique within its nation if present.
32
32
  validates :abbr,
33
- uniqueness: { scope: :nation_id, allow_nil: true, message: 'must be unique within its nation if provided' }
33
+ uniqueness: { scope: :nation_id, allow_blank: true, message: 'must be unique within its nation if provided' }
34
34
  # Slug (from Geopolitocracy) should be unique within its nation. Presence is already validated by Geopolitocracy.
35
35
  validates :slug, uniqueness: { scope: :nation_id, message: 'must be unique within its nation' }
36
36
 
37
37
  index({ slug: 1 }) # For .search; global uniqueness is not required, see the compound index below
38
38
  index({ abbr: 1 }, { sparse: true }) # Sparse index as abbr can be nil
39
39
  index({ nation_id: 1, name: 1 }, { unique: true }) # Enforce uniqueness of name within nation
40
- index({ nation_id: 1, abbr: 1 }, { unique: true, sparse: true }) # Enforce uniqueness of abbr within nation
40
+ # Partial, not sparse: nation_id is always set, so a sparse compound index
41
+ # still indexes every abbr-less region and the second one collides.
42
+ index({ nation_id: 1, abbr: 1 }, { unique: true, partial_filter_expression: { abbr: { '$gt' => '' } } })
41
43
  index({ nation_id: 1, slug: 1 }, { unique: true }) # Enforce uniqueness of slug within nation
42
44
 
43
45
  # Retrieves the phone dialing code for the region.
@@ -0,0 +1,6 @@
1
+ <div class="city">
2
+ <h4><%= link_to city.name, city_path(city) %></h4>
3
+ <div class="right">
4
+ <%= link_to t('geopolitical.actions.edit'), edit_city_path(city) %>
5
+ </div>
6
+ </div>
@@ -0,0 +1,42 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <div class="page-header">
4
+ <h1>
5
+ <% key = @city.new_record? ? 'new' : 'edit' %>
6
+ <%= t("geopolitical.forms.#{key}", model: City.model_name.human) %>
7
+ </h1>
8
+ </div>
9
+ </div>
10
+ <div class="row">
11
+ <%= form_with model: @city, url: (@city.persisted? ? city_path(@city) : cities_path), html: { class: 'form' } do |f| %>
12
+ <% if @city.errors.any? %>
13
+ <ul>
14
+ <% @city.errors.full_messages.each do |message| %>
15
+ <li><%= message %></li>
16
+ <% end %>
17
+ </ul>
18
+ <% end %>
19
+ <div class="form-group">
20
+ <%= f.label :nation_id %>
21
+ <%= f.select :nation_id, @nations, include_blank: true %>
22
+ </div>
23
+ <div class="form-group">
24
+ <%= f.label :region_id %>
25
+ <%= f.select :region_id, @regions, include_blank: true %>
26
+ </div>
27
+ <div class="form-group">
28
+ <%= f.label :name %>
29
+ <%= f.text_field :name %>
30
+ </div>
31
+ <div class="form-group">
32
+ <%= f.label :slug %>
33
+ <%= f.text_field :slug %>
34
+ </div>
35
+ <div class="form-group">
36
+ <%= f.label :souls %>
37
+ <%= f.number_field :souls %>
38
+ </div>
39
+ <%= f.submit t('geopolitical.actions.save') %>
40
+ <% end %>
41
+ </div>
42
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'form' %>
@@ -0,0 +1,46 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <div class="page-header">
4
+ <h1 class="title"><%= City.model_name.human(count: 2) %></h1>
5
+ <%= link_to t('geopolitical.actions.new', model: City.model_name.human), new_city_path %>
6
+ </div>
7
+ </div>
8
+ <div class="row">
9
+ <div class="content">
10
+ <table class="table">
11
+ <thead>
12
+ <tr>
13
+ <th><%= City.human_attribute_name(:name) %></th>
14
+ <th><%= City.human_attribute_name(:postal) %></th>
15
+ <th><%= City.human_attribute_name(:region_id) %></th>
16
+ <th><%= City.human_attribute_name(:nation_id) %></th>
17
+ <th><%= City.human_attribute_name(:souls) %></th>
18
+ <th></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% @cities.each do |city| %>
23
+ <tr>
24
+ <td>
25
+ <%= city.name %>
26
+ <%= link_to city.slug, city_path(city) %>
27
+ </td>
28
+ <td><%= city.postal %></td>
29
+ <td><%= city.region&.name %></td>
30
+ <td><%= city.nation&.name %></td>
31
+ <td><%= city.souls %></td>
32
+ <td align="right">
33
+ <%= link_to t('geopolitical.actions.edit'), edit_city_path(city) %>
34
+ <%= button_to t('geopolitical.actions.destroy'), city_path(city), method: :delete,
35
+ data: { turbo_confirm: t('geopolitical.actions.confirm_destroy', model: City.model_name.human) } %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
42
+ </div>
43
+ <div class="row">
44
+ <%= link_to t('geopolitical.actions.back'), root_path %>
45
+ </div>
46
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'form' %>
@@ -0,0 +1,24 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <h1><%= @city.name %></h1>
4
+ </div>
5
+ <div class="row">
6
+ <p>
7
+ <b><%= City.human_attribute_name(:slug) %>:</b>
8
+ <%= @city.slug %>
9
+ </p>
10
+ <p>
11
+ <b><%= City.human_attribute_name(:nation_id) %>:</b>
12
+ <%= link_to @city.nation, nation_path(@city.nation) if @city.nation %>
13
+ </p>
14
+ <p>
15
+ <b><%= City.human_attribute_name(:region_id) %>:</b>
16
+ <%= link_to @city.region, region_path(@city.region) if @city.region %>
17
+ </p>
18
+ </div>
19
+ <div class="row">
20
+ <%= link_to t('geopolitical.actions.edit'), edit_city_path(@city) %>
21
+ |
22
+ <%= link_to t('geopolitical.actions.back'), cities_path %>
23
+ </div>
24
+ </div>
@@ -0,0 +1 @@
1
+ <%= render "form" %>
@@ -0,0 +1,24 @@
1
+ <h1><%= t('geopolitical.title') %></h1>
2
+ <hr>
3
+
4
+ <% [Nation, Region, City, Hood].each do |klass| %>
5
+ <div class="block">
6
+ <h3 class="title">
7
+ <%= link_to klass.model_name.human(count: 2), send("#{klass.to_s.downcase.pluralize}_path") %>
8
+ <small><%= klass.count %></small>
9
+ </h3>
10
+ <div class="right links">
11
+ <%= link_to t('geopolitical.actions.new', model: klass.model_name.human),
12
+ send("new_#{klass.to_s.downcase}_path") %>
13
+ </div>
14
+ </div>
15
+ <% end %>
16
+
17
+ <br>
18
+ <h4>
19
+ <%= t('geopolitical.dashboard.populate') %>
20
+ <small><%= link_to 'geonames_local', 'https://github.com/nofxx/geonames_local' %></small>
21
+ </h4>
22
+
23
+ <hr>
24
+ <p><%= link_to 'fireho/geopolitical', 'https://github.com/fireho/geopolitical' %></p>
@@ -0,0 +1 @@
1
+ <%= render "form" %>
@@ -0,0 +1,6 @@
1
+ <div class="hood">
2
+ <h4><%= link_to hood.name, hood_path(hood) %></h4>
3
+ <div class="right">
4
+ <%= link_to t('geopolitical.actions.edit'), edit_hood_path(hood) %>
5
+ </div>
6
+ </div>
@@ -0,0 +1,40 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <div class="page-header">
4
+ <h1>
5
+ <% key = @hood.new_record? ? 'new' : 'edit' %>
6
+ <%= t("geopolitical.forms.#{key}", model: Hood.model_name.human) %>
7
+ </h1>
8
+ </div>
9
+ </div>
10
+ <div class="row">
11
+ <div class="content">
12
+ <%= form_with model: @hood, url: (@hood.persisted? ? hood_path(@hood) : hoods_path), html: { class: 'form' } do |f| %>
13
+ <% if @hood.errors.any? %>
14
+ <ul>
15
+ <% @hood.errors.full_messages.each do |message| %>
16
+ <li><%= message %></li>
17
+ <% end %>
18
+ </ul>
19
+ <% end %>
20
+ <div class="form-group">
21
+ <%= f.label :city_id %>
22
+ <%= f.select :city_id, @cities, include_blank: true %>
23
+ </div>
24
+ <div class="form-group">
25
+ <%= f.label :name %>
26
+ <%= f.text_field :name %>
27
+ </div>
28
+ <div class="form-group">
29
+ <%= f.label :slug %>
30
+ <%= f.text_field :slug %>
31
+ </div>
32
+ <div class="form-group">
33
+ <%= f.label :souls %>
34
+ <%= f.number_field :souls %>
35
+ </div>
36
+ <%= f.submit t('geopolitical.actions.save') %>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'form' %>