geopolitical 2.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/CHANGELOG.md +28 -0
- data/README.md +152 -73
- data/app/controllers/geopolitical/application_controller.rb +8 -0
- data/app/controllers/geopolitical/cities_controller.rb +44 -10
- data/app/controllers/geopolitical/geopolitical_controller.rb +11 -9
- data/app/controllers/geopolitical/hoods_controller.rb +47 -9
- data/app/controllers/geopolitical/nations_controller.rb +45 -7
- data/app/controllers/geopolitical/regions_controller.rb +47 -7
- data/app/helpers/geopolitical/application_helper.rb +2 -0
- data/app/models/city.rb +149 -48
- data/app/models/concerns/geopolitocracy.rb +121 -34
- data/app/models/hood.rb +44 -18
- data/app/models/nation.rb +96 -17
- data/app/models/region.rb +42 -14
- data/app/views/geopolitical/cities/_city.html.haml +2 -5
- data/app/views/geopolitical/cities/_form.html.haml +25 -28
- data/app/views/geopolitical/cities/edit.html.haml +1 -0
- data/app/views/geopolitical/cities/index.html.haml +17 -20
- data/app/views/geopolitical/cities/new.html.haml +1 -0
- data/app/views/geopolitical/cities/show.html.haml +7 -7
- data/app/views/geopolitical/geopolitical/index.html.haml +7 -9
- data/app/views/geopolitical/hoods/_city.html.haml +2 -5
- data/app/views/geopolitical/hoods/_form.html.haml +21 -22
- data/app/views/geopolitical/hoods/edit.html.haml +1 -0
- data/app/views/geopolitical/hoods/index.html.haml +13 -18
- data/app/views/geopolitical/hoods/new.html.haml +1 -0
- data/app/views/geopolitical/hoods/show.html.haml +5 -5
- data/app/views/geopolitical/nations/_form.html.haml +11 -8
- data/app/views/geopolitical/nations/_nation.html.haml +5 -0
- data/app/views/geopolitical/nations/edit.html.haml +1 -0
- data/app/views/geopolitical/nations/index.html.haml +16 -20
- data/app/views/geopolitical/nations/new.html.haml +1 -0
- data/app/views/geopolitical/nations/show.html.haml +7 -7
- data/app/views/geopolitical/regions/_form.html.haml +10 -8
- data/app/views/geopolitical/regions/_region.html.haml +2 -5
- data/app/views/geopolitical/regions/edit.html.haml +1 -0
- data/app/views/geopolitical/regions/index.html.haml +12 -15
- data/app/views/geopolitical/regions/new.html.haml +1 -0
- data/app/views/geopolitical/regions/show.html.haml +5 -5
- data/app/views/layouts/geopolitical.html.haml +13 -15
- data/config/locales/geopolitical.en.yml +76 -0
- data/config/locales/geopolitical.pt.yml +77 -0
- data/geopolitical.gemspec +14 -12
- data/lib/geopolitical/engine.rb +3 -0
- data/lib/geopolitical/version.rb +3 -2
- data/lib/geopolitical.rb +23 -3
- metadata +42 -118
- data/.coveralls.yml +0 -1
- data/.gitignore +0 -11
- data/.rspec +0 -1
- data/.travis.yml +0 -26
- data/Gemfile +0 -15
- data/Guardfile +0 -24
- data/Rakefile +0 -25
- data/app/serializers/city_serializer.rb +0 -8
- data/app/serializers/nation_serializer.rb +0 -4
- data/app/serializers/region_serializer.rb +0 -4
- data/bin/rails +0 -8
- data/lib/tasks/geopolitical_tasks.rake +0 -4
- data/spec/dummy/README.rdoc +0 -28
- data/spec/dummy/Rakefile +0 -6
- data/spec/dummy/app/assets/images/.keep +0 -0
- data/spec/dummy/app/assets/javascripts/application.js +0 -13
- data/spec/dummy/app/assets/stylesheets/application.css +0 -13
- data/spec/dummy/app/controllers/concerns/.keep +0 -0
- data/spec/dummy/app/helpers/application_helper.rb +0 -2
- data/spec/dummy/app/mailers/.keep +0 -0
- data/spec/dummy/app/models/.keep +0 -0
- data/spec/dummy/app/models/concerns/.keep +0 -0
- data/spec/dummy/app/views/layouts/application.html.erb +0 -14
- data/spec/dummy/bin/bundle +0 -3
- data/spec/dummy/bin/rails +0 -4
- data/spec/dummy/bin/rake +0 -4
- data/spec/dummy/config/application.rb +0 -27
- data/spec/dummy/config/boot.rb +0 -5
- data/spec/dummy/config/environment.rb +0 -5
- data/spec/dummy/config/environments/development.rb +0 -29
- data/spec/dummy/config/environments/production.rb +0 -80
- data/spec/dummy/config/environments/test.rb +0 -36
- data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/spec/dummy/config/initializers/inflections.rb +0 -16
- data/spec/dummy/config/initializers/mime_types.rb +0 -5
- data/spec/dummy/config/initializers/secret_token.rb +0 -12
- data/spec/dummy/config/initializers/session_store.rb +0 -3
- data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/spec/dummy/config/locales/en.yml +0 -23
- data/spec/dummy/config/mongoid.yml +0 -63
- data/spec/dummy/config/routes.rb +0 -56
- data/spec/dummy/config.ru +0 -4
- data/spec/dummy/lib/assets/.keep +0 -0
- data/spec/dummy/log/.keep +0 -0
- data/spec/dummy/log/development.log +0 -0
- data/spec/dummy/log/test.log +0 -23532
- data/spec/dummy/public/404.html +0 -58
- data/spec/dummy/public/422.html +0 -58
- data/spec/dummy/public/500.html +0 -57
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/fabricators/city_fabricator.rb +0 -6
- data/spec/fabricators/hood_fabricator.rb +0 -5
- data/spec/fabricators/nation_fabricator.rb +0 -4
- data/spec/fabricators/region_fabricator.rb +0 -5
- data/spec/models/city_spec.rb +0 -158
- data/spec/models/hood_spec.rb +0 -47
- data/spec/models/nation_spec.rb +0 -53
- data/spec/models/region_spec.rb +0 -20
- data/spec/spec_helper.rb +0 -57
data/app/models/city.rb
CHANGED
|
@@ -1,86 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Represents a city within a nation and, optionally, a region.
|
|
1
4
|
#
|
|
2
|
-
# Cities
|
|
5
|
+
# Cities have a name, geographical location (`geom`), area, and can be associated
|
|
6
|
+
# with neighborhoods (`Hood`). They also inherit common geopolitical attributes
|
|
7
|
+
# from the `Geopolitocracy` concern (like `name`, `abbr`, `slug`, `pop`, `phone`, `postal`).
|
|
3
8
|
#
|
|
9
|
+
# The city's `slug` is automatically generated and suffixed with its region's
|
|
10
|
+
# abbreviation to ensure uniqueness across regions.
|
|
4
11
|
class City
|
|
5
12
|
include Mongoid::Document
|
|
6
|
-
include Mongoid::Geospatial
|
|
13
|
+
include Mongoid::Geospatial # For GIS capabilities like `geom` field and spatial queries
|
|
7
14
|
include Geopolitocracy
|
|
8
15
|
|
|
9
|
-
|
|
16
|
+
# @!attribute [rw] area
|
|
17
|
+
# @return [Integer] The square area of the city in square meters (m2).
|
|
18
|
+
field :area, type: Integer
|
|
19
|
+
# @!attribute [rw] geom
|
|
20
|
+
# @return [Point] The geographical coordinates (longitude, latitude) of the city.
|
|
21
|
+
# Indexed for spatial queries.
|
|
10
22
|
field :geom, type: Point, spatial: true
|
|
11
|
-
#
|
|
23
|
+
# @!attribute [rw] rbbr
|
|
24
|
+
# @return [String] Cached abbreviation of the city's region.
|
|
25
|
+
# Used internally, primarily to construct unique slugs.
|
|
26
|
+
# Alias: `region_abbr` (writer only, getter is custom).
|
|
27
|
+
field :rbbr, type: String, as: :region_abbr
|
|
12
28
|
|
|
29
|
+
# Enables spatial queries on the `geom` field.
|
|
13
30
|
spatial_scope :geom
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
# @!attribute [rw] region
|
|
33
|
+
# @return [Region, nil] The region this city belongs to. Optional.
|
|
17
34
|
belongs_to :region, inverse_of: :cities, optional: true
|
|
35
|
+
# @!attribute [rw] nation
|
|
36
|
+
# @return [Nation] The nation this city belongs to. Required.
|
|
18
37
|
belongs_to :nation, inverse_of: :cities
|
|
19
|
-
has_many :hoods
|
|
20
|
-
|
|
21
|
-
has_one :nation_governancy, as: :nation_capital, class_name: 'Nation'
|
|
22
|
-
has_one :region_governancy, as: :region_capital, class_name: 'Region'
|
|
23
|
-
|
|
24
|
-
before_validation :set_defaults, on: [:create]
|
|
25
|
-
|
|
26
|
-
validates :name, uniqueness: { scope: :region_id }
|
|
27
|
-
validate :region_inside_nation
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
# @!attribute [rw] hoods
|
|
40
|
+
# @return [Mongoid::Relations::Targets::Enumerable<Hood>] Neighborhoods within this city.
|
|
41
|
+
has_many :hoods, dependent: :destroy # Assuming hoods should be destroyed if city is
|
|
42
|
+
|
|
43
|
+
# @!attribute [r] nation_governancy
|
|
44
|
+
# @return [Nation, nil] The nation for which this city is the capital.
|
|
45
|
+
# This association signifies that the city is a national capital.
|
|
46
|
+
has_one :nation_governancy, class_name: 'Nation', inverse_of: :capital, foreign_key: :capital_id
|
|
47
|
+
# @!attribute [r] region_governancy
|
|
48
|
+
# @return [Region, nil] The region for which this city is the capital.
|
|
49
|
+
# This association signifies that the city is a regional capital.
|
|
50
|
+
has_one :region_governancy, class_name: 'Region', inverse_of: :capital, foreign_key: :capital_id
|
|
51
|
+
|
|
52
|
+
before_validation :ensure_derived_fields_and_slug
|
|
53
|
+
|
|
54
|
+
validates :name, uniqueness: { scope: %i[nation_id region_id], message: 'must be unique within its region' }
|
|
55
|
+
validates :nation, presence: true
|
|
56
|
+
validate :region_inside_nation_if_region_present
|
|
57
|
+
|
|
58
|
+
# @!method self.population
|
|
59
|
+
# @return [Mongoid::Criteria] Cities ordered by population in descending order.
|
|
60
|
+
scope :population, -> { order_by(souls: :desc) }
|
|
61
|
+
|
|
62
|
+
index({ slug: 1 }, unique: true)
|
|
63
|
+
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
|
|
65
|
+
index({ region_id: 1 }, sparse: true) # For finding cities in a region, sparse if region is optional
|
|
66
|
+
index({ souls: -1 }) # For sorting by population count
|
|
67
|
+
index({ geom: '2dsphere' }) # Explicit 2dsphere index for geospatial queries
|
|
68
|
+
|
|
69
|
+
# Validates that if a city is associated with a region, that region
|
|
70
|
+
# belongs to the same nation as the city.
|
|
71
|
+
def region_inside_nation_if_region_present
|
|
72
|
+
return unless region.present? # Only validate if region is set
|
|
73
|
+
return if nation.nil? # Avoid error if nation is not yet set (covered by presence validation)
|
|
74
|
+
return if region.nation == nation
|
|
75
|
+
|
|
76
|
+
errors.add(
|
|
77
|
+
:region,
|
|
78
|
+
'must be within the same nation as the city. ' \
|
|
79
|
+
"Region's nation: #{region.nation&.abbr}, City's nation: #{nation.abbr}."
|
|
80
|
+
)
|
|
81
|
+
end
|
|
35
82
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
83
|
+
# Gets the region's abbreviation (`rbbr`).
|
|
84
|
+
# If `rbbr` is not set and the city has a region, it populates `rbbr`
|
|
85
|
+
# with the region's abbreviation or name. This value is then cached on the city.
|
|
86
|
+
#
|
|
87
|
+
# @return [String, nil] The region's abbreviation or name, or nil if not determinable.
|
|
88
|
+
def region_abbr
|
|
89
|
+
current_rbbr = read_attribute(:rbbr)
|
|
90
|
+
if current_rbbr.blank? && region.present?
|
|
91
|
+
new_rbbr = region.abbr.presence || region.name.presence
|
|
92
|
+
write_attribute(:rbbr, new_rbbr) if new_rbbr.present?
|
|
93
|
+
return new_rbbr
|
|
94
|
+
end
|
|
95
|
+
current_rbbr
|
|
39
96
|
end
|
|
40
97
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
98
|
+
# Callback executed before validation to:
|
|
99
|
+
# 1. Derive the `nation` from the `region` if `nation` is not set and `region` is.
|
|
100
|
+
# 2. Ensure `rbbr` (region abbreviation) is populated if possible, and refreshed if the city moved.
|
|
101
|
+
# 3. Append the (lowercase) region abbreviation to the `slug` (generated by `Geopolitocracy`)
|
|
102
|
+
# to ensure city slugs are unique, e.g., "cityname-regionabbr" — or the nation
|
|
103
|
+
# abbreviation when the city has no region, e.g. "singapore-sg".
|
|
104
|
+
def ensure_derived_fields_and_slug
|
|
105
|
+
# 1. Derive nation from region if possible and not already set
|
|
106
|
+
self.nation = region.nation if region.present? && nation.blank?
|
|
107
|
+
|
|
108
|
+
# 2. A moved city gets a fresh abbr cache and a slug rebuilt from its name,
|
|
109
|
+
# otherwise the old suffix would stick around ("santos-sp-rj").
|
|
110
|
+
if persisted? && (region_id_changed? || nation_id_changed?)
|
|
111
|
+
write_attribute(:rbbr, nil)
|
|
112
|
+
self.slug = name
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 3. Suffix the slug so it is unique planet-wide: the region abbr where there
|
|
116
|
+
# is a region ("santos-sp"), the nation abbr otherwise ("singapore-sg").
|
|
117
|
+
suffix = region_abbr.presence || nation&.abbr
|
|
118
|
+
return if suffix.blank? || slug.blank?
|
|
119
|
+
|
|
120
|
+
slug_suffix = "-#{Geopolitocracy.slugify(suffix)}"
|
|
121
|
+
self.slug += slug_suffix unless slug.end_with?(slug_suffix)
|
|
47
122
|
end
|
|
48
123
|
|
|
124
|
+
# Retrieves the phone number for the city.
|
|
125
|
+
# Falls back to the region's phone, then the nation's phone if the city's is not set.
|
|
126
|
+
# Assumes `phone` field is provided by `Geopolitocracy`.
|
|
127
|
+
#
|
|
128
|
+
# @return [String, nil] The phone number.
|
|
49
129
|
def phone
|
|
50
|
-
self[:phone] || region
|
|
130
|
+
self[:phone] || region&.phone || nation&.phone
|
|
51
131
|
end
|
|
52
132
|
|
|
133
|
+
# Collects unique, non-nil phone numbers from all associated hoods.
|
|
134
|
+
# Assumes `phone` field is provided by `Geopolitocracy` on Hood model.
|
|
135
|
+
#
|
|
136
|
+
# @return [Array<String>] An array of unique phone numbers.
|
|
53
137
|
def phones
|
|
54
|
-
hoods.map(&:phone)
|
|
138
|
+
hoods.map(&:phone).compact.uniq
|
|
55
139
|
end
|
|
56
140
|
|
|
141
|
+
# Retrieves the postal code for the city.
|
|
142
|
+
# Falls back to the region's postal code, then the nation's if the city's is not set.
|
|
143
|
+
# Assumes `postal` field is provided by `Geopolitocracy`.
|
|
144
|
+
#
|
|
145
|
+
# @return [String, nil] The postal code.
|
|
57
146
|
def postal
|
|
58
|
-
self[:postal] || region
|
|
147
|
+
self[:postal] || region&.postal || nation&.postal
|
|
59
148
|
end
|
|
60
149
|
|
|
150
|
+
# Collects unique, non-nil postal codes from all associated hoods.
|
|
151
|
+
# Assumes `postal` field is provided by `Geopolitocracy` on Hood model.
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<String>] An array of unique postal codes.
|
|
61
154
|
def postals
|
|
62
|
-
hoods.map(&:postal)
|
|
155
|
+
hoods.map(&:postal).compact.uniq
|
|
63
156
|
end
|
|
64
157
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
end
|
|
158
|
+
# Returns the city's name, optionally suffixed with its region's abbreviation.
|
|
159
|
+
#
|
|
160
|
+
# @param separator [String] The string to use between the name and region abbreviation.
|
|
161
|
+
# @return [String] The formatted name.
|
|
162
|
+
def with_region(separator = '/')
|
|
163
|
+
# Use the getter for region_abbr to ensure it's loaded if available
|
|
164
|
+
current_region_abbr = region_abbr
|
|
165
|
+
return name unless current_region_abbr.present?
|
|
74
166
|
|
|
75
|
-
|
|
76
|
-
return name unless region
|
|
77
|
-
"#{name}/#{region.abbr || region.name}"
|
|
167
|
+
"#{name}#{separator}#{current_region_abbr}"
|
|
78
168
|
end
|
|
79
169
|
|
|
80
|
-
|
|
81
|
-
|
|
170
|
+
# Returns the city's name, suffixed with its region's and nation's abbreviations.
|
|
171
|
+
# e.g., "CityName/RegionAbbr/NationAbbr"
|
|
172
|
+
#
|
|
173
|
+
# @param separator [String] The string to use as a separator.
|
|
174
|
+
# @return [String] The formatted name string.
|
|
175
|
+
def with_nation(separator = '/')
|
|
176
|
+
base = with_region(separator) # Relies on with_region using the (potentially now loaded) region_abbr
|
|
177
|
+
# Ensure nation and nation.abbr are present before appending
|
|
178
|
+
base += "#{separator}#{nation.abbr}" if nation&.abbr.present?
|
|
179
|
+
base
|
|
82
180
|
end
|
|
83
181
|
|
|
182
|
+
# Default string representation of the city.
|
|
183
|
+
#
|
|
184
|
+
# @return [String] The city name, possibly with its region abbreviation.
|
|
84
185
|
def to_s
|
|
85
186
|
with_region
|
|
86
187
|
end
|
|
@@ -1,61 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Provides common fields, validations, and helper methods for geopolitical entities
|
|
5
|
+
# like Nations, Regions, Cities, and Hoods.
|
|
6
|
+
#
|
|
7
|
+
# NOTE: A Geonames ID is used as ID
|
|
8
|
+
#
|
|
9
|
+
# It includes fields for names, abbreviations, population, codes, and contact details.
|
|
10
|
+
# It also handles automatic slug generation and provides a basic search functionality.
|
|
11
|
+
#
|
|
2
12
|
module Geopolitocracy
|
|
3
13
|
extend ActiveSupport::Concern
|
|
4
14
|
|
|
15
|
+
# Turns any name into a URL-friendly slug: "São Paulo" => "sao-paulo".
|
|
16
|
+
# Latin script is transliterated to ASCII (accents stripped via NFKD, then
|
|
17
|
+
# æ/ø/ß/đ via I18n); other scripts (日本, Москва, دبي) keep their letters so
|
|
18
|
+
# the slug is never blank: "東京" => "東京", "Санкт-Петербург" => "санкт-петербург".
|
|
19
|
+
# @param text [String]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def self.slugify(text)
|
|
22
|
+
# NFC first: decomposed input (macOS filenames, some form posts) would otherwise
|
|
23
|
+
# split a Latin run on its combining marks — "São" => "sa-o".
|
|
24
|
+
latin = text.to_s.unicode_normalize(:nfc).delete('.').gsub(/\p{Latin}+/) do |word|
|
|
25
|
+
ActiveSupport::Inflector.transliterate(word.unicode_normalize(:nfkd).gsub(/\p{Mn}/, ''))
|
|
26
|
+
end
|
|
27
|
+
latin.gsub(/[^\p{Alnum}]+/, '-').gsub(/^-+|-+$/, '').downcase
|
|
28
|
+
end
|
|
29
|
+
|
|
5
30
|
included do
|
|
6
|
-
#
|
|
31
|
+
# @!group Fields
|
|
7
32
|
|
|
33
|
+
# @!attribute [rw] name
|
|
34
|
+
# @return [String] The primary name of the entity. Localized.
|
|
35
|
+
# Automatically titleized on assignment if it doesn't contain mixed-case characters.
|
|
8
36
|
field :name, type: String, localize: true
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
37
|
+
# @!attribute [rw] alt
|
|
38
|
+
# @return [String, nil] An alternative name or spelling for the entity. Localized.
|
|
39
|
+
field :alt, type: String, localize: true, default: nil
|
|
40
|
+
# @!attribute [rw] abbr
|
|
41
|
+
# @return [String, nil] A common abbreviation for the entity (e.g., "NY" for New York).
|
|
42
|
+
field :abbr, type: String, default: nil
|
|
43
|
+
# @!attribute [rw] nick
|
|
44
|
+
# @return [String, nil] A nickname or colloquial name for the entity.
|
|
45
|
+
field :nick, type: String, default: nil
|
|
13
46
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
field :
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
field :
|
|
47
|
+
# @!attribute [rw] souls
|
|
48
|
+
# @return [Integer, nil] The population count of the entity. Note: `pop` was taken by mongoid
|
|
49
|
+
field :souls, type: Integer, default: nil
|
|
50
|
+
# @!attribute [rw] ascii
|
|
51
|
+
# @return [String, nil] An ASCII-only representation of the name, useful for systems that don't support Unicode.
|
|
52
|
+
field :ascii, type: String, default: nil
|
|
53
|
+
# @!attribute [rw] code
|
|
54
|
+
# @return [String, nil] A code associated with the entity (e.g., FIPS code, ISO 3166-2 subdivision code).
|
|
55
|
+
# Its uniqueness might be scoped by a parent entity (e.g., nation_id) in the including class.
|
|
56
|
+
field :code, type: String, default: nil
|
|
57
|
+
# @!attribute [rw] slug
|
|
58
|
+
# @return [String] A URL-friendly slug, automatically generated from the name if not provided.
|
|
59
|
+
# Guaranteed to be present and unique across all documents including this concern.
|
|
60
|
+
field :slug, type: String
|
|
61
|
+
# @!attribute [rw] postal
|
|
62
|
+
# @return [String, nil] The primary postal code or prefix for the entity.
|
|
63
|
+
field :postal, type: String, default: nil
|
|
64
|
+
# @!attribute [rw] phone
|
|
65
|
+
# @return [String, nil] The primary phone dialing code for the entity.
|
|
66
|
+
field :phone, type: String, default: nil
|
|
20
67
|
|
|
68
|
+
# @!group Aliases
|
|
69
|
+
# @!attribute [rw] population # This alias might still be problematic if Mongoid defines a 'population' method.
|
|
70
|
+
# @see #souls # For now, keeping it as it was a direct alias to 'pop'.
|
|
21
71
|
alias_method :population, :souls
|
|
22
|
-
alias_method :
|
|
72
|
+
alias_method :population=, :souls=
|
|
23
73
|
|
|
74
|
+
# @!group Validations
|
|
24
75
|
validates :name, presence: true
|
|
25
|
-
validates :slug, presence: true
|
|
26
|
-
|
|
76
|
+
validates :slug, presence: true # Uniqueness will be handled by including models with appropriate scope
|
|
77
|
+
# @!endgroup
|
|
78
|
+
|
|
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.
|
|
82
|
+
index({ name: 1 }) # For sorting and lookups by name
|
|
83
|
+
index({ abbr: 1 }, { sparse: true }) # Sparse as abbr can be nil
|
|
84
|
+
index({ code: 1 }, { sparse: true }) # Sparse as code can be nil
|
|
85
|
+
# @!endgroup
|
|
27
86
|
|
|
28
|
-
|
|
29
|
-
index name: 1
|
|
87
|
+
before_validation :ensure_slug_is_generated
|
|
30
88
|
|
|
31
|
-
|
|
89
|
+
# @!group Scopes
|
|
90
|
+
# @!method self.ordered
|
|
91
|
+
# @return [Mongoid::Criteria] Documents ordered by name ascending.
|
|
92
|
+
scope :ordered, -> { order_by(name: :asc) }
|
|
93
|
+
# @!endgroup
|
|
32
94
|
|
|
33
|
-
|
|
95
|
+
# Ensures that a slug is generated from the name if the slug is currently blank.
|
|
96
|
+
# This method is called before validation.
|
|
97
|
+
# It uses the custom `slug=` setter which handles parameterization.
|
|
98
|
+
def ensure_slug_is_generated
|
|
99
|
+
return unless slug.blank? && name.present?
|
|
34
100
|
|
|
35
|
-
|
|
36
|
-
self.slug ||= name
|
|
101
|
+
self.slug = name # Will trigger the custom slug setter
|
|
37
102
|
end
|
|
38
103
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
104
|
+
# Custom writer for the name attribute.
|
|
105
|
+
# It titleizes the input string if it doesn't already contain mixed-case characters.
|
|
106
|
+
# @param new_name [String] The new name.
|
|
107
|
+
def name=(new_name)
|
|
108
|
+
if new_name.present? && new_name !~ /[A-Z][a-z]/
|
|
109
|
+
super(new_name.titleize)
|
|
110
|
+
else
|
|
111
|
+
super
|
|
112
|
+
end
|
|
43
113
|
end
|
|
44
114
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
115
|
+
# Custom writer for the slug attribute. See {Geopolitocracy.slugify}.
|
|
116
|
+
# @param new_slug_source [String] The string to be converted into a slug.
|
|
117
|
+
def slug=(new_slug_source)
|
|
118
|
+
super(new_slug_source.present? ? Geopolitocracy.slugify(new_slug_source) : nil)
|
|
49
119
|
end
|
|
50
120
|
|
|
121
|
+
# Default string representation of the entity.
|
|
122
|
+
# @return [String] The name of the entity, or its slug if the name is not set.
|
|
51
123
|
def to_s
|
|
52
|
-
name || slug
|
|
124
|
+
name.presence || slug.presence || ''
|
|
53
125
|
end
|
|
54
126
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
127
|
+
# @!group Class Methods
|
|
128
|
+
# Performs a search for entities where the slug starts with the given text (after parameterization).
|
|
129
|
+
#
|
|
130
|
+
# @param query_text [String] The text to search for.
|
|
131
|
+
# @param exact_match [Boolean] If true, searches for an exact match of the parameterized query.
|
|
132
|
+
# If false (default), searches for slugs starting with the parameterized query.
|
|
133
|
+
# @return [Mongoid::Criteria] A criteria object for fetching matching documents.
|
|
134
|
+
def self.search(query_text, exact: false)
|
|
135
|
+
return none if query_text.blank? # Return an empty criteria if query is blank
|
|
136
|
+
|
|
137
|
+
parameterized_query = Geopolitocracy.slugify(query_text)
|
|
138
|
+
return none if parameterized_query.blank?
|
|
139
|
+
|
|
140
|
+
if exact # matches the exact slug
|
|
141
|
+
where(slug: parameterized_query)
|
|
142
|
+
else
|
|
143
|
+
where(slug: /^#{Regexp.escape(parameterized_query)}/)
|
|
144
|
+
end
|
|
59
145
|
end
|
|
146
|
+
# @!endgroup
|
|
60
147
|
end
|
|
61
148
|
end
|
data/app/models/hood.rb
CHANGED
|
@@ -1,35 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Represents a neighborhood (hood) within a city.
|
|
1
4
|
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
5
|
+
# Hoods belong to a city and inherit common geopolitical attributes
|
|
6
|
+
# from the `Geopolitocracy` concern (like `name`, `abbr`, `slug`, `pop`, `phone`, `postal`).
|
|
7
|
+
# The slug for a hood is typically a combination of the city's slug and the hood's name.
|
|
4
8
|
class Hood
|
|
5
9
|
include Mongoid::Document
|
|
6
|
-
include Geopolitocracy
|
|
10
|
+
include Geopolitocracy # Provides common geopolitical fields like name, abbr, slug, etc.
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
# @!attribute [rw] rank
|
|
13
|
+
# @return [Integer, nil] An optional ranking for the neighborhood, if applicable.
|
|
14
|
+
field :rank, type: Integer
|
|
9
15
|
|
|
16
|
+
# @!attribute [rw] city
|
|
17
|
+
# @return [City] The city this neighborhood belongs to. Required.
|
|
10
18
|
belongs_to :city
|
|
11
19
|
|
|
12
20
|
validates :city, presence: true
|
|
13
|
-
validates :name, uniqueness: { scope: :city_id }
|
|
21
|
+
validates :name, uniqueness: { scope: :city_id, message: 'must be unique within its city' }
|
|
22
|
+
# `abbr` (from Geopolitocracy) could also be validated for uniqueness within the city if used.
|
|
23
|
+
# validates :abbr,
|
|
24
|
+
# uniqueness: { scope: :city_id, allow_nil: true, message: "must be unique within its city if provided" }
|
|
25
|
+
|
|
26
|
+
index({ city_id: 1, name: 1 }, { unique: true })
|
|
27
|
+
# index({ city_id: 1, abbr: 1 }, { unique: true, sparse: true }) # If abbr is used and needs to be unique
|
|
28
|
+
index({ slug: 1 }, { unique: true }) # Hood slugs are globally unique due to city_slug prefix
|
|
29
|
+
|
|
30
|
+
before_validation :ensure_slug_for_hood
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
# Slug is "#{city.slug}-#{name slug}"; prepends the city slug if a bare one was
|
|
33
|
+
# given, and is rebuilt when the hood is renamed or moved to another city.
|
|
34
|
+
def ensure_slug_for_hood
|
|
35
|
+
return if city.blank?
|
|
36
|
+
|
|
37
|
+
self.slug = name if persisted? && (name_changed? || city_id_changed?)
|
|
38
|
+
return if slug.blank?
|
|
39
|
+
|
|
40
|
+
prefix = "#{city.slug}-"
|
|
41
|
+
self.slug = "#{prefix}#{slug}" unless slug.start_with?(prefix)
|
|
18
42
|
end
|
|
19
43
|
|
|
44
|
+
# Retrieves the phone dialing code for the neighborhood.
|
|
45
|
+
# Falls back to the city's phone code if the neighborhood's is not set.
|
|
46
|
+
# Assumes `phone` field is provided by `Geopolitocracy`.
|
|
47
|
+
#
|
|
48
|
+
# @return [String, nil] The phone dialing code.
|
|
20
49
|
def phone
|
|
21
|
-
self[:phone] || city
|
|
50
|
+
self[:phone] || city&.phone
|
|
22
51
|
end
|
|
23
52
|
|
|
53
|
+
# Retrieves the primary postal code for the neighborhood.
|
|
54
|
+
# Falls back to the city's postal code if the neighborhood's is not set.
|
|
55
|
+
# Assumes `postal` field is provided by `Geopolitocracy`.
|
|
56
|
+
#
|
|
57
|
+
# @return [String, nil] The postal code.
|
|
24
58
|
def postal
|
|
25
|
-
self[:postal] || city
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def as_json(_opts = {})
|
|
29
|
-
{
|
|
30
|
-
id: id.to_s,
|
|
31
|
-
name: name,
|
|
32
|
-
city: city
|
|
33
|
-
}
|
|
59
|
+
self[:postal] || city&.postal
|
|
34
60
|
end
|
|
35
61
|
end
|
data/app/models/nation.rb
CHANGED
|
@@ -1,42 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Represents a nation or country.
|
|
1
5
|
#
|
|
2
|
-
#
|
|
6
|
+
# Each nation has a unique abbreviation (`abbr`) which also serves as its `_id`.
|
|
7
|
+
# It includes geopolitical information such as its Geonames ID, top-level domain,
|
|
8
|
+
# currency, ISO 3166-3 code, and languages.
|
|
3
9
|
#
|
|
10
|
+
# Nations can have a capital city, multiple regions, and multiple cities.
|
|
4
11
|
class Nation
|
|
5
12
|
include Mongoid::Document
|
|
6
13
|
include Geopolitocracy
|
|
7
14
|
|
|
8
|
-
|
|
15
|
+
# The unique identifier for the nation, defaults to its abbreviation.
|
|
16
|
+
# The `abbr` is expected to be provided by the Geopolitocracy concern or set explicitly.
|
|
17
|
+
field :_id, type: String, default: -> { abbr }, overwrite: true
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
field :
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
field :
|
|
19
|
+
# @!attribute [rw] gid
|
|
20
|
+
# @return [Integer] The Geonames identifier for the nation.
|
|
21
|
+
field :gid, type: Integer
|
|
22
|
+
# @!attribute [rw] tld
|
|
23
|
+
# @return [String] The top-level domain (e.g., ".us", ".de").
|
|
24
|
+
field :tld, type: String
|
|
25
|
+
# @!attribute [rw] cash
|
|
26
|
+
# @return [String] The currency symbol or code (e.g., "$", "EUR").
|
|
27
|
+
field :cash, type: String
|
|
28
|
+
# @!attribute [rw] code3
|
|
29
|
+
# @return [String] The ISO 3166-1 alpha-3 code for the country.
|
|
30
|
+
field :code3, type: String
|
|
31
|
+
# @!attribute [rw] langs
|
|
32
|
+
# @return [Array<String>] A list of all official language codes.
|
|
33
|
+
field :langs, type: Array, default: []
|
|
16
34
|
|
|
35
|
+
# Alias for the `cash` field.
|
|
36
|
+
# @return [String] The currency symbol or code.
|
|
17
37
|
alias currency cash
|
|
18
|
-
|
|
38
|
+
# Alias for the `cash=` writer.
|
|
39
|
+
alias currency= cash=
|
|
40
|
+
|
|
41
|
+
# Alias for the `code3` field, representing the ISO 3166-1 alpha-3 code.
|
|
42
|
+
# @return [String] The ISO 3166-1 alpha-3 code.
|
|
43
|
+
# alias iso_3166_3 code3
|
|
44
|
+
# # Alias for the `code3=` writer.
|
|
45
|
+
# alias iso_3166_3= code3=
|
|
19
46
|
|
|
20
|
-
validates :abbr,
|
|
47
|
+
validates :abbr, presence: true # Name presence is validated in Geopolitocracy
|
|
48
|
+
validates_uniqueness_of :abbr, case_sensitive: false
|
|
49
|
+
validates :slug, uniqueness: { message: 'must be unique' } # Slug presence is validated in Geopolitocracy
|
|
21
50
|
|
|
22
|
-
|
|
23
|
-
|
|
51
|
+
# @!attribute [rw] capital
|
|
52
|
+
# @return [City] The capital city of this nation.
|
|
53
|
+
belongs_to :capital, inverse_of: :nation_governancy, class_name: 'City', optional: true
|
|
24
54
|
|
|
55
|
+
# @!attribute [rw] regions
|
|
56
|
+
# @return [Mongoid::Relations::Targets::Enumerable<Region>] The regions within this nation.
|
|
25
57
|
has_many :regions, dependent: :destroy
|
|
58
|
+
# @!attribute [rw] cities
|
|
59
|
+
# @return [Mongoid::Relations::Targets::Enumerable<City>] The cities within this nation.
|
|
26
60
|
has_many :cities, dependent: :destroy
|
|
27
61
|
|
|
28
|
-
index
|
|
62
|
+
index({ name: 1 }) # Index for sorting by name, common operation
|
|
63
|
+
index({ slug: 1 }, { unique: true }) # Slugs must be globally unique for nations
|
|
64
|
+
|
|
65
|
+
# Nation['br'] => the Nation with abbr BR (the abbr is the _id), nil if unknown.
|
|
66
|
+
# @param abbr [String, Symbol]
|
|
67
|
+
# @return [Nation, nil]
|
|
68
|
+
def self.[](abbr) = where(_id: abbr.to_s.upcase).first
|
|
69
|
+
|
|
70
|
+
# A whimsical method indicating the planet.
|
|
71
|
+
# In a more complex system, this might point to a Planet model.
|
|
72
|
+
# @return [Symbol] Always returns `:earth`.
|
|
73
|
+
def planet
|
|
74
|
+
:earth # In a larger cosmological model, this could be `Planet.find_by(name: 'Earth')`
|
|
75
|
+
end
|
|
29
76
|
|
|
30
|
-
|
|
31
|
-
|
|
77
|
+
# Helper method to retrieve the primary language of the nation.
|
|
78
|
+
# This returns the first language in the `langs` array, if available.
|
|
79
|
+
#
|
|
80
|
+
# @return [String, nil] The primary language code or nil if no languages are set.
|
|
81
|
+
def lang
|
|
82
|
+
langs&.first
|
|
32
83
|
end
|
|
33
84
|
|
|
85
|
+
# Sets the primary language, keeping any other languages the nation speaks.
|
|
86
|
+
# @param value [String, nil]
|
|
87
|
+
def lang=(value)
|
|
88
|
+
rest = langs.to_a.drop(1)
|
|
89
|
+
self.langs = value.present? ? [value.to_s, *rest] : rest
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Sets the abbreviation for the nation.
|
|
93
|
+
# The abbreviation is automatically converted to uppercase.
|
|
94
|
+
#
|
|
95
|
+
# @param new_abbr [String] The new abbreviation.
|
|
96
|
+
# @return [String, nil] The uppercased abbreviation or nil if input was nil.
|
|
97
|
+
def abbr=(new_abbr)
|
|
98
|
+
super(new_abbr&.upcase)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Compares this nation with another object for equality.
|
|
102
|
+
# Nations are considered equal if their abbreviations are the same.
|
|
103
|
+
#
|
|
104
|
+
# @param other [Object] The object to compare with.
|
|
105
|
+
# @return [Boolean] True if the other object is a Nation and has the same abbreviation, false otherwise.
|
|
34
106
|
def ==(other)
|
|
35
|
-
|
|
36
|
-
abbr == other.abbr
|
|
107
|
+
other.is_a?(Nation) && abbr == other.abbr
|
|
37
108
|
end
|
|
38
109
|
|
|
110
|
+
# Compares this nation with another nation for sorting purposes.
|
|
111
|
+
# Comparison is based on the nation's name.
|
|
112
|
+
#
|
|
113
|
+
# @param other [Nation] The other nation to compare with.
|
|
114
|
+
# @return [-1, 0, 1] -1 if self.name < other.name, 0 if equal, 1 if self.name > other.name.
|
|
115
|
+
# @raise [ArgumentError] if other is not a Nation.
|
|
39
116
|
def <=>(other)
|
|
117
|
+
return nil unless other.is_a?(Nation) # Or raise ArgumentError?
|
|
118
|
+
|
|
40
119
|
name <=> other.name
|
|
41
120
|
end
|
|
42
121
|
end
|