redmineup 1.0.2

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