govuk_content_models 6.0.2

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 (104) hide show
  1. data/.gitignore +17 -0
  2. data/.ruby-version +1 -0
  3. data/.travis.yml +14 -0
  4. data/CONTRIBUTING.md +22 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE +20 -0
  7. data/README.md +5 -0
  8. data/Rakefile +46 -0
  9. data/app/models/action.rb +60 -0
  10. data/app/models/answer_edition.rb +13 -0
  11. data/app/models/artefact.rb +341 -0
  12. data/app/models/artefact_action.rb +27 -0
  13. data/app/models/artefact_external_link.rb +15 -0
  14. data/app/models/business_support/business_size.rb +14 -0
  15. data/app/models/business_support/business_type.rb +14 -0
  16. data/app/models/business_support/location.rb +14 -0
  17. data/app/models/business_support/purpose.rb +14 -0
  18. data/app/models/business_support/sector.rb +14 -0
  19. data/app/models/business_support/stage.rb +14 -0
  20. data/app/models/business_support/support_type.rb +14 -0
  21. data/app/models/business_support_edition.rb +69 -0
  22. data/app/models/campaign_edition.rb +72 -0
  23. data/app/models/completed_transaction_edition.rb +14 -0
  24. data/app/models/curated_list.rb +32 -0
  25. data/app/models/edition.rb +286 -0
  26. data/app/models/expectant.rb +21 -0
  27. data/app/models/expectation.rb +12 -0
  28. data/app/models/guide_edition.rb +19 -0
  29. data/app/models/help_page_edition.rb +13 -0
  30. data/app/models/licence_edition.rb +35 -0
  31. data/app/models/local_authority.rb +58 -0
  32. data/app/models/local_interaction.rb +20 -0
  33. data/app/models/local_service.rb +49 -0
  34. data/app/models/local_transaction_edition.rb +49 -0
  35. data/app/models/overview_dashboard.rb +25 -0
  36. data/app/models/part.rb +28 -0
  37. data/app/models/parted.rb +32 -0
  38. data/app/models/place_edition.rb +20 -0
  39. data/app/models/programme_edition.rb +26 -0
  40. data/app/models/simple_smart_answer_edition.rb +66 -0
  41. data/app/models/simple_smart_answer_edition/node.rb +40 -0
  42. data/app/models/simple_smart_answer_edition/node/option.rb +31 -0
  43. data/app/models/tag.rb +88 -0
  44. data/app/models/transaction_edition.rb +28 -0
  45. data/app/models/travel_advice_edition.rb +177 -0
  46. data/app/models/user.rb +54 -0
  47. data/app/models/video_edition.rb +24 -0
  48. data/app/models/workflow.rb +217 -0
  49. data/app/models/workflow_actor.rb +141 -0
  50. data/app/traits/attachable.rb +60 -0
  51. data/app/traits/govspeak_smart_quotes_fixer.rb +19 -0
  52. data/app/traits/taggable.rb +113 -0
  53. data/app/validators/safe_html.rb +33 -0
  54. data/app/validators/slug_validator.rb +53 -0
  55. data/config/mongoid.yml +5 -0
  56. data/govuk_content_models.gemspec +42 -0
  57. data/jenkins.sh +7 -0
  58. data/lib/fact_check_address.rb +36 -0
  59. data/lib/govuk_content_models.rb +12 -0
  60. data/lib/govuk_content_models/require_all.rb +14 -0
  61. data/lib/govuk_content_models/test_helpers/factories.rb +213 -0
  62. data/lib/govuk_content_models/test_helpers/local_services.rb +24 -0
  63. data/lib/govuk_content_models/version.rb +4 -0
  64. data/test/fixtures/contactotron_api_response.json +1 -0
  65. data/test/fixtures/uploads/image.jpg +0 -0
  66. data/test/models/artefact_action_test.rb +123 -0
  67. data/test/models/artefact_external_link_test.rb +32 -0
  68. data/test/models/artefact_tag_test.rb +52 -0
  69. data/test/models/artefact_test.rb +583 -0
  70. data/test/models/business_support/business_size_test.rb +25 -0
  71. data/test/models/business_support/business_type_test.rb +25 -0
  72. data/test/models/business_support/location_test.rb +25 -0
  73. data/test/models/business_support/purpose_test.rb +29 -0
  74. data/test/models/business_support/sector_test.rb +25 -0
  75. data/test/models/business_support/stage_test.rb +25 -0
  76. data/test/models/business_support/support_type_test.rb +25 -0
  77. data/test/models/business_support_edition_test.rb +186 -0
  78. data/test/models/campaign_edition_test.rb +90 -0
  79. data/test/models/curated_list_test.rb +32 -0
  80. data/test/models/edition_test.rb +826 -0
  81. data/test/models/fact_check_address_test.rb +36 -0
  82. data/test/models/help_page_edition_test.rb +38 -0
  83. data/test/models/licence_edition_test.rb +104 -0
  84. data/test/models/local_authority_test.rb +113 -0
  85. data/test/models/local_service_test.rb +199 -0
  86. data/test/models/local_transaction_edition_test.rb +78 -0
  87. data/test/models/overview_dashboard_test.rb +47 -0
  88. data/test/models/simple_smart_answer_edition_test.rb +169 -0
  89. data/test/models/simple_smart_answer_node_test.rb +134 -0
  90. data/test/models/simple_smart_answer_option_test.rb +90 -0
  91. data/test/models/tag_test.rb +92 -0
  92. data/test/models/time_zone_test.rb +48 -0
  93. data/test/models/transaction_edition_test.rb +20 -0
  94. data/test/models/travel_advice_edition_test.rb +480 -0
  95. data/test/models/user_test.rb +114 -0
  96. data/test/models/video_edition_test.rb +64 -0
  97. data/test/models/workflow_actor_test.rb +61 -0
  98. data/test/models/workflow_test.rb +307 -0
  99. data/test/test_helper.rb +47 -0
  100. data/test/traits/attachable_test.rb +143 -0
  101. data/test/traits/taggable_test.rb +114 -0
  102. data/test/validators/safe_html_validator_test.rb +86 -0
  103. data/test/validators/slug_validator_test.rb +42 -0
  104. metadata +511 -0
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 1.9.3-p484
data/.travis.yml ADDED
@@ -0,0 +1,14 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3-p484
4
+ env:
5
+ - GOVUK_APP_DOMAIN=dev.gov.uk
6
+ services:
7
+ - mongodb
8
+ script:
9
+ - bundle exec rake
10
+ branches:
11
+ except:
12
+ - release
13
+ notifications:
14
+ email: false
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,22 @@
1
+ ## Git workflow ##
2
+
3
+ - Pull requests must contain a succint, clear summary of what the user need is driving this feature change.
4
+ - Follow our [Git styleguide](https://github.com/alphagov/styleguides/blob/master/git.md)
5
+ - Make a feature branch
6
+ - Ensure your branch contains logical atomic commits before sending a pull request - follow our [Git styleguide](https://github.com/alphagov/styleguides/blob/master/git.md)
7
+ - Pull requests are automatically integration tested, where applicable using [Travis CI](https://travis-ci.org/), which will report back on whether the tests still pass on your branch
8
+ - You *may* rebase your branch after feedback if it's to include relevant updates from the master branch. We prefer a rebase here to a merge commit as we prefer a clean and straight history on master with discrete merge commits for features
9
+
10
+ ## Copy ##
11
+
12
+ - Follow the [style guide](https://www.gov.uk/designprinciples/styleguide)
13
+ - URLs should use hyphens, not underscores
14
+
15
+ ## Code ##
16
+
17
+ - Must be readable with meaningful naming, eg no short hand single character variable names
18
+ - Follow our [Ruby style guide](https://github.com/alphagov/styleguides/blob/master/ruby.md)
19
+
20
+ ## Testing ##
21
+
22
+ Write tests.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+ source 'https://BnrJb6FZyzspBboNJzYZ@gem.fury.io/govuk/'
3
+
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (C) 2012 HM Government (Government Digital Service)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # Gov.uk Content Models
2
+
3
+ A gem containing the shared models for Panopticon and Publisher.
4
+
5
+ This is a continual **work in progress**.
data/Rakefile ADDED
@@ -0,0 +1,46 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $:.unshift lib unless $:.include?(lib)
3
+
4
+ require "rake"
5
+ require "rake/testtask"
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.verbose = true
11
+ end
12
+
13
+ require "gem_publisher"
14
+ desc "Publish gem to Gemfury"
15
+ task :publish_gem do |t|
16
+ gem = GemPublisher.publish_if_updated("govuk_content_models.gemspec", :rubygems)
17
+ puts "Published #{gem}" if gem
18
+ end
19
+
20
+ task :check_for_bad_time_handling do
21
+ directories = Dir.glob(File.join(File.dirname(__FILE__), '**', '*.rb'))
22
+ matching_files = directories.select do |filename|
23
+ match = false
24
+ File.open(filename, :encoding => 'utf-8') do |file|
25
+ match = file.grep(%r{Time\.(now|utc|parse)}).any?
26
+ end
27
+ match
28
+ end
29
+ if matching_files.any?
30
+ raise <<-MSG
31
+
32
+ Avoid issues with daylight-savings time by always building instances of
33
+ TimeWithZone and not Time. Use methods like:
34
+ Time.zone.now, Time.zone.parse, n.days.ago, m.hours.from_now, etc
35
+
36
+ in preference to methods like:
37
+ Time.now, Time.utc, Time.parse, etc
38
+
39
+ Files that contain bad Time handling:
40
+ #{matching_files.join("\n ")}
41
+
42
+ MSG
43
+ end
44
+ end
45
+
46
+ task :default => [:test, :check_for_bad_time_handling]
@@ -0,0 +1,60 @@
1
+ require "safe_html"
2
+
3
+ class Action
4
+ include Mongoid::Document
5
+
6
+ STATUS_ACTIONS = [
7
+ CREATE = "create",
8
+ START_WORK = "start_work",
9
+ REQUEST_REVIEW = "request_review",
10
+ APPROVE_REVIEW = "approve_review",
11
+ APPROVE_FACT_CHECK = "approve_fact_check",
12
+ REQUEST_AMENDMENTS = "request_amendments",
13
+ SEND_FACT_CHECK = "send_fact_check",
14
+ RECEIVE_FACT_CHECK = "receive_fact_check",
15
+ SKIP_FACT_CHECK = "skip_fact_check",
16
+ PUBLISH = "publish",
17
+ ARCHIVE = "archive",
18
+ NEW_VERSION = "new_version",
19
+ ]
20
+
21
+ NON_STATUS_ACTIONS = [
22
+ NOTE = "note",
23
+ ASSIGN = "assign",
24
+ ]
25
+
26
+ embedded_in :edition
27
+
28
+ belongs_to :recipient, class_name: "User"
29
+ belongs_to :requester, class_name: "User"
30
+
31
+ field :approver_id, type: Integer
32
+ field :approved, type: DateTime
33
+ field :comment, type: String
34
+ field :comment_sanitized, type: Boolean, default: false
35
+ field :diff, type: String
36
+ field :request_type, type: String
37
+ field :email_addresses, type: String
38
+ field :customised_message, type: String
39
+ field :created_at, type: DateTime, default: lambda { Time.zone.now }
40
+
41
+ GOVSPEAK_FIELDS = []
42
+ validates_with SafeHtml
43
+
44
+ def container_class_name(edition)
45
+ edition.container.class.name.underscore.humanize
46
+ end
47
+
48
+ def status_action?
49
+ STATUS_ACTIONS.include?(request_type)
50
+ end
51
+
52
+ def to_s
53
+ request_type.humanize.capitalize
54
+ end
55
+
56
+ def is_fact_check_request?
57
+ # SEND_FACT_CHECK is now a state - in older publications it isn't
58
+ request_type == SEND_FACT_CHECK || request_type == "fact_check_requested"
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ require "edition"
2
+
3
+ class AnswerEdition < Edition
4
+ field :body, type: String
5
+
6
+ GOVSPEAK_FIELDS = Edition::GOVSPEAK_FIELDS + [:body]
7
+
8
+ @fields_to_clone = [:body]
9
+
10
+ def whole_body
11
+ self.body
12
+ end
13
+ end
@@ -0,0 +1,341 @@
1
+ require "slug_validator"
2
+ require "plek"
3
+ require "traits/taggable"
4
+ require "artefact_action" # Require this when running outside Rails
5
+ require "safe_html"
6
+
7
+ class CannotEditSlugIfEverPublished < ActiveModel::Validator
8
+ def validate(record)
9
+ if record.changes.keys.include?("slug") && record.state_was == "live"
10
+ record.errors[:slug] << ("Cannot edit slug for live artefacts")
11
+ end
12
+ end
13
+ end
14
+
15
+ class Artefact
16
+ include Mongoid::Document
17
+ include Mongoid::Timestamps
18
+
19
+ include Taggable
20
+ stores_tags_for :sections, :writing_teams, :propositions,
21
+ :keywords, :legacy_sources
22
+ has_primary_tag_for :section
23
+
24
+ # NOTE: these fields are deprecated, and soon to be replaced with a
25
+ # tag-based implementation
26
+ field "department", type: String
27
+ field "business_proposition", type: Boolean, default: false
28
+
29
+ field "name", type: String
30
+ field "slug", type: String
31
+ field "paths", type: Array, default: []
32
+ field "prefixes", type: Array, default: []
33
+ field "kind", type: String
34
+ field "owning_app", type: String
35
+ field "rendering_app", type: String
36
+ field "active", type: Boolean, default: false
37
+ field "need_id", type: String
38
+ field "fact_checkers", type: String
39
+ field "publication_id", type: String
40
+ field "description", type: String
41
+ field "state", type: String, default: "draft"
42
+ field "specialist_body", type: String
43
+ field "language", type: String, default: "en"
44
+ field "need_extended_font", type: Boolean, default: false
45
+
46
+ index "slug", :unique => true
47
+
48
+ # This index allows the `relatable_artefacts` method to use an index-covered
49
+ # query, so it doesn't have to load each of the artefacts.
50
+ index [[:name, Mongo::ASCENDING],
51
+ [:state, Mongo::ASCENDING],
52
+ [:kind, Mongo::ASCENDING],
53
+ [:_type, Mongo::ASCENDING],
54
+ [:_id, Mongo::ASCENDING]]
55
+
56
+ scope :not_archived, where(:state.nin => ["archived"])
57
+
58
+ GOVSPEAK_FIELDS = []
59
+
60
+ validates_with SafeHtml
61
+
62
+ MAXIMUM_RELATED_ITEMS = 8
63
+
64
+ FORMATS_BY_DEFAULT_OWNING_APP = {
65
+ "publisher" => ["answer",
66
+ "business_support",
67
+ "campaign",
68
+ "completed_transaction",
69
+ "guide",
70
+ "help_page",
71
+ "licence",
72
+ "local_transaction",
73
+ "place",
74
+ "programme",
75
+ "simple_smart_answer",
76
+ "transaction",
77
+ "video"],
78
+ "smartanswers" => ["smart-answer"],
79
+ "custom-application" => ["custom-application"], # In this case the owning_app is overriden. eg calendars, licencefinder
80
+ "travel-advice-publisher" => ["travel-advice"],
81
+ "whitehall" => ["case_study",
82
+ "consultation",
83
+ "detailed_guide",
84
+ "news_article",
85
+ "speech",
86
+ "policy",
87
+ "publication",
88
+ "statistical_data_set",
89
+ "worldwide_priority"]
90
+ }.freeze
91
+
92
+ FORMATS = FORMATS_BY_DEFAULT_OWNING_APP.values.flatten
93
+
94
+ def self.default_app_for_format(format)
95
+ FORMATS_BY_DEFAULT_OWNING_APP.detect { |app, formats| formats.include?(format) }.first
96
+ end
97
+
98
+ KIND_TRANSLATIONS = {
99
+ "standard transaction link" => "transaction",
100
+ "local authority transaction link" => "local_transaction",
101
+ "completed/done transaction" => "completed_transaction",
102
+ "benefit / scheme" => "programme",
103
+ "find my nearest" => "place",
104
+ }.tap { |h| h.default_proc = -> _, k { k } }.freeze
105
+
106
+ has_and_belongs_to_many :related_artefacts, class_name: "Artefact"
107
+ embeds_many :actions, class_name: "ArtefactAction", order: :created_at
108
+
109
+ embeds_many :external_links, class_name: "ArtefactExternalLink"
110
+ accepts_nested_attributes_for :external_links, :allow_destroy => true,
111
+ reject_if: proc { |attrs| attrs["title"].blank? && attrs["url"].blank? }
112
+
113
+ before_validation :normalise, on: :create
114
+ before_create :record_create_action
115
+ before_update :record_update_action
116
+ after_update :update_editions
117
+
118
+ validates :name, presence: true
119
+ validates :slug, presence: true, uniqueness: true, slug: true
120
+ validates :kind, inclusion: { in: lambda { |x| FORMATS } }
121
+ validates :state, inclusion: { in: ["draft", "live", "archived"] }
122
+ validates :owning_app, presence: true
123
+ validates :language, inclusion: { in: ["en", "cy"] }
124
+ validates_with CannotEditSlugIfEverPublished
125
+ validate :validate_prefixes_and_paths
126
+
127
+ def self.in_alphabetical_order
128
+ order_by([[:name, :asc]])
129
+ end
130
+
131
+ def self.find_by_slug(s)
132
+ where(slug: s).first
133
+ end
134
+
135
+ def self.relatable_items
136
+ # Only retrieving the name field, because that's all we use in Panopticon's
137
+ # helper method (the only place we use this), and it means the index can
138
+ # cover the query entirely
139
+ self.in_alphabetical_order
140
+ .where(:kind.ne => "completed_transaction", :state.ne => "archived")
141
+ .only(:name)
142
+ end
143
+
144
+ # The old-style section string identifier, of the form 'Crime:Prisons'
145
+ def section
146
+ return '' unless self.primary_section
147
+ if primary_section.parent
148
+ [primary_section.parent.title, primary_section.title].join ':'
149
+ else
150
+ primary_section.title
151
+ end
152
+ end
153
+
154
+ # Fallback to english if no language is present
155
+ def language
156
+ attributes['language'] || "en"
157
+ end
158
+
159
+ def normalise
160
+ return unless kind.present?
161
+ self.kind = KIND_TRANSLATIONS[kind.to_s.downcase.strip]
162
+ end
163
+
164
+ def admin_url(options = {})
165
+ [ "#{Plek.current.find(owning_app)}/admin/publications/#{id}",
166
+ options.to_query
167
+ ].reject(&:blank?).join("?")
168
+ end
169
+
170
+ # TODO: Replace this nonsense with a proper API layer.
171
+ def as_json(options={})
172
+ super.tap { |hash|
173
+ if hash["tag_ids"]
174
+ hash["tags"] = Tag.by_tag_ids!(hash["tag_ids"]).map(&:as_json)
175
+ else
176
+ hash["tag_ids"] = []
177
+ hash["tags"] = []
178
+ end
179
+
180
+ if self.primary_section
181
+ hash['primary_section'] = self.primary_section.tag_id
182
+ end
183
+
184
+ unless options[:ignore_related_artefacts]
185
+ hash["related_items"] = published_related_artefacts.map do |a|
186
+ {"artefact" => a.as_json(ignore_related_artefacts: true)}
187
+ end
188
+ end
189
+ hash.delete("related_artefacts")
190
+ hash.delete("related_artefact_ids")
191
+ hash["id"] = hash.delete("_id")
192
+
193
+ # Add a section identifier if needed
194
+ hash["section"] ||= section
195
+ }
196
+ end
197
+
198
+ def published_related_artefacts
199
+ related_artefacts.select do |related_artefact|
200
+ if related_artefact.owning_app == "publisher"
201
+ related_artefact.any_editions_published?
202
+ else
203
+ true
204
+ end
205
+ end
206
+ end
207
+
208
+ # Pass in the desired scope, eg self.related_artefacts.live,
209
+ # get back the items in the order they were set in, rather than natural order
210
+ def ordered_related_artefacts(scope_or_array = self.related_artefacts)
211
+ scope_or_array.sort_by { |artefact| related_artefact_ids.index(artefact.id) }
212
+ end
213
+
214
+ def related_artefacts_grouped_by_distance(scope_or_array = self.related_artefacts)
215
+ groups = { "subsection" => [], "section" => [], "other" => [] }
216
+ scoped_artefacts = ordered_related_artefacts(scope_or_array)
217
+
218
+ if primary_tag = self.primary_section
219
+ groups['subsection'] = scoped_artefacts.select {|a| a.tag_ids.include?(primary_tag.tag_id) }
220
+
221
+ if primary_tag.parent_id.present?
222
+ pattern = Regexp.new "^#{Regexp.quote(primary_tag.parent_id)}\/.+"
223
+ groups['section'] = scoped_artefacts.reject {|a| groups['subsection'].include?(a) }.select {|a|
224
+ a.tag_ids.grep(pattern).count > 0
225
+ }
226
+ end
227
+ end
228
+ groups['other'] = scoped_artefacts.reject {|a| (groups['subsection'] + groups['section']).include?(a) }
229
+
230
+ groups
231
+ end
232
+
233
+ def any_editions_published?
234
+ Edition.where(panopticon_id: self.id, state: 'published').any?
235
+ end
236
+
237
+ def any_editions_ever_published?
238
+ Edition.where(panopticon_id: self.id,
239
+ :state.in => ['published', 'archived']).any?
240
+ end
241
+
242
+ def update_editions
243
+ if state != 'archived'
244
+ Edition.where(:state.nin => ["archived"],
245
+ panopticon_id: self.id).each do |edition|
246
+ edition.update_from_artefact(self)
247
+ end
248
+ else
249
+ archive_editions
250
+ end
251
+ end
252
+
253
+ def archive_editions
254
+ if state == 'archived'
255
+ Edition.where(panopticon_id: self.id, :state.nin => ["archived"]).each do |edition|
256
+ edition.new_action(self, "note", comment: "Artefact has been archived. Archiving this edition.")
257
+ edition.archive!
258
+ end
259
+ end
260
+ end
261
+
262
+ def self.from_param(slug_or_id)
263
+ find_by_slug(slug_or_id) || find(slug_or_id)
264
+ rescue BSON::InvalidObjectId
265
+ raise Mongoid::Errors::DocumentNotFound.new(self, slug_or_id)
266
+ end
267
+
268
+ def update_attributes_as(user, *args)
269
+ assign_attributes(*args)
270
+ save_as user
271
+ end
272
+
273
+ def save_as(user, options={})
274
+ default_action = new_record? ? "create" : "update"
275
+ action_type = options.delete(:action_type) || default_action
276
+ record_action action_type, user: user
277
+ save(options)
278
+ end
279
+
280
+ def record_create_action
281
+ record_action "create"
282
+ end
283
+
284
+ def record_update_action
285
+ record_action "update"
286
+ end
287
+
288
+ def record_action(action_type, options={})
289
+ user = options[:user]
290
+ current_snapshot = snapshot
291
+ last_snapshot = actions.last ? actions.last.snapshot : nil
292
+ unless current_snapshot == last_snapshot
293
+ new_action = actions.build(
294
+ user: user,
295
+ action_type: action_type,
296
+ snapshot: current_snapshot
297
+ )
298
+ # Mongoid will not fire creation callbacks on embedded documents, so we
299
+ # need to trigger this manually. There is a `cascade_callbacks` option on
300
+ # `embeds_many`, but it doesn't appear to trigger creation events on
301
+ # children when an update event fires on the parent
302
+ new_action.set_created_at
303
+ end
304
+ end
305
+
306
+ def archived?
307
+ self.state == "archived"
308
+ end
309
+
310
+ def live?
311
+ self.state == "live"
312
+ end
313
+
314
+ def snapshot
315
+ reconcile_tag_ids
316
+ attributes.except "_id", "created_at", "updated_at", "actions"
317
+ end
318
+
319
+ private
320
+
321
+ def validate_prefixes_and_paths
322
+ if ! self.prefixes.nil? and self.prefixes_changed?
323
+ if self.prefixes.any? {|p| ! valid_url_path?(p)}
324
+ errors.add(:prefixes, "are not all valid absolute URL paths")
325
+ end
326
+ end
327
+ if ! self.paths.nil? and self.paths_changed?
328
+ if self.paths.any? {|p| ! valid_url_path?(p)}
329
+ errors.add(:paths, "are not all valid absolute URL paths")
330
+ end
331
+ end
332
+ end
333
+
334
+ def valid_url_path?(path)
335
+ return false unless path.starts_with?("/")
336
+ uri = URI.parse(path)
337
+ uri.path == path && path !~ %r{//} && path !~ %r{./\z}
338
+ rescue URI::InvalidURIError
339
+ false
340
+ end
341
+ end