geopolitical 3.2.1 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +41 -0
  4. data/app/models/city.rb +8 -10
  5. data/app/models/geopolitical/postal/brasilapi.rb +39 -0
  6. data/app/models/geopolitical/postal/geoapify.rb +39 -0
  7. data/app/models/geopolitical/postal/google.rb +64 -0
  8. data/app/models/geopolitical/postal.rb +105 -0
  9. data/app/views/geopolitical/cities/_city.html.erb +6 -0
  10. data/app/views/geopolitical/cities/_form.html.erb +42 -0
  11. data/app/views/geopolitical/cities/edit.html.erb +1 -0
  12. data/app/views/geopolitical/cities/index.html.erb +46 -0
  13. data/app/views/geopolitical/cities/new.html.erb +1 -0
  14. data/app/views/geopolitical/cities/show.html.erb +24 -0
  15. data/app/views/geopolitical/geopolitical/edit.html.erb +1 -0
  16. data/app/views/geopolitical/geopolitical/index.html.erb +24 -0
  17. data/app/views/geopolitical/geopolitical/new.html.erb +1 -0
  18. data/app/views/geopolitical/hoods/_city.html.erb +6 -0
  19. data/app/views/geopolitical/hoods/_form.html.erb +40 -0
  20. data/app/views/geopolitical/hoods/edit.html.erb +1 -0
  21. data/app/views/geopolitical/hoods/index.html.erb +42 -0
  22. data/app/views/geopolitical/hoods/new.html.erb +1 -0
  23. data/app/views/geopolitical/hoods/show.html.erb +20 -0
  24. data/app/views/geopolitical/nations/_form.html.erb +44 -0
  25. data/app/views/geopolitical/nations/_nation.html.erb +7 -0
  26. data/app/views/geopolitical/nations/edit.html.erb +1 -0
  27. data/app/views/geopolitical/nations/index.html.erb +46 -0
  28. data/app/views/geopolitical/nations/new.html.erb +1 -0
  29. data/app/views/geopolitical/nations/show.html.erb +28 -0
  30. data/app/views/geopolitical/regions/_form.html.erb +40 -0
  31. data/app/views/geopolitical/regions/_region.html.erb +6 -0
  32. data/app/views/geopolitical/regions/edit.html.erb +1 -0
  33. data/app/views/geopolitical/regions/index.html.erb +42 -0
  34. data/app/views/geopolitical/regions/new.html.erb +1 -0
  35. data/app/views/geopolitical/regions/show.html.erb +24 -0
  36. data/app/views/layouts/geopolitical.html.erb +25 -0
  37. data/geopolitical.gemspec +2 -3
  38. data/lib/geopolitical/engine.rb +0 -1
  39. data/lib/geopolitical/version.rb +1 -1
  40. data/lib/geopolitical.rb +13 -0
  41. metadata +35 -45
  42. data/app/views/geopolitical/cities/_city.html.haml +0 -4
  43. data/app/views/geopolitical/cities/_form.html.haml +0 -28
  44. data/app/views/geopolitical/cities/edit.html.haml +0 -1
  45. data/app/views/geopolitical/cities/index.html.haml +0 -32
  46. data/app/views/geopolitical/cities/new.html.haml +0 -1
  47. data/app/views/geopolitical/cities/show.html.haml +0 -17
  48. data/app/views/geopolitical/geopolitical/edit.html.haml +0 -1
  49. data/app/views/geopolitical/geopolitical/index.html.haml +0 -19
  50. data/app/views/geopolitical/geopolitical/new.html.haml +0 -1
  51. data/app/views/geopolitical/hoods/_city.html.haml +0 -4
  52. data/app/views/geopolitical/hoods/_form.html.haml +0 -26
  53. data/app/views/geopolitical/hoods/edit.html.haml +0 -1
  54. data/app/views/geopolitical/hoods/index.html.haml +0 -28
  55. data/app/views/geopolitical/hoods/new.html.haml +0 -1
  56. data/app/views/geopolitical/hoods/show.html.haml +0 -14
  57. data/app/views/geopolitical/nations/_form.html.haml +0 -29
  58. data/app/views/geopolitical/nations/_nation.html.haml +0 -5
  59. data/app/views/geopolitical/nations/edit.html.haml +0 -1
  60. data/app/views/geopolitical/nations/index.html.haml +0 -32
  61. data/app/views/geopolitical/nations/new.html.haml +0 -1
  62. data/app/views/geopolitical/nations/show.html.haml +0 -20
  63. data/app/views/geopolitical/regions/_form.html.haml +0 -26
  64. data/app/views/geopolitical/regions/_region.html.haml +0 -4
  65. data/app/views/geopolitical/regions/edit.html.haml +0 -1
  66. data/app/views/geopolitical/regions/index.html.haml +0 -28
  67. data/app/views/geopolitical/regions/new.html.haml +0 -1
  68. data/app/views/geopolitical/regions/show.html.haml +0 -17
  69. data/app/views/layouts/geopolitical.html.haml +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29f0c0d286b916e4998c96f873a2ca71309cebdb24b5237d0d2a9983fb6a0b78
