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.
Files changed (118) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/Gemfile +4 -0
  4. data/README.md +204 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/redmineup_controller.rb +26 -0
  7. data/app/views/redmine_crm/redmineup_calendar/_calendar.html.erb +34 -0
  8. data/app/views/redmineup/_money.html.erb +44 -0
  9. data/app/views/redmineup/settings.html.erb +10 -0
  10. data/bitbucket-pipelines.yml +50 -0
  11. data/config/currency_iso.json +2544 -0
  12. data/config/locales/cs.yml +13 -0
  13. data/config/locales/de.yml +13 -0
  14. data/config/locales/en.yml +13 -0
  15. data/config/locales/es.yml +13 -0
  16. data/config/locales/ru.yml +13 -0
  17. data/config/routes.rb +5 -0
  18. data/doc/CHANGELOG +14 -0
  19. data/doc/LICENSE.txt +339 -0
  20. data/lib/redmineup/acts_as_draftable/draft.rb +40 -0
  21. data/lib/redmineup/acts_as_draftable/up_acts_as_draftable.rb +172 -0
  22. data/lib/redmineup/acts_as_list/list.rb +282 -0
  23. data/lib/redmineup/acts_as_priceable/up_acts_as_priceable.rb +33 -0
  24. data/lib/redmineup/acts_as_taggable/tag.rb +81 -0
  25. data/lib/redmineup/acts_as_taggable/tag_list.rb +111 -0
  26. data/lib/redmineup/acts_as_taggable/tagging.rb +16 -0
  27. data/lib/redmineup/acts_as_taggable/up_acts_as_taggable.rb +357 -0
  28. data/lib/redmineup/acts_as_viewed/up_acts_as_viewed.rb +274 -0
  29. data/lib/redmineup/acts_as_votable/up_acts_as_votable.rb +80 -0
  30. data/lib/redmineup/acts_as_votable/up_acts_as_voter.rb +20 -0
  31. data/lib/redmineup/acts_as_votable/votable.rb +323 -0
  32. data/lib/redmineup/acts_as_votable/vote.rb +28 -0
  33. data/lib/redmineup/acts_as_votable/voter.rb +131 -0
  34. data/lib/redmineup/assets_manager.rb +43 -0
  35. data/lib/redmineup/colors_helper.rb +192 -0
  36. data/lib/redmineup/compatibility/application_controller_patch.rb +33 -0
  37. data/lib/redmineup/compatibility/routing_mapper_patch.rb +25 -0
  38. data/lib/redmineup/currency/formatting.rb +224 -0
  39. data/lib/redmineup/currency/heuristics.rb +151 -0
  40. data/lib/redmineup/currency/loader.rb +23 -0
  41. data/lib/redmineup/currency.rb +450 -0
  42. data/lib/redmineup/engine.rb +4 -0
  43. data/lib/redmineup/helpers/external_assets_helper.rb +20 -0
  44. data/lib/redmineup/helpers/form_tag_helper.rb +88 -0
  45. data/lib/redmineup/helpers/rup_calendar_helper.rb +146 -0
  46. data/lib/redmineup/helpers/tags_helper.rb +13 -0
  47. data/lib/redmineup/helpers/vote_helper.rb +35 -0
  48. data/lib/redmineup/hooks/views_layouts_hook.rb +11 -0
  49. data/lib/redmineup/liquid/drops/attachment_drop.rb +47 -0
  50. data/lib/redmineup/liquid/drops/issue_relations_drop.rb +41 -0
  51. data/lib/redmineup/liquid/drops/issues_drop.rb +217 -0
  52. data/lib/redmineup/liquid/drops/news_drop.rb +54 -0
  53. data/lib/redmineup/liquid/drops/projects_drop.rb +86 -0
  54. data/lib/redmineup/liquid/drops/time_entries_drop.rb +65 -0
  55. data/lib/redmineup/liquid/drops/users_drop.rb +68 -0
  56. data/lib/redmineup/liquid/filters/arrays.rb +254 -0
  57. data/lib/redmineup/liquid/filters/base.rb +249 -0
  58. data/lib/redmineup/liquid/filters/colors.rb +31 -0
  59. data/lib/redmineup/money_helper.rb +66 -0
  60. data/lib/redmineup/patches/liquid_patch.rb +33 -0
  61. data/lib/redmineup/settings/money.rb +46 -0
  62. data/lib/redmineup/settings.rb +53 -0
  63. data/lib/redmineup/version.rb +3 -0
  64. data/lib/redmineup.rb +108 -0
  65. data/redmineup.gemspec +29 -0
  66. data/test/acts_as_draftable/draft_test.rb +29 -0
  67. data/test/acts_as_draftable/rup_acts_as_draftable_test.rb +178 -0
  68. data/test/acts_as_taggable/rup_acts_as_taggable_test.rb +350 -0
  69. data/test/acts_as_taggable/tag_list_test.rb +34 -0
  70. data/test/acts_as_taggable/tag_test.rb +72 -0
  71. data/test/acts_as_taggable/tagging_test.rb +15 -0
  72. data/test/acts_as_viewed/rup_acts_as_viewed_test.rb +47 -0
  73. data/test/acts_as_votable/rup_acts_as_votable_test.rb +19 -0
  74. data/test/acts_as_votable/rup_acts_as_voter_test.rb +14 -0
  75. data/test/acts_as_votable/votable_test.rb +507 -0
  76. data/test/acts_as_votable/voter_test.rb +296 -0
  77. data/test/currency_test.rb +292 -0
  78. data/test/database.yml +17 -0
  79. data/test/fixtures/attachments.yml +14 -0
  80. data/test/fixtures/issues.yml +24 -0
  81. data/test/fixtures/news.yml +8 -0
  82. data/test/fixtures/projects.yml +10 -0
  83. data/test/fixtures/taggings.yml +32 -0
  84. data/test/fixtures/tags.yml +11 -0
  85. data/test/fixtures/users.yml +9 -0
  86. data/test/fixtures/votable_caches.yml +2 -0
  87. data/test/fixtures/votables.yml +4 -0
  88. data/test/fixtures/voters.yml +6 -0
  89. data/test/liquid/drops/attachment_drop_test.rb +15 -0
  90. data/test/liquid/drops/issue_relations_drop_test.rb +24 -0
  91. data/test/liquid/drops/issues_drop_test.rb +38 -0
  92. data/test/liquid/drops/news_drop_test.rb +38 -0
  93. data/test/liquid/drops/projects_drop_test.rb +44 -0
  94. data/test/liquid/drops/uses_drop_test.rb +36 -0
  95. data/test/liquid/filters/arrays_filter_test.rb +31 -0
  96. data/test/liquid/filters/base_filter_test.rb +67 -0
  97. data/test/liquid/filters/colors_filter_test.rb +33 -0
  98. data/test/liquid/liquid_helper.rb +34 -0
  99. data/test/models/attachment.rb +3 -0
  100. data/test/models/issue.rb +21 -0
  101. data/test/models/issue_relation.rb +10 -0
  102. data/test/models/news.rb +3 -0
  103. data/test/models/project.rb +8 -0
  104. data/test/models/user.rb +11 -0
  105. data/test/models/vote_classes.rb +33 -0
  106. data/test/money_helper_test.rb +12 -0
  107. data/test/schema.rb +144 -0
  108. data/test/tags_helper_test.rb +29 -0
  109. data/test/test_helper.rb +66 -0
  110. data/test/vote_helper_test.rb +28 -0
  111. data/vendor/assets/images/money.png +0 -0
  112. data/vendor/assets/images/vcard.png +0 -0
  113. data/vendor/assets/javascripts/Chart.bundle.min.js +16 -0
  114. data/vendor/assets/javascripts/select2.js +2 -0
  115. data/vendor/assets/javascripts/select2_helpers.js +192 -0
  116. data/vendor/assets/stylesheets/money.css +96 -0
  117. data/vendor/assets/stylesheets/select2.css +424 -0
  118. 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