redmineup 1.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.
- checksums.yaml +7 -0
- data/.gitignore +21 -0
- data/Gemfile +4 -0
- data/README.md +204 -0
- data/Rakefile +11 -0
- data/app/controllers/redmineup_controller.rb +26 -0
- data/app/views/redmine_crm/redmineup_calendar/_calendar.html.erb +34 -0
- data/app/views/redmineup/_money.html.erb +44 -0
- data/app/views/redmineup/settings.html.erb +10 -0
- data/bitbucket-pipelines.yml +50 -0
- data/config/currency_iso.json +2544 -0
- data/config/locales/cs.yml +13 -0
- data/config/locales/de.yml +13 -0
- data/config/locales/en.yml +13 -0
- data/config/locales/es.yml +13 -0
- data/config/locales/ru.yml +13 -0
- data/config/routes.rb +5 -0
- data/doc/CHANGELOG +14 -0
- data/doc/LICENSE.txt +339 -0
- data/lib/redmineup/acts_as_draftable/draft.rb +40 -0
- data/lib/redmineup/acts_as_draftable/up_acts_as_draftable.rb +172 -0
- data/lib/redmineup/acts_as_list/list.rb +282 -0
- data/lib/redmineup/acts_as_priceable/up_acts_as_priceable.rb +33 -0
- data/lib/redmineup/acts_as_taggable/tag.rb +81 -0
- data/lib/redmineup/acts_as_taggable/tag_list.rb +111 -0
- data/lib/redmineup/acts_as_taggable/tagging.rb +16 -0
- data/lib/redmineup/acts_as_taggable/up_acts_as_taggable.rb +357 -0
- data/lib/redmineup/acts_as_viewed/up_acts_as_viewed.rb +274 -0
- data/lib/redmineup/acts_as_votable/up_acts_as_votable.rb +80 -0
- data/lib/redmineup/acts_as_votable/up_acts_as_voter.rb +20 -0
- data/lib/redmineup/acts_as_votable/votable.rb +323 -0
- data/lib/redmineup/acts_as_votable/vote.rb +28 -0
- data/lib/redmineup/acts_as_votable/voter.rb +131 -0
- data/lib/redmineup/assets_manager.rb +43 -0
- data/lib/redmineup/colors_helper.rb +192 -0
- data/lib/redmineup/compatibility/application_controller_patch.rb +33 -0
- data/lib/redmineup/compatibility/routing_mapper_patch.rb +25 -0
- data/lib/redmineup/currency/formatting.rb +224 -0
- data/lib/redmineup/currency/heuristics.rb +151 -0
- data/lib/redmineup/currency/loader.rb +23 -0
- data/lib/redmineup/currency.rb +450 -0
- data/lib/redmineup/engine.rb +4 -0
- data/lib/redmineup/helpers/external_assets_helper.rb +20 -0
- data/lib/redmineup/helpers/form_tag_helper.rb +88 -0
- data/lib/redmineup/helpers/rup_calendar_helper.rb +146 -0
- data/lib/redmineup/helpers/tags_helper.rb +13 -0
- data/lib/redmineup/helpers/vote_helper.rb +35 -0
- data/lib/redmineup/hooks/views_layouts_hook.rb +11 -0
- data/lib/redmineup/liquid/drops/attachment_drop.rb +47 -0
- data/lib/redmineup/liquid/drops/issue_relations_drop.rb +41 -0
- data/lib/redmineup/liquid/drops/issues_drop.rb +217 -0
- data/lib/redmineup/liquid/drops/news_drop.rb +54 -0
- data/lib/redmineup/liquid/drops/projects_drop.rb +86 -0
- data/lib/redmineup/liquid/drops/time_entries_drop.rb +65 -0
- data/lib/redmineup/liquid/drops/users_drop.rb +68 -0
- data/lib/redmineup/liquid/filters/arrays.rb +254 -0
- data/lib/redmineup/liquid/filters/base.rb +249 -0
- data/lib/redmineup/liquid/filters/colors.rb +31 -0
- data/lib/redmineup/money_helper.rb +66 -0
- data/lib/redmineup/patches/liquid_patch.rb +33 -0
- data/lib/redmineup/settings/money.rb +46 -0
- data/lib/redmineup/settings.rb +53 -0
- data/lib/redmineup/version.rb +3 -0
- data/lib/redmineup.rb +108 -0
- data/redmineup.gemspec +29 -0
- data/test/acts_as_draftable/draft_test.rb +29 -0
- data/test/acts_as_draftable/rup_acts_as_draftable_test.rb +178 -0
- data/test/acts_as_taggable/rup_acts_as_taggable_test.rb +350 -0
- data/test/acts_as_taggable/tag_list_test.rb +34 -0
- data/test/acts_as_taggable/tag_test.rb +72 -0
- data/test/acts_as_taggable/tagging_test.rb +15 -0
- data/test/acts_as_viewed/rup_acts_as_viewed_test.rb +47 -0
- data/test/acts_as_votable/rup_acts_as_votable_test.rb +19 -0
- data/test/acts_as_votable/rup_acts_as_voter_test.rb +14 -0
- data/test/acts_as_votable/votable_test.rb +507 -0
- data/test/acts_as_votable/voter_test.rb +296 -0
- data/test/currency_test.rb +292 -0
- data/test/database.yml +17 -0
- data/test/fixtures/attachments.yml +14 -0
- data/test/fixtures/issues.yml +24 -0
- data/test/fixtures/news.yml +8 -0
- data/test/fixtures/projects.yml +10 -0
- data/test/fixtures/taggings.yml +32 -0
- data/test/fixtures/tags.yml +11 -0
- data/test/fixtures/users.yml +9 -0
- data/test/fixtures/votable_caches.yml +2 -0
- data/test/fixtures/votables.yml +4 -0
- data/test/fixtures/voters.yml +6 -0
- data/test/liquid/drops/attachment_drop_test.rb +15 -0
- data/test/liquid/drops/issue_relations_drop_test.rb +24 -0
- data/test/liquid/drops/issues_drop_test.rb +38 -0
- data/test/liquid/drops/news_drop_test.rb +38 -0
- data/test/liquid/drops/projects_drop_test.rb +44 -0
- data/test/liquid/drops/uses_drop_test.rb +36 -0
- data/test/liquid/filters/arrays_filter_test.rb +31 -0
- data/test/liquid/filters/base_filter_test.rb +67 -0
- data/test/liquid/filters/colors_filter_test.rb +33 -0
- data/test/liquid/liquid_helper.rb +34 -0
- data/test/models/attachment.rb +3 -0
- data/test/models/issue.rb +21 -0
- data/test/models/issue_relation.rb +10 -0
- data/test/models/news.rb +3 -0
- data/test/models/project.rb +8 -0
- data/test/models/user.rb +11 -0
- data/test/models/vote_classes.rb +33 -0
- data/test/money_helper_test.rb +12 -0
- data/test/schema.rb +144 -0
- data/test/tags_helper_test.rb +29 -0
- data/test/test_helper.rb +66 -0
- data/test/vote_helper_test.rb +28 -0
- data/vendor/assets/images/money.png +0 -0
- data/vendor/assets/images/vcard.png +0 -0
- data/vendor/assets/javascripts/Chart.bundle.min.js +16 -0
- data/vendor/assets/javascripts/select2.js +2 -0
- data/vendor/assets/javascripts/select2_helpers.js +192 -0
- data/vendor/assets/stylesheets/money.css +96 -0
- data/vendor/assets/stylesheets/select2.css +424 -0
- metadata +295 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
require 'active_record'
|
|
2
|
+
|
|
3
|
+
module Redmineup
|
|
4
|
+
module ActsAsTaggable #:nodoc:
|
|
5
|
+
module Taggable #:nodoc:
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def taggable?
|
|
12
|
+
false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def up_acts_as_taggable
|
|
16
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy, :class_name => '::Redmineup::ActsAsTaggable::Tagging'
|
|
17
|
+
has_many :tags, :through => :taggings, :class_name => '::Redmineup::ActsAsTaggable::Tag'
|
|
18
|
+
|
|
19
|
+
before_save :save_cached_tag_list
|
|
20
|
+
|
|
21
|
+
after_create :save_tags
|
|
22
|
+
after_update :save_tags
|
|
23
|
+
|
|
24
|
+
include Redmineup::ActsAsTaggable::Taggable::InstanceMethods
|
|
25
|
+
extend Redmineup::ActsAsTaggable::Taggable::SingletonMethods
|
|
26
|
+
|
|
27
|
+
alias_method :reload_without_tag_list, :reload
|
|
28
|
+
alias_method :reload, :reload_with_tag_list
|
|
29
|
+
|
|
30
|
+
class_eval do
|
|
31
|
+
def self.taggable?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cached_tag_list_column_name
|
|
38
|
+
'cached_tag_list'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_cached_tag_list_column_name(value = nil, &block)
|
|
42
|
+
define_attr_method :cached_tag_list_column_name, value, &block
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Create the taggable tables
|
|
46
|
+
# === Options hash:
|
|
47
|
+
# * <tt>:table_name</tt> - use a table name other than viewings
|
|
48
|
+
# To be used during migration, but can also be used in other places
|
|
49
|
+
def create_taggable_table(options = {})
|
|
50
|
+
tag_name_table = options[:tags] || :tags
|
|
51
|
+
|
|
52
|
+
if !self.connection.table_exists?(tag_name_table)
|
|
53
|
+
self.connection.create_table(tag_name_table) do |t|
|
|
54
|
+
t.column :name, :string
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
taggings_name_table = options[:taggings] || :taggings
|
|
59
|
+
if !self.connection.table_exists?(taggings_name_table)
|
|
60
|
+
self.connection.create_table(taggings_name_table) do |t|
|
|
61
|
+
t.column :tag_id, :integer
|
|
62
|
+
t.column :taggable_id, :integer
|
|
63
|
+
|
|
64
|
+
# You should make sure that the column created is
|
|
65
|
+
# long enough to store the required class names.
|
|
66
|
+
t.column :taggable_type, :string
|
|
67
|
+
|
|
68
|
+
t.column :created_at, :datetime
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
self.connection.add_index :taggings, :tag_id
|
|
72
|
+
self.connection.add_index :taggings, [:taggable_id, :taggable_type]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def drop_taggable_table(options = {})
|
|
77
|
+
tag_name_table = options[:tags] || :tags
|
|
78
|
+
if self.connection.table_exists?(tag_name_table)
|
|
79
|
+
self.connection.drop_table tag_name_table
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
taggings_name_table = options[:taggings] || :taggings
|
|
83
|
+
if self.connection.table_exists?(taggings_name_table)
|
|
84
|
+
self.connection.drop_table taggings_name_table
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
module SingletonMethods
|
|
90
|
+
#Return all avalible tags for a project or global
|
|
91
|
+
#Example: Question.available_tags(:project => @project_id )
|
|
92
|
+
def available_tags(options = {})
|
|
93
|
+
projects = [[options[:project]], options[:projects]].flatten.compact
|
|
94
|
+
limit = options[:limit].to_i.zero? ? 30 : options[:limit].to_i
|
|
95
|
+
scope = Tag.where({})
|
|
96
|
+
class_name = quote_string_value(base_class.name)
|
|
97
|
+
join = []
|
|
98
|
+
join << "JOIN #{Tagging.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id "
|
|
99
|
+
join << "JOIN #{table_name} ON #{table_name}.id = #{Tagging.table_name}.taggable_id
|
|
100
|
+
AND #{Tagging.table_name}.taggable_type = #{class_name} "
|
|
101
|
+
if attribute_names.include?('project_id') && projects.any?
|
|
102
|
+
join << "JOIN #{Project.table_name} ON #{Project.table_name}.id = #{table_name}.project_id"
|
|
103
|
+
scope = scope.where("#{table_name}.project_id IN (%s)", projects.map(&:id).join(','))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if options[:name_like]
|
|
107
|
+
scope = scope.where("LOWER(#{Tag.table_name}.name) LIKE LOWER(?)", "%#{options[:name_like]}%")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
group_fields = ''
|
|
111
|
+
group_fields << ", #{Tag.table_name}.created_at" if Tag.respond_to?(:created_at)
|
|
112
|
+
group_fields << ", #{Tag.table_name}.updated_at" if Tag.respond_to?(:updated_at)
|
|
113
|
+
|
|
114
|
+
if base_class.respond_to?(:visible_condition)
|
|
115
|
+
visible_condition = base_class.visible_condition(User.current)
|
|
116
|
+
if visible_condition.include?('project_id') && join.all? { |jn| jn.exclude?('JOIN projects') }
|
|
117
|
+
join << "JOIN #{Project.table_name} ON #{Project.table_name}.id = #{table_name}.project_id"
|
|
118
|
+
end
|
|
119
|
+
scope = scope.where(visible_condition)
|
|
120
|
+
end
|
|
121
|
+
scope = scope.joins(join.join(' '))
|
|
122
|
+
scope = scope.select("#{Tag.table_name}.*, COUNT(DISTINCT #{Tagging.table_name}.taggable_id) AS count")
|
|
123
|
+
scope = scope.group("#{Tag.table_name}.id, #{Tag.table_name}.name #{group_fields}")
|
|
124
|
+
scope = scope.having('COUNT(*) > 0')
|
|
125
|
+
scope = scope.order("#{Tag.table_name}.name")
|
|
126
|
+
scope = scope.limit(limit)
|
|
127
|
+
scope
|
|
128
|
+
end
|
|
129
|
+
# Returns an array of related tags.
|
|
130
|
+
# Related tags are all the other tags that are found on the models tagged with the provided tags.
|
|
131
|
+
#
|
|
132
|
+
# Pass either a tag, string, or an array of strings or tags.
|
|
133
|
+
#
|
|
134
|
+
# Options:
|
|
135
|
+
# :order - SQL Order how to order the tags. Defaults to "count DESC, tags.name".
|
|
136
|
+
def find_related_tags(tags, options = {})
|
|
137
|
+
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
|
|
138
|
+
|
|
139
|
+
related_models = find_tagged_with(tags)
|
|
140
|
+
|
|
141
|
+
return [] if related_models.blank?
|
|
142
|
+
|
|
143
|
+
related_ids = related_models.map{|c| c.id }.join(",")
|
|
144
|
+
Tag.select(
|
|
145
|
+
"#{Tag.table_name}.*, COUNT(#{Tag.table_name}.id) AS count").joins(
|
|
146
|
+
"JOIN #{Tagging.table_name} ON #{Tagging.table_name}.taggable_type = '#{base_class.name}'
|
|
147
|
+
AND #{Tagging.table_name}.taggable_id IN (#{related_ids})
|
|
148
|
+
AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id").order(
|
|
149
|
+
options[:order] || "count DESC, #{Tag.table_name}.name").group(
|
|
150
|
+
"#{Tag.table_name}.id, #{Tag.table_name}.name HAVING #{Tag.table_name}.name NOT IN (#{tags.map { |n| quote_string_value(n) }.join(",")})")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Pass either a tag, string, or an array of strings or tags.
|
|
154
|
+
#
|
|
155
|
+
# Options:
|
|
156
|
+
# :exclude - Find models that are not tagged with the given tags
|
|
157
|
+
# :match_all - Find models that match all of the given tags, not just one
|
|
158
|
+
# :conditions - A piece of SQL conditions to add to the query
|
|
159
|
+
def find_tagged_with(*args)
|
|
160
|
+
options = find_options_for_find_tagged_with(*args)
|
|
161
|
+
options.blank? ? [] : select(options[:select]).where(options[:conditions]).joins(options[:joins]).order(options[:order]).to_a
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
alias_method :tagged_with, :find_tagged_with
|
|
165
|
+
|
|
166
|
+
def find_options_for_find_tagged_with(tags, options = {})
|
|
167
|
+
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
|
|
168
|
+
options = options.dup
|
|
169
|
+
|
|
170
|
+
return {} if tags.empty?
|
|
171
|
+
|
|
172
|
+
conditions = []
|
|
173
|
+
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
|
|
174
|
+
|
|
175
|
+
taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
|
|
176
|
+
|
|
177
|
+
joins = [
|
|
178
|
+
"INNER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_string_value(base_class.name)}",
|
|
179
|
+
"INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id"
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if options.delete(:exclude)
|
|
183
|
+
conditions << <<-END
|
|
184
|
+
#{table_name}.id NOT IN
|
|
185
|
+
(SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name}
|
|
186
|
+
INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
|
|
187
|
+
WHERE #{tags_condition(tags)} AND #{Tagging.table_name}.taggable_type = #{quote_string_value(base_class.name)})
|
|
188
|
+
END
|
|
189
|
+
else
|
|
190
|
+
if options.delete(:match_all)
|
|
191
|
+
joins << joins_for_match_all_tags(tags)
|
|
192
|
+
else
|
|
193
|
+
conditions << tags_condition(tags, tags_alias)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{ :select => "DISTINCT #{table_name}.* ",
|
|
198
|
+
:joins => joins.join(" "),
|
|
199
|
+
:conditions => conditions.join(" AND ")
|
|
200
|
+
}.reverse_merge!(options)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def joins_for_match_all_tags(tags)
|
|
204
|
+
joins = []
|
|
205
|
+
|
|
206
|
+
tags.each_with_index do |tag, index|
|
|
207
|
+
taggings_alias, tags_alias = "taggings_#{index}", "tags_#{index}"
|
|
208
|
+
|
|
209
|
+
join = <<-END
|
|
210
|
+
INNER JOIN #{Tagging.table_name} #{taggings_alias} ON
|
|
211
|
+
#{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND
|
|
212
|
+
#{taggings_alias}.taggable_type = #{quote_string_value(base_class.name)}
|
|
213
|
+
|
|
214
|
+
INNER JOIN #{Tag.table_name} #{tags_alias} ON
|
|
215
|
+
#{taggings_alias}.tag_id = #{tags_alias}.id AND
|
|
216
|
+
#{tags_alias}.name = ?
|
|
217
|
+
END
|
|
218
|
+
|
|
219
|
+
joins << sanitize_sql([join, tag])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
joins.join(' ')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Calculate the tag counts for all tags.
|
|
226
|
+
#
|
|
227
|
+
# See Tag.counts for available options.
|
|
228
|
+
def tag_counts(options = {})
|
|
229
|
+
# Tag.find(:all, find_options_for_tag_counts(options))
|
|
230
|
+
opt = find_options_for_tag_counts(options)
|
|
231
|
+
Tag.select(opt[:select]).where(opt[:conditions]).joins(opt[:joins]).group(opt[:group]).having(opt[:having]).order(opt[:order]).limit(options[:limit])
|
|
232
|
+
end
|
|
233
|
+
alias_method :all_tag_counts, :tag_counts
|
|
234
|
+
|
|
235
|
+
def find_options_for_tag_counts(options = {})
|
|
236
|
+
options = options.dup
|
|
237
|
+
scope = scope_attributes
|
|
238
|
+
# scope(:find)
|
|
239
|
+
|
|
240
|
+
conditions = []
|
|
241
|
+
conditions << send(:sanitize_sql_for_assignment, options.delete(:conditions)) if options[:conditions]
|
|
242
|
+
conditions << send(:sanitize_sql_for_assignment, scope) if scope
|
|
243
|
+
conditions << "#{Tagging.table_name}.taggable_type = #{quote_string_value(base_class.name)}"
|
|
244
|
+
conditions << type_condition unless descends_from_active_record?
|
|
245
|
+
conditions.delete('')
|
|
246
|
+
conditions.compact!
|
|
247
|
+
conditions = conditions.join(" AND ")
|
|
248
|
+
|
|
249
|
+
joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"]
|
|
250
|
+
joins << options.delete(:joins) if options[:joins].present?
|
|
251
|
+
# joins << scope[:joins] if scope && scope[:joins].present?
|
|
252
|
+
joins = joins.join(" ")
|
|
253
|
+
|
|
254
|
+
options = { :conditions => conditions, :joins => joins }.update(options)
|
|
255
|
+
|
|
256
|
+
Tag.options_for_counts(options)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def caching_tag_list?
|
|
260
|
+
column_names.include?(cached_tag_list_column_name)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def quote_string_value(object)
|
|
266
|
+
connection.quote(object)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def tags_condition(tags, table_name = Tag.table_name)
|
|
270
|
+
condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ")
|
|
271
|
+
"(" + condition + ")" unless condition.blank?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def merge_conditions(*conditions)
|
|
275
|
+
segments = []
|
|
276
|
+
|
|
277
|
+
conditions.each do |condition|
|
|
278
|
+
unless condition.blank?
|
|
279
|
+
sql = sanitize_sql(condition)
|
|
280
|
+
segments << sql unless sql.blank?
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
"(#{segments.join(') AND (')})" unless segments.empty?
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
module InstanceMethods
|
|
289
|
+
def tag_list
|
|
290
|
+
return @tag_list if @tag_list ||= nil
|
|
291
|
+
|
|
292
|
+
if self.class.caching_tag_list? && !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
|
|
293
|
+
@tag_list = TagList.from(cached_value)
|
|
294
|
+
else
|
|
295
|
+
@tag_list = TagList.new(*tags.map(&:name))
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def tag_list=(value)
|
|
300
|
+
@tag_list = TagList.from(value)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def save_cached_tag_list
|
|
304
|
+
if self.class.caching_tag_list?
|
|
305
|
+
self[self.class.cached_tag_list_column_name] = tag_list.to_s
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
#build list from related tags
|
|
310
|
+
def all_tags_list
|
|
311
|
+
tags.pluck(:name)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def save_tags
|
|
315
|
+
return unless @tag_list
|
|
316
|
+
|
|
317
|
+
new_tag_names = @tag_list - tags.map(&:name)
|
|
318
|
+
old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
|
|
319
|
+
|
|
320
|
+
self.class.transaction do
|
|
321
|
+
if old_tags.any?
|
|
322
|
+
taggings.where("tag_id IN (?)", old_tags.map(&:id)).each(&:destroy)
|
|
323
|
+
taggings.reset
|
|
324
|
+
end
|
|
325
|
+
new_tag_names.each do |new_tag_name|
|
|
326
|
+
tags << Tag.find_or_create_with_like_by_name(new_tag_name)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Calculate the tag counts for the tags used by this model.
|
|
334
|
+
#
|
|
335
|
+
# The possible options are the same as the tag_counts class method.
|
|
336
|
+
def tag_counts(options = {})
|
|
337
|
+
return [] if tag_list.blank?
|
|
338
|
+
|
|
339
|
+
options[:conditions] = self.class.send(:merge_conditions, options[:conditions], self.class.send(:tags_condition, tag_list))
|
|
340
|
+
self.class.tag_counts(options)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def reload_with_tag_list(*args) #:nodoc:
|
|
344
|
+
@tag_list = nil
|
|
345
|
+
reload_without_tag_list(*args)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
ActiveRecord::Base.send(:include, Redmineup::ActsAsTaggable::Taggable)
|
|
353
|
+
|
|
354
|
+
# Class aliases
|
|
355
|
+
Redmineup::Tag = Redmineup::ActsAsTaggable::Tag
|
|
356
|
+
Redmineup::TagList = Redmineup::ActsAsTaggable::TagList
|
|
357
|
+
Redmineup::Tagging = Redmineup::ActsAsTaggable::Tagging
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Copyright (c) 2008 Damian Martinelli
|
|
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.
|
|
21
|
+
module Redmineup
|
|
22
|
+
module ActsAsViewed #:nodoc:
|
|
23
|
+
# == acts_as_viewed
|
|
24
|
+
# Adds views count capabilities to any ActiveRecord object.
|
|
25
|
+
# It has the ability to work with objects that have or don't special fields to keep a tally of the
|
|
26
|
+
# viewings for each object.
|
|
27
|
+
# In addition it will by default use the User model as the viewer object and keep the viewings per-user.
|
|
28
|
+
# It can be configured to use another class.
|
|
29
|
+
# The IP address are used to not repeat views from the same ip. Only one view are count by user or IP.
|
|
30
|
+
#
|
|
31
|
+
# Special methods are provided to create the viewings table and if needed, to add the special fields needed
|
|
32
|
+
# to keep per-objects viewings fast for access to viewed objects. Can be easily used in migrations.
|
|
33
|
+
#
|
|
34
|
+
# == Example of usage:
|
|
35
|
+
#
|
|
36
|
+
# class Video < ActiveRecord::Base
|
|
37
|
+
# acts_as_viewed
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# In a controller:
|
|
41
|
+
#
|
|
42
|
+
# bill = User.find_by_name 'bill'
|
|
43
|
+
# batman = Video.find_by_title 'Batman'
|
|
44
|
+
# toystory = Video.find_by_title 'Toy Story'
|
|
45
|
+
#
|
|
46
|
+
# batman.view request.remote_addr, bill
|
|
47
|
+
# toystory.view request.remote_addr, bill
|
|
48
|
+
#
|
|
49
|
+
# batman.view_count # => 1
|
|
50
|
+
#
|
|
51
|
+
#
|
|
52
|
+
module Viewed
|
|
53
|
+
class ViewedError < RuntimeError; end
|
|
54
|
+
|
|
55
|
+
def self.included(base) #:nodoc:
|
|
56
|
+
base.extend(ClassMethods)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module ClassMethods
|
|
60
|
+
# Make the model viewable.
|
|
61
|
+
# The Viewing model, holding the details of the viewings, will be created dynamically if it doesn't exist.
|
|
62
|
+
#
|
|
63
|
+
# * Adds a <tt>has_many :viewings</tt> association to the model for easy retrieval of the detailed viewings.
|
|
64
|
+
# * Adds a <tt>has_many :viewers</tt> association to the object.
|
|
65
|
+
# * Adds a <tt>has_many :viewings</tt> associations to the viewer class.
|
|
66
|
+
#
|
|
67
|
+
# === Options
|
|
68
|
+
# * <tt>:viewing_class</tt> -
|
|
69
|
+
# class of the model used for the viewings. Defaults to Viewing. This class will be dynamically created if not already defined.
|
|
70
|
+
# If the class is predefined, it must have in it the following definitions:
|
|
71
|
+
# <tt>belongs_to :viewed, :polymorphic => true</tt>
|
|
72
|
+
# <tt>belongs_to :viewer, :class_name => 'User', :foreign_key => :viewer_id</tt> replace user with the viewer class if needed.
|
|
73
|
+
# * <tt>:viewer_class</tt> -
|
|
74
|
+
# class of the model that creates the viewing.
|
|
75
|
+
# Defaults to User This class will NOT be created, so it must be defined in the app.
|
|
76
|
+
# Use the IP address to prevent multiple viewings from the same client.
|
|
77
|
+
#
|
|
78
|
+
def up_acts_as_viewed(options = {})
|
|
79
|
+
# don't allow multiple calls
|
|
80
|
+
return if self.included_modules.include?(ActsAsViewed::Viewed::ViewMethods)
|
|
81
|
+
send :include, ActsAsViewed::Viewed::ViewMethods
|
|
82
|
+
|
|
83
|
+
# Create the model for ratings if it doesn't yet exist
|
|
84
|
+
viewing_class = options[:viewing_class] || 'Viewing'
|
|
85
|
+
viewer_class = options[:viewer_class] || 'User'
|
|
86
|
+
|
|
87
|
+
unless Object.const_defined?(viewing_class)
|
|
88
|
+
Object.class_eval <<-EOV
|
|
89
|
+
class #{viewing_class} < ActiveRecord::Base
|
|
90
|
+
belongs_to :viewed, polymorphic: true
|
|
91
|
+
belongs_to :viewer, class_name: '#{viewer_class}', foreign_key: :viewer_id
|
|
92
|
+
end
|
|
93
|
+
EOV
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Rails < 3
|
|
97
|
+
# write_inheritable_attribute( :acts_as_viewed_options ,
|
|
98
|
+
# { :viewing_class => viewing_class,
|
|
99
|
+
# :viewer_class => viewer_class } )
|
|
100
|
+
# class_inheritable_reader :acts_as_viewed_options
|
|
101
|
+
|
|
102
|
+
# Rails >= 3
|
|
103
|
+
class_attribute :acts_as_viewed_options
|
|
104
|
+
self.acts_as_viewed_options = { :viewing_class => viewing_class,
|
|
105
|
+
:viewer_class => viewer_class }
|
|
106
|
+
class_eval do
|
|
107
|
+
has_many :viewings, as: :viewed, dependent: :delete_all, class_name: viewing_class.to_s
|
|
108
|
+
has_many :viewers, through: :viewings, class_name: viewer_class.to_s
|
|
109
|
+
|
|
110
|
+
before_create :init_viewing_fields
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Add to the User (or whatever the viewer is) a has_many viewings
|
|
114
|
+
viewer_as_class = viewer_class.constantize
|
|
115
|
+
return if viewer_as_class.instance_methods.include?('find_in_viewings')
|
|
116
|
+
viewer_as_class.class_eval do
|
|
117
|
+
has_many :viewings, foreign_key: :viewer_id, class_name: viewing_class.to_s, dependent: :delete_all
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
module ViewMethods
|
|
123
|
+
def self.included(base) #:nodoc:
|
|
124
|
+
base.extend ClassMethods
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Is this object viewed already?
|
|
128
|
+
def viewed?
|
|
129
|
+
return (!self.views.nil? && self.views > 0) if attributes.has_key? 'views'
|
|
130
|
+
!viewings.first.nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Get the number of viewings for this object based on the views field,
|
|
134
|
+
# or with a SQL query if the viewed objects doesn't have the views field
|
|
135
|
+
def view_count
|
|
136
|
+
return ("#{self.total_views}(#{self.views})" || 0) if attributes.has_key? 'views'
|
|
137
|
+
viewings.count
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Change views count (total_views and views) if it's existing in object
|
|
141
|
+
# If options[:only_total] == true count of unique views doesn't change
|
|
142
|
+
def increase_views_count(options)
|
|
143
|
+
if attributes.has_key?('views') && attributes.has_key?('total_views')
|
|
144
|
+
target = self
|
|
145
|
+
target.views = ((target.views || 0) + 1) unless options[:only_total]
|
|
146
|
+
target.total_views = ((target.total_views || 0) + 1)
|
|
147
|
+
target.record_timestamps = false
|
|
148
|
+
target.save(:validate => false, :touch => false)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# View the object with or without a viewer - create new or update as needed
|
|
153
|
+
#
|
|
154
|
+
# * <tt>ip</tt> - the viewer ip
|
|
155
|
+
# * <tt>viewer</tt> - an object of the viewer class. Must be valid and with an id to be used. Or nil
|
|
156
|
+
def view(ip, viewer = nil)
|
|
157
|
+
# Sanity checks for the parameters
|
|
158
|
+
viewing_class = acts_as_viewed_options[:viewing_class].constantize
|
|
159
|
+
if viewer && !(acts_as_viewed_options[:viewer_class].constantize === viewer)
|
|
160
|
+
raise ViewedError, "the viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
viewing_class.transaction do
|
|
164
|
+
if !viewed_by? ip, viewer
|
|
165
|
+
view = viewing_class.new
|
|
166
|
+
view.viewer_id = viewer.id if viewer && !viewer.id.nil?
|
|
167
|
+
view.ip = ip
|
|
168
|
+
viewings << view
|
|
169
|
+
view.save
|
|
170
|
+
increase_views_count(:only_total => false)
|
|
171
|
+
else
|
|
172
|
+
increase_views_count(:only_total => true)
|
|
173
|
+
end
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check if an item was already viewed by the given viewer
|
|
179
|
+
def viewed_by?(ip, viewer = nil)
|
|
180
|
+
if viewer && !viewer.nil? && !(acts_as_viewed_options[:viewer_class].constantize === viewer)
|
|
181
|
+
raise ViewedError, "the viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable"
|
|
182
|
+
end
|
|
183
|
+
if viewer && !viewer.id.nil? && !viewer.anonymous?
|
|
184
|
+
return viewings.where("viewer_id = '#{viewer.id}'").any?
|
|
185
|
+
else
|
|
186
|
+
return viewings.where("ip = :ip", ip: ip).any?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def init_viewing_fields #:nodoc:
|
|
193
|
+
self.views ||= 0 if attributes.has_key?('views')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
module ClassMethods
|
|
198
|
+
# Generate the viewings columns on a table, to be used when creating the table
|
|
199
|
+
# in a migration. This is the preferred way to do in a migration that creates
|
|
200
|
+
# new tables as it will make it as part of the table creation, and not generate
|
|
201
|
+
# ALTER TABLE calls after the fact
|
|
202
|
+
def generate_viewings_columns(table)
|
|
203
|
+
table.column :views, :integer # uniq views
|
|
204
|
+
table.column :total_views, :integer
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Create the needed columns for acts_as_viewed.
|
|
208
|
+
# To be used during migration, but can also be used in other places.
|
|
209
|
+
def add_viewings_columns
|
|
210
|
+
if !self.content_columns.find { |c| 'views' == c.name }
|
|
211
|
+
self.connection.add_column table_name, :views, :integer, :default => '0'
|
|
212
|
+
self.connection.add_column table_name, :total_views, :integer, :default => '0'
|
|
213
|
+
self.reset_column_information
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Remove the acts_as_viewed specific columns added with add_viewings_columns
|
|
218
|
+
# To be used during migration, but can also be used in other places
|
|
219
|
+
def remove_viewings_columns
|
|
220
|
+
if self.content_columns.find { |c| 'views' == c.name }
|
|
221
|
+
self.connection.remove_column table_name, :views
|
|
222
|
+
self.connection.remove_column table_name, :total_views
|
|
223
|
+
self.reset_column_information
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Create the viewings table
|
|
228
|
+
# === Options hash:
|
|
229
|
+
# * <tt>:table_name</tt> - use a table name other than viewings
|
|
230
|
+
# To be used during migration, but can also be used in other places
|
|
231
|
+
def create_viewings_table(options = {})
|
|
232
|
+
name = options[:table_name] || :viewings
|
|
233
|
+
if !self.connection.table_exists?(name)
|
|
234
|
+
self.connection.create_table(name) do |t|
|
|
235
|
+
t.column :viewer_id, :integer
|
|
236
|
+
t.column :viewed_id, :integer
|
|
237
|
+
t.column :viewed_type, :string
|
|
238
|
+
t.column :ip, :string, :limit => '24'
|
|
239
|
+
t.column :created_at, :datetime
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
self.connection.add_index(name, :viewer_id)
|
|
243
|
+
self.connection.add_index(name, [:viewed_type, :viewed_id])
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Drop the viewings table.
|
|
248
|
+
# === Options hash:
|
|
249
|
+
# * <tt>:table_name</tt> - the name of the viewings table, defaults to viewings
|
|
250
|
+
# To be used during migration, but can also be used in other places
|
|
251
|
+
def drop_viewings_table(options = {})
|
|
252
|
+
name = options[:table_name] || :viewings
|
|
253
|
+
if self.connection.table_exists?(name)
|
|
254
|
+
self.connection.drop_table(name)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Find all viewings for a specific viewer.
|
|
259
|
+
def find_viewed_by(viewer)
|
|
260
|
+
viewing_class = acts_as_viewed_options[:viewing_class].constantize
|
|
261
|
+
if !(acts_as_viewed_options[:viewer_class].constantize === viewer)
|
|
262
|
+
raise ViewedError, "The viewer object must be the one used when defining acts_as_viewed (or a descendent of it). other objects are not acceptable"
|
|
263
|
+
end
|
|
264
|
+
raise ViewedError, 'Viewer must be a valid and existing object' if viewer.nil? || viewer.id.nil?
|
|
265
|
+
raise ViewedError, 'Viewer must be a valid viewer' if !viewing_class.column_names.include?('viewer_id')
|
|
266
|
+
conds = ['viewed_type = ? AND viewer_id = ?', self.name, viewer.id]
|
|
267
|
+
acts_as_viewed_options[:viewing_class].constantize.where(conds).collect { |r| r.viewed_type.constantize.find_by_id r.viewed.id }
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
ActiveRecord::Base.send :include, Redmineup::ActsAsViewed::Viewed
|