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.
Files changed (108) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +152 -73
  4. data/app/controllers/geopolitical/application_controller.rb +8 -0
  5. data/app/controllers/geopolitical/cities_controller.rb +44 -10
  6. data/app/controllers/geopolitical/geopolitical_controller.rb +11 -9
  7. data/app/controllers/geopolitical/hoods_controller.rb +47 -9
  8. data/app/controllers/geopolitical/nations_controller.rb +45 -7
  9. data/app/controllers/geopolitical/regions_controller.rb +47 -7
  10. data/app/helpers/geopolitical/application_helper.rb +2 -0
  11. data/app/models/city.rb +149 -48
  12. data/app/models/concerns/geopolitocracy.rb +121 -34
  13. data/app/models/hood.rb +44 -18
  14. data/app/models/nation.rb +96 -17
  15. data/app/models/region.rb +42 -14
  16. data/app/views/geopolitical/cities/_city.html.haml +2 -5
  17. data/app/views/geopolitical/cities/_form.html.haml +25 -28
  18. data/app/views/geopolitical/cities/edit.html.haml +1 -0
  19. data/app/views/geopolitical/cities/index.html.haml +17 -20
  20. data/app/views/geopolitical/cities/new.html.haml +1 -0
  21. data/app/views/geopolitical/cities/show.html.haml +7 -7
  22. data/app/views/geopolitical/geopolitical/index.html.haml +7 -9
  23. data/app/views/geopolitical/hoods/_city.html.haml +2 -5
  24. data/app/views/geopolitical/hoods/_form.html.haml +21 -22
  25. data/app/views/geopolitical/hoods/edit.html.haml +1 -0
  26. data/app/views/geopolitical/hoods/index.html.haml +13 -18
  27. data/app/views/geopolitical/hoods/new.html.haml +1 -0
  28. data/app/views/geopolitical/hoods/show.html.haml +5 -5
  29. data/app/views/geopolitical/nations/_form.html.haml +11 -8
  30. data/app/views/geopolitical/nations/_nation.html.haml +5 -0
  31. data/app/views/geopolitical/nations/edit.html.haml +1 -0
  32. data/app/views/geopolitical/nations/index.html.haml +16 -20
  33. data/app/views/geopolitical/nations/new.html.haml +1 -0
  34. data/app/views/geopolitical/nations/show.html.haml +7 -7
  35. data/app/views/geopolitical/regions/_form.html.haml +10 -8
  36. data/app/views/geopolitical/regions/_region.html.haml +2 -5
  37. data/app/views/geopolitical/regions/edit.html.haml +1 -0
  38. data/app/views/geopolitical/regions/index.html.haml +12 -15
  39. data/app/views/geopolitical/regions/new.html.haml +1 -0
  40. data/app/views/geopolitical/regions/show.html.haml +5 -5
  41. data/app/views/layouts/geopolitical.html.haml +13 -15
  42. data/config/locales/geopolitical.en.yml +76 -0
  43. data/config/locales/geopolitical.pt.yml +77 -0
  44. data/geopolitical.gemspec +14 -12
  45. data/lib/geopolitical/engine.rb +3 -0
  46. data/lib/geopolitical/version.rb +3 -2
  47. data/lib/geopolitical.rb +23 -3
  48. metadata +42 -118
  49. data/.coveralls.yml +0 -1
  50. data/.gitignore +0 -11
  51. data/.rspec +0 -1
  52. data/.travis.yml +0 -26
  53. data/Gemfile +0 -15
  54. data/Guardfile +0 -24
  55. data/Rakefile +0 -25
  56. data/app/serializers/city_serializer.rb +0 -8
  57. data/app/serializers/nation_serializer.rb +0 -4
  58. data/app/serializers/region_serializer.rb +0 -4
  59. data/bin/rails +0 -8
  60. data/lib/tasks/geopolitical_tasks.rake +0 -4
  61. data/spec/dummy/README.rdoc +0 -28
  62. data/spec/dummy/Rakefile +0 -6
  63. data/spec/dummy/app/assets/images/.keep +0 -0
  64. data/spec/dummy/app/assets/javascripts/application.js +0 -13
  65. data/spec/dummy/app/assets/stylesheets/application.css +0 -13
  66. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  67. data/spec/dummy/app/helpers/application_helper.rb +0 -2
  68. data/spec/dummy/app/mailers/.keep +0 -0
  69. data/spec/dummy/app/models/.keep +0 -0
  70. data/spec/dummy/app/models/concerns/.keep +0 -0
  71. data/spec/dummy/app/views/layouts/application.html.erb +0 -14
  72. data/spec/dummy/bin/bundle +0 -3
  73. data/spec/dummy/bin/rails +0 -4
  74. data/spec/dummy/bin/rake +0 -4
  75. data/spec/dummy/config/application.rb +0 -27
  76. data/spec/dummy/config/boot.rb +0 -5
  77. data/spec/dummy/config/environment.rb +0 -5
  78. data/spec/dummy/config/environments/development.rb +0 -29
  79. data/spec/dummy/config/environments/production.rb +0 -80
  80. data/spec/dummy/config/environments/test.rb +0 -36
  81. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  82. data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  83. data/spec/dummy/config/initializers/inflections.rb +0 -16
  84. data/spec/dummy/config/initializers/mime_types.rb +0 -5
  85. data/spec/dummy/config/initializers/secret_token.rb +0 -12
  86. data/spec/dummy/config/initializers/session_store.rb +0 -3
  87. data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
  88. data/spec/dummy/config/locales/en.yml +0 -23
  89. data/spec/dummy/config/mongoid.yml +0 -63
  90. data/spec/dummy/config/routes.rb +0 -56
  91. data/spec/dummy/config.ru +0 -4
  92. data/spec/dummy/lib/assets/.keep +0 -0
  93. data/spec/dummy/log/.keep +0 -0
  94. data/spec/dummy/log/development.log +0 -0
  95. data/spec/dummy/log/test.log +0 -23532
  96. data/spec/dummy/public/404.html +0 -58
  97. data/spec/dummy/public/422.html +0 -58
  98. data/spec/dummy/public/500.html +0 -57
  99. data/spec/dummy/public/favicon.ico +0 -0
  100. data/spec/fabricators/city_fabricator.rb +0 -6
  101. data/spec/fabricators/hood_fabricator.rb +0 -5
  102. data/spec/fabricators/nation_fabricator.rb +0 -4
  103. data/spec/fabricators/region_fabricator.rb +0 -5
  104. data/spec/models/city_spec.rb +0 -158
  105. data/spec/models/hood_spec.rb +0 -47
  106. data/spec/models/nation_spec.rb +0 -53
  107. data/spec/models/region_spec.rb +0 -20
  108. 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
