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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +41 -0
- data/app/models/city.rb +9 -10
- data/app/models/concerns/geopolitocracy.rb +3 -3
- data/app/models/geopolitical/postal/brasilapi.rb +39 -0
- data/app/models/geopolitical/postal/geoapify.rb +39 -0
- data/app/models/geopolitical/postal/google.rb +64 -0
- data/app/models/geopolitical/postal.rb +105 -0
- data/app/models/nation.rb +1 -0
- data/app/models/region.rb +4 -2
- data/app/views/geopolitical/cities/_city.html.erb +6 -0
- data/app/views/geopolitical/cities/_form.html.erb +42 -0
- data/app/views/geopolitical/cities/edit.html.erb +1 -0
- data/app/views/geopolitical/cities/index.html.erb +46 -0
- data/app/views/geopolitical/cities/new.html.erb +1 -0
- data/app/views/geopolitical/cities/show.html.erb +24 -0
- data/app/views/geopolitical/geopolitical/edit.html.erb +1 -0
- data/app/views/geopolitical/geopolitical/index.html.erb +24 -0
- data/app/views/geopolitical/geopolitical/new.html.erb +1 -0
- data/app/views/geopolitical/hoods/_city.html.erb +6 -0
- data/app/views/geopolitical/hoods/_form.html.erb +40 -0
- data/app/views/geopolitical/hoods/edit.html.erb +1 -0
- data/app/views/geopolitical/hoods/index.html.erb +42 -0
- data/app/views/geopolitical/hoods/new.html.erb +1 -0
- data/app/views/geopolitical/hoods/show.html.erb +20 -0
- data/app/views/geopolitical/nations/_form.html.erb +44 -0
- data/app/views/geopolitical/nations/_nation.html.erb +7 -0
- data/app/views/geopolitical/nations/edit.html.erb +1 -0
- data/app/views/geopolitical/nations/index.html.erb +46 -0
- data/app/views/geopolitical/nations/new.html.erb +1 -0
- data/app/views/geopolitical/nations/show.html.erb +28 -0
- data/app/views/geopolitical/regions/_form.html.erb +40 -0
- data/app/views/geopolitical/regions/_region.html.erb +6 -0
- data/app/views/geopolitical/regions/edit.html.erb +1 -0
- data/app/views/geopolitical/regions/index.html.erb +42 -0
- data/app/views/geopolitical/regions/new.html.erb +1 -0
- data/app/views/geopolitical/regions/show.html.erb +24 -0
- data/app/views/layouts/geopolitical.html.erb +25 -0
- data/geopolitical.gemspec +2 -3
- data/lib/geopolitical/engine.rb +0 -1
- data/lib/geopolitical/version.rb +1 -1
- data/lib/geopolitical.rb +13 -0
- metadata +35 -45
- data/app/views/geopolitical/cities/_city.html.haml +0 -4
- data/app/views/geopolitical/cities/_form.html.haml +0 -28
- data/app/views/geopolitical/cities/edit.html.haml +0 -1
- data/app/views/geopolitical/cities/index.html.haml +0 -32
- data/app/views/geopolitical/cities/new.html.haml +0 -1
- data/app/views/geopolitical/cities/show.html.haml +0 -17
- data/app/views/geopolitical/geopolitical/edit.html.haml +0 -1
- data/app/views/geopolitical/geopolitical/index.html.haml +0 -19
- data/app/views/geopolitical/geopolitical/new.html.haml +0 -1
- data/app/views/geopolitical/hoods/_city.html.haml +0 -4
- data/app/views/geopolitical/hoods/_form.html.haml +0 -26
- data/app/views/geopolitical/hoods/edit.html.haml +0 -1
- data/app/views/geopolitical/hoods/index.html.haml +0 -28
- data/app/views/geopolitical/hoods/new.html.haml +0 -1
- data/app/views/geopolitical/hoods/show.html.haml +0 -14
- data/app/views/geopolitical/nations/_form.html.haml +0 -29
- data/app/views/geopolitical/nations/_nation.html.haml +0 -5
- data/app/views/geopolitical/nations/edit.html.haml +0 -1
- data/app/views/geopolitical/nations/index.html.haml +0 -32
- data/app/views/geopolitical/nations/new.html.haml +0 -1
- data/app/views/geopolitical/nations/show.html.haml +0 -20
- data/app/views/geopolitical/regions/_form.html.haml +0 -26
- data/app/views/geopolitical/regions/_region.html.haml +0 -4
- data/app/views/geopolitical/regions/edit.html.haml +0 -1
- data/app/views/geopolitical/regions/index.html.haml +0 -28
- data/app/views/geopolitical/regions/new.html.haml +0 -1
- data/app/views/geopolitical/regions/show.html.haml +0 -17
- data/app/views/layouts/geopolitical.html.haml +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfe29900897a18f20389e3bcc7107c7934acf4a482594d62cd203d16218baacf
|
|
4
|
+
data.tar.gz: 7502b7cb5e3b66461e849cbb4eac85ac20e05e7e4c77f05d30067363dd99670c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
18
|
+
field :area, type: Integer
|
|
19
|
+
|
|
19
20
|
# @!attribute [rw] geom
|
|
20
|
-
# @return [Point]
|
|
21
|
-
#
|
|
22
|
-
field :geom,
|
|
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,
|
|
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 })
|
|
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
|
|
81
|
-
# can be)
|
|
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,
|
|
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
|
-
|
|
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,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,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' %>
|