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
@@ -0,0 +1,217 @@
1
+ require "differ"
2
+ require "state_machine"
3
+ require "action"
4
+
5
+ module Workflow
6
+ class CannotDeletePublishedPublication < RuntimeError; end
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ validate :not_editing_published_item
11
+ before_destroy :check_can_delete_and_notify
12
+
13
+ before_save :denormalise_users
14
+ after_create :notify_siblings_of_new_edition
15
+
16
+ field :state, type: String, default: "lined_up"
17
+ belongs_to :assigned_to, class_name: "User"
18
+ embeds_many :actions
19
+
20
+ state_machine initial: :lined_up do
21
+ after_transition on: :request_amendments do |edition, transition|
22
+ edition.mark_as_rejected
23
+ end
24
+
25
+ after_transition on: :publish do |edition, transition|
26
+ edition.was_published
27
+ end
28
+
29
+ event :start_work do
30
+ transition lined_up: :draft
31
+ end
32
+
33
+ event :request_review do
34
+ transition [:draft, :amends_needed] => :in_review
35
+ end
36
+
37
+ event :approve_review do
38
+ transition in_review: :ready
39
+ end
40
+
41
+ event :approve_fact_check do
42
+ transition fact_check_received: :ready
43
+ end
44
+
45
+ event :request_amendments do
46
+ transition [:fact_check_received, :in_review] => :amends_needed
47
+ end
48
+
49
+ # Editions can optionally be sent out for fact check
50
+ event :send_fact_check do
51
+ transition ready: :fact_check
52
+ end
53
+
54
+ # If no response is received to a fact check request we can skip
55
+ # that fact check and return the edition to the 'ready' state
56
+ event :skip_fact_check do
57
+ transition fact_check: :ready
58
+ end
59
+
60
+ # Where a fact check response has been received the item is moved
61
+ # into a special state so that the fact check responses can be
62
+ # reviewed
63
+ event :receive_fact_check do
64
+ transition fact_check: :fact_check_received
65
+ end
66
+
67
+ event :publish do
68
+ transition ready: :published
69
+ end
70
+
71
+ event :emergency_publish do
72
+ transition draft: :published
73
+ end
74
+
75
+ event :archive do
76
+ transition all => :archived, :unless => :archived?
77
+ end
78
+ end
79
+
80
+ # alias_method :created_by, :creator
81
+ # alias_method :published_by, :publisher
82
+ # alias_method :archived_by, :archiver
83
+ end
84
+
85
+ def fact_checked?
86
+ (self.actions.where(request_type: Action::APPROVE_FACT_CHECK).count > 0)
87
+ end
88
+
89
+ def capitalized_state_name
90
+ self.human_state_name.capitalize
91
+ end
92
+
93
+ def update_user_action(property, statuses)
94
+ actions.where(:request_type.in => statuses).limit(1).each do |action|
95
+ # This can be invoked by Panopticon when it updates an artefact and associated
96
+ # editions. The problem is that Panopticon and Publisher users live in different
97
+ # collections, but share a model and relationships with eg actions.
98
+ # Therefore, Panopticon might not find a user for an action.
99
+ if action.requester
100
+ self[property] = action.requester.name
101
+ end
102
+ end
103
+ end
104
+
105
+ def denormalise_users
106
+ self.assignee = assigned_to.name if assigned_to
107
+ update_user_action("creator", [Action::CREATE, Action::NEW_VERSION])
108
+ update_user_action("publisher", [Action::PUBLISH])
109
+ update_user_action("archiver", [Action::ARCHIVE])
110
+ self
111
+ end
112
+
113
+ def created_by
114
+ creation = actions.detect do |a|
115
+ a.request_type == Action::CREATE || a.request_type == Action::NEW_VERSION
116
+ end
117
+ creation.requester if creation
118
+ end
119
+
120
+ def published_by
121
+ publication = actions.where(request_type: Action::PUBLISH).first
122
+ publication.requester if publication
123
+ end
124
+
125
+ def archived_by
126
+ publication = actions.where(request_type: Action::ARCHIVE).first
127
+ publication.requester if publication
128
+ end
129
+
130
+ def latest_status_action(type = nil)
131
+ if type
132
+ self.actions.where(request_type: type).last
133
+ else
134
+ most_recent_action(&:status_action?)
135
+ end
136
+ end
137
+
138
+ def last_fact_checked_at
139
+ last_fact_check = actions.reverse.find(&:is_fact_check_request?)
140
+ last_fact_check ? last_fact_check.created_at : NullTimestamp.new
141
+ end
142
+
143
+ def new_action(user, type, options={})
144
+ actions.create!(options.merge(requester_id: user.id, request_type: type))
145
+ end
146
+
147
+ def new_action_without_validation(user, type, options={})
148
+ action = actions.build(options.merge(requester_id: user.id, request_type: type))
149
+ save(validate: false)
150
+ action
151
+ end
152
+
153
+ def most_recent_action(&blk)
154
+ self.actions.sort_by(&:created_at).reverse.find(&blk)
155
+ end
156
+
157
+ def not_editing_published_item
158
+ if changed? and ! state_changed?
159
+ if archived?
160
+ errors.add(:base, "Archived editions can't be edited")
161
+ end
162
+ if published?
163
+ changes_allowed_when_published = ["slug", "section",
164
+ "department", "business_proposition"]
165
+ illegal_changes = changes.keys - changes_allowed_when_published
166
+ if illegal_changes.empty?
167
+ # Allow it
168
+ else
169
+ errors.add(:base, "Published editions can't be edited")
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ def can_destroy?
176
+ ! published? and ! archived?
177
+ end
178
+
179
+ def check_can_delete_and_notify
180
+ raise CannotDeletePublishedPublication unless can_destroy?
181
+ end
182
+
183
+ def mark_as_rejected
184
+ self.inc(:rejected_count, 1)
185
+ end
186
+
187
+ def previous_edition
188
+ self.previous_published_edition || false
189
+ end
190
+
191
+ def edition_changes
192
+ if self.whole_body.empty?
193
+ false
194
+ else
195
+ my_body, their_body = [self, self.published_edition].map do |edition|
196
+ edition.whole_body.gsub("\r\n", "\n")
197
+ end
198
+ Differ.diff_by_line(my_body, their_body)
199
+ end
200
+ end
201
+
202
+ def notify_siblings_of_new_edition
203
+ siblings.update_all(sibling_in_progress: self.version_number)
204
+ end
205
+
206
+ def notify_siblings_of_published_edition
207
+ siblings.update_all(sibling_in_progress: nil)
208
+ end
209
+
210
+ def update_sibling_in_progress(version_number_or_nil)
211
+ update_attribute(:sibling_in_progress, version_number_or_nil)
212
+ end
213
+
214
+ def in_progress?
215
+ ! ["archived", "published"].include? self.state
216
+ end
217
+ end
@@ -0,0 +1,141 @@
1
+ require "answer_edition"
2
+ require "guide_edition"
3
+ require "local_transaction_edition"
4
+ require "place_edition"
5
+ require "programme_edition"
6
+ require "transaction_edition"
7
+
8
+ module WorkflowActor
9
+ SIMPLE_WORKFLOW_ACTIONS = %W[start_work request_review
10
+ request_amendments approve_review approve_fact_check archive]
11
+
12
+ def record_action(edition, type, options={})
13
+ type = Action.const_get(type.to_s.upcase)
14
+ action = edition.new_action(self, type, options)
15
+ edition.save! # force callbacks for denormalisation
16
+ action
17
+ end
18
+
19
+ def record_action_without_validation(edition, type, options={})
20
+ type = Action.const_get(type.to_s.upcase)
21
+ action = edition.new_action_without_validation(self, type, options)
22
+ edition.save! # force callbacks for denormalisation
23
+ action
24
+ end
25
+
26
+ def can_take_action(action, edition)
27
+ respond_to?(:"can_#{action}?") ? __send__(:"can_#{action}?", edition) : true
28
+ end
29
+
30
+ def take_action(edition, action, details = {})
31
+ if can_take_action(action, edition) and edition.send(action)
32
+ record_action(edition, action, details)
33
+ edition
34
+ else
35
+ false
36
+ end
37
+ end
38
+
39
+ def take_action!(edition, action, details = {})
40
+ edition = take_action(edition, action, details)
41
+ edition.save if edition
42
+ end
43
+
44
+ def progress(edition, activity_details)
45
+ activity = activity_details.delete(:request_type)
46
+
47
+ edition = send(activity, edition, activity_details)
48
+ edition.save if edition
49
+ end
50
+
51
+ def record_note(edition, comment)
52
+ edition.new_action(self, "note", comment: comment)
53
+ end
54
+
55
+ def create_edition(format, attributes = {})
56
+ format = "#{format}_edition" unless format.to_s.match(/edition$/)
57
+ publication_class = format.to_s.camelize.constantize
58
+
59
+ item = publication_class.create(attributes)
60
+ record_action(item, Action::CREATE) if item.persisted?
61
+ item
62
+ end
63
+
64
+ def new_version(edition, convert_to = nil)
65
+ return false unless edition.published?
66
+
67
+ if not convert_to.nil?
68
+ convert_to = convert_to.to_s.camelize.constantize
69
+ new_edition = edition.build_clone(convert_to)
70
+ else
71
+ new_edition = edition.build_clone
72
+ end
73
+
74
+ if new_edition
75
+ record_action new_edition, Action::NEW_VERSION
76
+ new_edition
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ def send_fact_check(edition, details)
83
+ return false if details[:email_addresses].blank?
84
+
85
+ details[:comment] ||= "Fact check requested"
86
+ details[:comment] += "\n\nResponses should be sent to: " +
87
+ edition.fact_check_email_address
88
+
89
+ take_action(edition, __method__, details)
90
+ end
91
+
92
+ # Advances state if possible (i.e. if in "fact_check" state)
93
+ # Always records the action.
94
+ def receive_fact_check(edition, details)
95
+ edition.receive_fact_check
96
+ # Fact checks are processed async, so the user doesn't get an opportunity
97
+ # to retry without the content that (inadvertantly) fails validation, which happens frequently.
98
+ record_action_without_validation(edition, :receive_fact_check, details)
99
+ end
100
+
101
+ def skip_fact_check(edition, details)
102
+ edition.skip_fact_check
103
+ record_action(edition, :skip_fact_check, details)
104
+ end
105
+
106
+ SIMPLE_WORKFLOW_ACTIONS.each do |method|
107
+ define_method(method) do |edition, details = {}|
108
+ take_action(edition, __method__, details)
109
+ end
110
+ end
111
+
112
+ def publish(edition, details)
113
+ if edition.published_edition
114
+ details.merge!({ diff: edition.edition_changes })
115
+ end
116
+
117
+ take_action(edition, __method__, details)
118
+ end
119
+
120
+ def can_approve_review?(edition)
121
+ # To accommodate latest_status_action being nil, we'll always return true in
122
+ # those cases
123
+ # This is intended as a v.temporary fix until we can remedy the root cause
124
+ if edition.latest_status_action
125
+ edition.latest_status_action.requester_id != self.id
126
+ else
127
+ true
128
+ end
129
+ end
130
+ alias :can_request_amendments? :can_approve_review?
131
+
132
+ def assign(edition, recipient)
133
+ edition.assigned_to_id = recipient.id
134
+
135
+ # We're saving the edition here as the controller treats assignment as a
136
+ # special case.
137
+ # The controller saves the publication, then updates assignment.
138
+ edition.save! and edition.reload
139
+ record_action edition, __method__, recipient: recipient
140
+ end
141
+ end
@@ -0,0 +1,60 @@
1
+ module Attachable
2
+ class ApiClientNotPresent < StandardError; end
3
+
4
+ @asset_api_client = nil
5
+
6
+ def self.asset_api_client
7
+ @asset_api_client
8
+ end
9
+
10
+ def self.asset_api_client=(api_client)
11
+ @asset_api_client = api_client
12
+ end
13
+
14
+ module ClassMethods
15
+ def attaches(*fields)
16
+ fields.map(&:to_s).each do |field|
17
+ before_save "upload_#{field}".to_sym, :if => "#{field}_has_changed?".to_sym
18
+ self.field "#{field}_id".to_sym, type: String
19
+
20
+ define_method(field) do
21
+ raise ApiClientNotPresent unless Attachable.asset_api_client
22
+ unless self.send("#{field}_id").nil?
23
+ @attachments ||= { }
24
+ @attachments[field] ||= Attachable.asset_api_client.asset(self.send("#{field}_id"))
25
+ end
26
+ end
27
+
28
+ define_method("#{field}=") do |file|
29
+ instance_variable_set("@#{field}_has_changed", true)
30
+ instance_variable_set("@#{field}_file", file)
31
+ end
32
+
33
+ define_method("#{field}_has_changed?") do
34
+ instance_variable_get("@#{field}_has_changed")
35
+ end
36
+
37
+ define_method("remove_#{field}=") do |value|
38
+ unless value.nil? or value == false or (value.respond_to?(:empty?) and value.empty?)
39
+ self.send("#{field}_id=", nil)
40
+ end
41
+ end
42
+
43
+ define_method("upload_#{field}") do
44
+ raise ApiClientNotPresent unless Attachable.asset_api_client
45
+ begin
46
+ response = Attachable.asset_api_client.create_asset(:file => instance_variable_get("@#{field}_file"))
47
+ self.send("#{field}_id=", response.id.match(/\/([^\/]+)\z/) {|m| m[1] })
48
+ rescue StandardError
49
+ errors.add("#{field}_id".to_sym, "could not be uploaded")
50
+ end
51
+ end
52
+ private "upload_#{field}".to_sym
53
+ end
54
+ end
55
+ end
56
+
57
+ def self.included(klass)
58
+ klass.extend ClassMethods
59
+ end
60
+ end
@@ -0,0 +1,19 @@
1
+ # encoding: UTF-8
2
+
3
+ module GovspeakSmartQuotesFixer
4
+ def self.included(model)
5
+ model.class_eval do
6
+ before_validation :fix_smart_quotes_in_govspeak
7
+ end
8
+ end
9
+
10
+ private
11
+
12
+ def fix_smart_quotes_in_govspeak
13
+ self.class::GOVSPEAK_FIELDS.each do |field|
14
+ if self.send(field) =~ /[“”]/
15
+ self.send(field).gsub!(/[“”]/, '"')
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,113 @@
1
+ module Taggable
2
+ module ClassMethods
3
+ def stores_tags_for(*keys)
4
+ tag_types = keys.to_a.flatten.compact.map(&:to_s)
5
+ class_attribute :tag_types
6
+ self.tag_types = tag_types
7
+
8
+ tag_types.each do |k|
9
+ define_method "#{k}=" do |values|
10
+ set_tags_of_type(k, values)
11
+ end
12
+ alias_method :"#{k.singularize}_ids=", :"#{k}="
13
+
14
+ define_method k do
15
+ tags_of_type(k.singularize)
16
+ end
17
+ define_method "#{k.singularize}_ids" do
18
+ tags_of_type(k.singularize).collect(&:tag_id)
19
+ end
20
+ end
21
+ end
22
+
23
+ def has_primary_tag_for(*keys)
24
+ tag_types = keys.to_a.flatten.compact.map(&:to_s)
25
+ class_attribute :primary_tag_types
26
+ self.primary_tag_types = tag_types
27
+
28
+ tag_types.each do |key|
29
+ method_name = "primary_#{key}"
30
+
31
+ define_method "#{method_name}=" do |value|
32
+ set_primary_tag_of_type(key.to_s, value)
33
+ end
34
+
35
+ define_method method_name do
36
+ tags_of_type(key.to_s).first
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def self.included(klass)
43
+ klass.extend ClassMethods
44
+ klass.field :tag_ids, type: Array, default: []
45
+ klass.index :tag_ids
46
+ klass.attr_protected :tags, :tag_ids
47
+ klass.__send__ :private, :tag_ids=
48
+ end
49
+
50
+ def set_tags_of_type(collection_name, values)
51
+ tag_type = collection_name.singularize
52
+
53
+ # Ensure all tags loaded from database. This feels inelegant
54
+ # but ensures integrity. It could go away if we moved to a more
55
+ # consistent repository approach to retrieving and constructing
56
+ # objects, or if we used a custom serialization type for tags
57
+ # as documented on http://mongoid.org/en/mongoid/docs/documents.html
58
+ tags
59
+
60
+ # This will raise a Tag::MissingTags exception unless all the tags exist
61
+ new_tags = Tag.by_tag_ids!(values, tag_type)
62
+
63
+ @tags.reject! { |t| t.tag_type == tag_type }
64
+ @tags += new_tags
65
+ end
66
+
67
+ # The primary tag is simply the first one of its
68
+ # type. If that tag is already applied this method
69
+ # moves it to the start of the list. If it's not then
70
+ # we add it at the start of the list.
71
+ def set_primary_tag_of_type(tag_type, value)
72
+ tags
73
+
74
+ tag = Tag.by_tag_id(value, tag_type)
75
+ raise "Missing tag" unless tag
76
+ raise "Wrong tag type" unless tag.tag_type == tag_type
77
+
78
+ @tags -= [tag]
79
+ @tags.unshift(tag)
80
+ end
81
+
82
+ def tags_of_type(tag_type)
83
+ tags.select { |t| t.tag_type == tag_type }
84
+ end
85
+
86
+ def reconcile_tag_ids
87
+ # Ensure tags are loaded so we don't accidentally
88
+ # remove all tagging in situations where tags haven't
89
+ # been accessed during the lifetime of the object
90
+ tags
91
+
92
+ self.tag_ids = @tags.collect(&:tag_id)
93
+ end
94
+
95
+ def tags
96
+ @tags ||= Tag.by_tag_ids(tag_ids).compact.to_a
97
+ end
98
+
99
+ def reload
100
+ @tags = nil
101
+ super
102
+ end
103
+
104
+ def save(options={})
105
+ reconcile_tag_ids
106
+ super(options)
107
+ end
108
+
109
+ def save!(options={})
110
+ reconcile_tag_ids
111
+ super(options)
112
+ end
113
+ end