- field :area, type: Integer # m2 square area
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
- # field :capital, type: String
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
- attr_writer :x, :y, :z
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
- scope :population, -> { order_by(souls: -1) }
30
-
31
- index nation_id: 1
32
- index souls: -1
33
- index name: 1, nation_id: 1
34
- index({ region_id: 1 }, sparse: true)
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
- def region_inside_nation
37
- return if !region || region.nation == nation
38
- errors.add(:region, 'not inside Nation')
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
- def set_defaults
42
- self.nation ||= region.try(:nation)
43
- return unless City.where(slug: slug).first
44
- self.slug += "-#{region.abbr}"
45
- return unless City.where(slug: slug).first
46
- raise "Two cities with the same name in #{region}: '#{slug}'"
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.phone || nation.phone
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.postal || nation.postal
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
- def ==(other)
66
- return unless other.is_a?(City)
67
- other && slug == other.slug
68
- end
69
-
70
- def <=>(other)
71
- return unless other.is_a?(City)
72
- slug <=> other.slug
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
- def with_region
76
- return name unless region
77
- "#{name}/#{region.abbr || region.name}"
167
+ "#{name}#{separator}#{current_region_abbr}"
78
168
  end
79
169
 
80
- def with_nation
81
- with_region + '/' + nation.abbr
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
- # Geopolitical Helpers
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
- # field :gid, type: Integer # geonames id
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
- field :abbr, type: String
10
- field :nick, type: String
11
-
12
- field :souls, type: Fixnum # Population
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
- field :ascii, type: String
15
- field :code, type: String
16
- field :slug, type: String # , default: -> { name }
17
-
18
- field :postal, type: String # , default: -> { name }
19
- field :phone, type: String # , default: -> { name }
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 :iso_3166_2, :code
72
+ alias_method :population=, :souls=
23
73
 
74
+ # @!group Validations
24
75
  validates :name, presence: true
25
- validates :slug, presence: true, uniqueness: true
26
- validates :code, uniqueness: { allow_nil: true }
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
- index slug: 1
29
- index name: 1
87
+ before_validation :ensure_slug_is_generated
30
88
 
31
- before_validation :ensure_slug
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
- scope :ordered, -> { order_by(name: 1) }
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
- def ensure_slug
36
- self.slug ||= name
101
+ self.slug = name # Will trigger the custom slug setter
37
102
  end
38
103
 
39
- def name=(txt)
40
- return unless txt
41
- txt = txt.titleize unless txt =~ /[A-Z][a-z]/
42
- super txt
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
- def slug=(txt)
46
- return unless txt
47
- self[:slug] = ActiveSupport::Inflector
48
- .transliterate(txt).delete('.').gsub(/\W/, '-').downcase
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
- def self.search(txt, lazy = false)
56
- key = ActiveSupport::Inflector.transliterate(txt).gsub(/\W/, '-')
57
- char = lazy ? nil : '$'
58
- where(slug: /^#{key}#{char}/i)
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
- # Hood/Neighborhood
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
- field :rank, type: Integer
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
- def ensure_slug
16
- return unless city
17
- self.slug ||= "#{city.slug}-#{name}"
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.phone
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.postal
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
- # Nation/Country/Flag
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
- field :_id, type: String, default: -> { abbr }
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
- field :gid, type: Integer # geonames id
11
- field :tld, type: String # Top level domain
12
- field :cash, type: String # Currency prefix
13
- field :code3, type: String # Iso 3166_3
14
- field :lang, type: String # Official/main language
15
- field :langs, type: Array # All official languages
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
- alias iso_3166_3 code3
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, uniqueness: true, presence: true
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
- belongs_to :capital, inverse_of: :nation_capital, class_name: 'City',
23
- optional: true
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 lang: 1
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
- def abbr=(txt)
31
- self[:abbr] = txt && txt.upcase
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
- return unless other
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