4
- data.tar.gz: dc3f5db9a230f9ce51716fd93d41b47f36435bc8a319028399d9980dacd74616
3
+ metadata.gz: bfe29900897a18f20389e3bcc7107c7934acf4a482594d62cd203d16218baacf
4
+ data.tar.gz: 7502b7cb5e3b66461e849cbb4eac85ac20e05e7e4c77f05d30067363dd99670c
5
5
  SHA512:
6
- metadata.gz: 5d5bf693346187061b119960d60de605a1eca32b8a56fd29d9b18273292d826c99b8c06d778b070db3a6dcbc0966263626aa5cfe64de35cee09f0573438a58ed
7
- data.tar.gz: 547960a20a10bdba9e01af25e1ed8c9809c2a7f5e25330ff3cea774fc051a2f00380dc574eaff71ad4fde17b95a4a70f6f1be4203e33069f93e6c366da3d578d
6
+ metadata.gz: 7d878011e11bc16a92e2709938930d3a52bb6acabd4735ddcd393c150cd8c179b594cc0b497db50f37d556414f77f59ee235db6e519e41c3fb6aab6bf1c9f689
7
+ data.tar.gz: b6fa32e93cd018cf6487c5930b3e22dcd9ca4eb6c89a5c970c8824f56cb3050c55370c7eb3950d07027166f39c6b958440a32950497ea4b6629d008d582a209f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 3.2.1
4
15
 
5
16
  - Every uniqueness validation now has a unique index behind it, so concurrent writes can't slip a duplicate past the check:
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.
@@ -62,10 +61,9 @@ class City
62
61
  index({ slug: 1 }, unique: true)
63
62
  index({ name: 1, nation_id: 1 }) # For lookups by name within a nation
64
63
  index({ nation_id: 1, region_id: 1, name: 1 }, { unique: true }) # Backs the name uniqueness validation
65
- index({ nation_id: 1 }) # For finding all cities in a nation
64
+ index({ nation_id: 1 }) # For finding all cities in a nation
66
65
  index({ region_id: 1 }, sparse: true) # For finding cities in a region, sparse if region is optional
67
66
  index({ souls: -1 }) # For sorting by population count
68
- index({ geom: '2dsphere' }) # Explicit 2dsphere index for geospatial queries
69
67
 
70
68
  # Validates that if a city is associated with a region, that region
71
69
  # belongs to the same nation as the city.
@@ -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
@@ -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' %>
@@ -0,0 +1,42 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <div class="page-header">
4
+ <h1 class="title"><%= Hood.model_name.human(count: 2) %></h1>
5
+ <%= link_to t('geopolitical.actions.new', model: Hood.model_name.human), new_hood_path %>
6
+ </div>
7
+ </div>
8
+ <div class="row">
9
+ <div class="content">
10
+ <table class="table">
11
+ <thead>
12
+ <tr>
13
+ <th><%= Hood.human_attribute_name(:name) %></th>
14
+ <th><%= Hood.human_attribute_name(:city_id) %></th>
15
+ <th><%= Hood.human_attribute_name(:souls) %></th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @hoods.each do |hood| %>
21
+ <tr>
22
+ <td>
23
+ <%= hood.name %>
24
+ <%= link_to hood.slug, hood_path(hood) %>
25
+ </td>
26
+ <td><%= hood.city&.name %></td>
27
+ <td><%= hood.souls %></td>
28
+ <td align="right">
29
+ <%= link_to t('geopolitical.actions.edit'), edit_hood_path(hood) %>
30
+ <%= button_to t('geopolitical.actions.destroy'), hood_path(hood), method: :delete,
31
+ data: { turbo_confirm: t('geopolitical.actions.confirm_destroy', model: Hood.model_name.human) } %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </tbody>
36
+ </table>
37
+ </div>
38
+ </div>
39
+ <div class="row">
40
+ <%= link_to t('geopolitical.actions.back'), root_path %>
41
+ </div>
42
+ </div>
@@ -0,0 +1 @@
1
+ <%= render 'form' %>
@@ -0,0 +1,20 @@
1
+ <div class="container">
2
+ <div class="row">
3
+ <h1><%= @hood.name %></h1>
4
+ </div>
5
+ <div class="row">
6
+ <p>
7
+ <b><%= Hood.human_attribute_name(:slug) %>:</b>
8
+ <%= @hood.slug %>
9
+ </p>
10
+ <p>
11
+ <b><%= Hood.human_attribute_name(:city_id) %>:</b>
12
+ <%= link_to @hood.city, city_path(@hood.city) if @hood.city %>
13
+ </p>
14
+ </div>
15
+ <div class="row">
16
+ <%= link_to t('geopolitical.actions.edit'), edit_hood_path(@hood) %>
17
+ |
18
+ <%= link_to t('geopolitical.actions.back'), hoods_path %>
19
+ </div>
20
+ </div>