govuk_content_models 6.0.2

Sign up to get free protection for your applications and to get access to all the features.
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