govuk_content_models 6.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/.ruby-version +1 -0
- data/.travis.yml +14 -0
- data/CONTRIBUTING.md +22 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/README.md +5 -0
- data/Rakefile +46 -0
- data/app/models/action.rb +60 -0
- data/app/models/answer_edition.rb +13 -0
- data/app/models/artefact.rb +341 -0
- data/app/models/artefact_action.rb +27 -0
- data/app/models/artefact_external_link.rb +15 -0
- data/app/models/business_support/business_size.rb +14 -0
- data/app/models/business_support/business_type.rb +14 -0
- data/app/models/business_support/location.rb +14 -0
- data/app/models/business_support/purpose.rb +14 -0
- data/app/models/business_support/sector.rb +14 -0
- data/app/models/business_support/stage.rb +14 -0
- data/app/models/business_support/support_type.rb +14 -0
- data/app/models/business_support_edition.rb +69 -0
- data/app/models/campaign_edition.rb +72 -0
- data/app/models/completed_transaction_edition.rb +14 -0
- data/app/models/curated_list.rb +32 -0
- data/app/models/edition.rb +286 -0
- data/app/models/expectant.rb +21 -0
- data/app/models/expectation.rb +12 -0
- data/app/models/guide_edition.rb +19 -0
- data/app/models/help_page_edition.rb +13 -0
- data/app/models/licence_edition.rb +35 -0
- data/app/models/local_authority.rb +58 -0
- data/app/models/local_interaction.rb +20 -0
- data/app/models/local_service.rb +49 -0
- data/app/models/local_transaction_edition.rb +49 -0
- data/app/models/overview_dashboard.rb +25 -0
- data/app/models/part.rb +28 -0
- data/app/models/parted.rb +32 -0
- data/app/models/place_edition.rb +20 -0
- data/app/models/programme_edition.rb +26 -0
- data/app/models/simple_smart_answer_edition.rb +66 -0
- data/app/models/simple_smart_answer_edition/node.rb +40 -0
- data/app/models/simple_smart_answer_edition/node/option.rb +31 -0
- data/app/models/tag.rb +88 -0
- data/app/models/transaction_edition.rb +28 -0
- data/app/models/travel_advice_edition.rb +177 -0
- data/app/models/user.rb +54 -0
- data/app/models/video_edition.rb +24 -0
- data/app/models/workflow.rb +217 -0
- data/app/models/workflow_actor.rb +141 -0
- data/app/traits/attachable.rb +60 -0
- data/app/traits/govspeak_smart_quotes_fixer.rb +19 -0
- data/app/traits/taggable.rb +113 -0
- data/app/validators/safe_html.rb +33 -0
- data/app/validators/slug_validator.rb +53 -0
- data/config/mongoid.yml +5 -0
- data/govuk_content_models.gemspec +42 -0
- data/jenkins.sh +7 -0
- data/lib/fact_check_address.rb +36 -0
- data/lib/govuk_content_models.rb +12 -0
- data/lib/govuk_content_models/require_all.rb +14 -0
- data/lib/govuk_content_models/test_helpers/factories.rb +213 -0
- data/lib/govuk_content_models/test_helpers/local_services.rb +24 -0
- data/lib/govuk_content_models/version.rb +4 -0
- data/test/fixtures/contactotron_api_response.json +1 -0
- data/test/fixtures/uploads/image.jpg +0 -0
- data/test/models/artefact_action_test.rb +123 -0
- data/test/models/artefact_external_link_test.rb +32 -0
- data/test/models/artefact_tag_test.rb +52 -0
- data/test/models/artefact_test.rb +583 -0
- data/test/models/business_support/business_size_test.rb +25 -0
- data/test/models/business_support/business_type_test.rb +25 -0
- data/test/models/business_support/location_test.rb +25 -0
- data/test/models/business_support/purpose_test.rb +29 -0
- data/test/models/business_support/sector_test.rb +25 -0
- data/test/models/business_support/stage_test.rb +25 -0
- data/test/models/business_support/support_type_test.rb +25 -0
- data/test/models/business_support_edition_test.rb +186 -0
- data/test/models/campaign_edition_test.rb +90 -0
- data/test/models/curated_list_test.rb +32 -0
- data/test/models/edition_test.rb +826 -0
- data/test/models/fact_check_address_test.rb +36 -0
- data/test/models/help_page_edition_test.rb +38 -0
- data/test/models/licence_edition_test.rb +104 -0
- data/test/models/local_authority_test.rb +113 -0
- data/test/models/local_service_test.rb +199 -0
- data/test/models/local_transaction_edition_test.rb +78 -0
- data/test/models/overview_dashboard_test.rb +47 -0
- data/test/models/simple_smart_answer_edition_test.rb +169 -0
- data/test/models/simple_smart_answer_node_test.rb +134 -0
- data/test/models/simple_smart_answer_option_test.rb +90 -0
- data/test/models/tag_test.rb +92 -0
- data/test/models/time_zone_test.rb +48 -0
- data/test/models/transaction_edition_test.rb +20 -0
- data/test/models/travel_advice_edition_test.rb +480 -0
- data/test/models/user_test.rb +114 -0
- data/test/models/video_edition_test.rb +64 -0
- data/test/models/workflow_actor_test.rb +61 -0
- data/test/models/workflow_test.rb +307 -0
- data/test/test_helper.rb +47 -0
- data/test/traits/attachable_test.rb +143 -0
- data/test/traits/taggable_test.rb +114 -0
- data/test/validators/safe_html_validator_test.rb +86 -0
- data/test/validators/slug_validator_test.rb +42 -0
- 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
|