redmine_crm 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/Gemfile +4 -0
- data/Guardfile +51 -0
- data/LICENSE.txt +339 -0
- data/README.md +57 -0
- data/Rakefile +20 -0
- data/config/currency_iso.json +2516 -0
- data/lib/generators/redmine_crm_migration/redmine_crm_migration_generator.rb +13 -0
- data/lib/generators/redmine_crm_migration/templates/migration.rb +26 -0
- data/lib/redmine_crm.rb +8 -0
- data/lib/redmine_crm/currency.rb +433 -0
- data/lib/redmine_crm/currency/heuristics.rb +151 -0
- data/lib/redmine_crm/currency/loader.rb +24 -0
- data/lib/redmine_crm/rcrm_acts_as_taggable.rb +301 -0
- data/lib/redmine_crm/tag.rb +82 -0
- data/lib/redmine_crm/tag_list.rb +112 -0
- data/lib/redmine_crm/tagging.rb +20 -0
- data/lib/redmine_crm/tags_helper.rb +15 -0
- data/lib/redmine_crm/version.rb +3 -0
- data/redmine_crm.gemspec +30 -0
- data/test/acts_as_taggable_test.rb +384 -0
- data/test/currency_test.rb +292 -0
- data/test/database.yml +17 -0
- data/test/fixtures/issue.rb +9 -0
- data/test/fixtures/issues.yml +12 -0
- data/test/fixtures/taggings.yml +32 -0
- data/test/fixtures/tags.yml +11 -0
- data/test/fixtures/user.rb +3 -0
- data/test/fixtures/users.yml +5 -0
- data/test/schema.rb +25 -0
- data/test/tag_test.rb +64 -0
- data/test/tagging_test.rb +14 -0
- data/test/tags_helper_test.rb +29 -0
- data/test/test_helper.rb +112 -0
- metadata +219 -0
@@ -0,0 +1,151 @@
|
|
1
|
+
module RedmineCrm
|
2
|
+
class Currency
|
3
|
+
module Heuristics
|
4
|
+
|
5
|
+
# An robust and efficient algorithm for finding currencies in
|
6
|
+
# text. Using several algorithms it can find symbols, iso codes and
|
7
|
+
# even names of currencies.
|
8
|
+
# Although not recommendable, it can also attempt to find the given
|
9
|
+
# currency in an entire sentence
|
10
|
+
#
|
11
|
+
# Returns: Array (matched results)
|
12
|
+
def analyze(str)
|
13
|
+
return Analyzer.new(str, search_tree).process
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# Build a search tree from the currency database
|
19
|
+
def search_tree
|
20
|
+
@_search_tree ||= {
|
21
|
+
:by_symbol => currencies_by_symbol,
|
22
|
+
:by_iso_code => currencies_by_iso_code,
|
23
|
+
:by_name => currencies_by_name
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def currencies_by_symbol
|
28
|
+
{}.tap do |r|
|
29
|
+
table.each do |dummy, c|
|
30
|
+
symbol = (c[:symbol]||"").downcase
|
31
|
+
symbol.chomp!('.')
|
32
|
+
(r[symbol] ||= []) << c
|
33
|
+
|
34
|
+
(c[:alternate_symbols]||[]).each do |ac|
|
35
|
+
ac = ac.downcase
|
36
|
+
ac.chomp!('.')
|
37
|
+
(r[ac] ||= []) << c
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def currencies_by_iso_code
|
44
|
+
{}.tap do |r|
|
45
|
+
table.each do |dummy,c|
|
46
|
+
(r[c[:iso_code].downcase] ||= []) << c
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def currencies_by_name
|
52
|
+
{}.tap do |r|
|
53
|
+
table.each do |dummy,c|
|
54
|
+
name_parts = c[:name].downcase.split
|
55
|
+
name_parts.each {|part| part.chomp!('.')}
|
56
|
+
|
57
|
+
# construct one branch per word
|
58
|
+
root = r
|
59
|
+
while name_part = name_parts.shift
|
60
|
+
root = (root[name_part] ||= {})
|
61
|
+
end
|
62
|
+
|
63
|
+
# the leaf is a currency
|
64
|
+
(root[:value] ||= []) << c
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class Analyzer
|
70
|
+
attr_reader :search_tree, :words
|
71
|
+
attr_accessor :str, :currencies
|
72
|
+
|
73
|
+
def initialize str, search_tree
|
74
|
+
@str = (str||'').dup
|
75
|
+
@search_tree = search_tree
|
76
|
+
@currencies = []
|
77
|
+
end
|
78
|
+
|
79
|
+
def process
|
80
|
+
format
|
81
|
+
return [] if str.empty?
|
82
|
+
|
83
|
+
search_by_symbol
|
84
|
+
search_by_iso_code
|
85
|
+
search_by_name
|
86
|
+
|
87
|
+
prepare_reply
|
88
|
+
end
|
89
|
+
|
90
|
+
def format
|
91
|
+
str.gsub!(/[\r\n\t]/,'')
|
92
|
+
str.gsub!(/[0-9][\.,:0-9]*[0-9]/,'')
|
93
|
+
str.gsub!(/[0-9]/, '')
|
94
|
+
str.downcase!
|
95
|
+
@words = str.split
|
96
|
+
@words.each {|word| word.chomp!('.'); word.chomp!(',') }
|
97
|
+
end
|
98
|
+
|
99
|
+
def search_by_symbol
|
100
|
+
words.each do |word|
|
101
|
+
if found = search_tree[:by_symbol][word]
|
102
|
+
currencies.concat(found)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def search_by_iso_code
|
108
|
+
words.each do |word|
|
109
|
+
if found = search_tree[:by_iso_code][word]
|
110
|
+
currencies.concat(found)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def search_by_name
|
116
|
+
# remember, the search tree by name is a construct of branches and leaf!
|
117
|
+
# We need to try every combination of words within the sentence, so we
|
118
|
+
# end up with a x^2 equation, which should be fine as most names are either
|
119
|
+
# one or two words, and this is multiplied with the words of given sentence
|
120
|
+
|
121
|
+
search_words = words.dup
|
122
|
+
|
123
|
+
while search_words.length > 0
|
124
|
+
root = search_tree[:by_name]
|
125
|
+
|
126
|
+
search_words.each do |word|
|
127
|
+
if root = root[word]
|
128
|
+
if root[:value]
|
129
|
+
currencies.concat(root[:value])
|
130
|
+
end
|
131
|
+
else
|
132
|
+
break
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
search_words.delete_at(0)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def prepare_reply
|
141
|
+
codes = currencies.map do |currency|
|
142
|
+
currency[:iso_code]
|
143
|
+
end
|
144
|
+
codes.uniq!
|
145
|
+
codes.sort!
|
146
|
+
codes
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module RedmineCrm
|
2
|
+
class Currency
|
3
|
+
module Loader
|
4
|
+
DATA_PATH = File.expand_path("../../../../config", __FILE__)
|
5
|
+
|
6
|
+
# Loads and returns the currencies stored in JSON files in the config directory.
|
7
|
+
#
|
8
|
+
# @return [Hash]
|
9
|
+
def load_currencies
|
10
|
+
currencies = parse_currency_file("currency_iso.json")
|
11
|
+
# currencies.merge! parse_currency_file("currency_non_iso.json")
|
12
|
+
# currencies.merge! parse_currency_file("currency_backwards_compatible.json")
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def parse_currency_file(filename)
|
18
|
+
json = File.read("#{DATA_PATH}/#{filename}")
|
19
|
+
json.force_encoding(::Encoding::UTF_8) if defined?(::Encoding)
|
20
|
+
JSON.parse(json, :symbolize_names => true)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
# module ActiveRecord #:nodoc:
|
4
|
+
module RedmineCrm
|
5
|
+
module Acts #:nodoc:
|
6
|
+
module Taggable #:nodoc:
|
7
|
+
def self.included(base)
|
8
|
+
base.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
|
13
|
+
def rcrm_acts_as_taggable
|
14
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy, :class_name => '::RedmineCrm::Tagging'#, :include => :tag
|
15
|
+
has_many :tags, :through => :taggings, :class_name => '::RedmineCrm::Tag'
|
16
|
+
|
17
|
+
before_save :save_cached_tag_list
|
18
|
+
|
19
|
+
after_create :save_tags
|
20
|
+
after_update :save_tags
|
21
|
+
|
22
|
+
include RedmineCrm::Acts::Taggable::InstanceMethods
|
23
|
+
extend RedmineCrm::Acts::Taggable::SingletonMethods
|
24
|
+
|
25
|
+
alias_method_chain :reload, :tag_list
|
26
|
+
end
|
27
|
+
|
28
|
+
def cached_tag_list_column_name
|
29
|
+
"cached_tag_list"
|
30
|
+
end
|
31
|
+
|
32
|
+
def set_cached_tag_list_column_name(value = nil, &block)
|
33
|
+
define_attr_method :cached_tag_list_column_name, value, &block
|
34
|
+
end
|
35
|
+
|
36
|
+
# Create the taggable tables
|
37
|
+
# === Options hash:
|
38
|
+
# * <tt>:table_name</tt> - use a table name other than viewings
|
39
|
+
# To be used during migration, but can also be used in other places
|
40
|
+
def create_taggable_table options = {}
|
41
|
+
tag_name_table = options[:tags] || :tags
|
42
|
+
|
43
|
+
if !self.connection.table_exists?(tag_name_table)
|
44
|
+
self.connection.create_table(tag_name_table) do |t|
|
45
|
+
t.column :name, :string
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
taggings_name_table = options[:taggings] || :taggings
|
50
|
+
if !self.connection.table_exists?(taggings_name_table)
|
51
|
+
self.connection.create_table(taggings_name_table) do |t|
|
52
|
+
t.column :tag_id, :integer
|
53
|
+
t.column :taggable_id, :integer
|
54
|
+
|
55
|
+
# You should make sure that the column created is
|
56
|
+
# long enough to store the required class names.
|
57
|
+
t.column :taggable_type, :string
|
58
|
+
|
59
|
+
t.column :created_at, :datetime
|
60
|
+
end
|
61
|
+
|
62
|
+
self.connection.add_index :taggings, :tag_id
|
63
|
+
self.connection.add_index :taggings, [:taggable_id, :taggable_type]
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
def drop_taggable_table options = {}
|
69
|
+
tag_name_table = options[:tags] || :tags
|
70
|
+
if !self.connection.table_exists?(tag_name_table)
|
71
|
+
self.connection.drop_table tag_name_table
|
72
|
+
end
|
73
|
+
taggings_name_table = options[:taggings] || :taggings
|
74
|
+
if !self.connection.table_exists?(taggings_name_table)
|
75
|
+
self.connection.drop_table taggings_name_table
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
module SingletonMethods
|
82
|
+
# Returns an array of related tags.
|
83
|
+
# Related tags are all the other tags that are found on the models tagged with the provided tags.
|
84
|
+
#
|
85
|
+
# Pass either a tag, string, or an array of strings or tags.
|
86
|
+
#
|
87
|
+
# Options:
|
88
|
+
# :order - SQL Order how to order the tags. Defaults to "count DESC, tags.name".
|
89
|
+
def find_related_tags(tags, options = {})
|
90
|
+
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
|
91
|
+
|
92
|
+
related_models = find_tagged_with(tags)
|
93
|
+
|
94
|
+
return [] if related_models.blank?
|
95
|
+
|
96
|
+
related_ids = related_models.ids.join(",")
|
97
|
+
Tag.select( #find(:all, options.merge({
|
98
|
+
"#{Tag.table_name}.*, COUNT(#{Tag.table_name}.id) AS count").joins(
|
99
|
+
"JOIN #{Tagging.table_name} ON #{Tagging.table_name}.taggable_type = '#{base_class.name}'
|
100
|
+
AND #{Tagging.table_name}.taggable_id IN (#{related_ids})
|
101
|
+
AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id").order(
|
102
|
+
options[:order] || "count DESC, #{Tag.table_name}.name").group(
|
103
|
+
"#{Tag.table_name}.id, #{Tag.table_name}.name HAVING #{Tag.table_name}.name NOT IN (#{tags.map { |n| quote_value(n, nil) }.join(",")})")
|
104
|
+
# }))
|
105
|
+
end
|
106
|
+
|
107
|
+
# Pass either a tag, string, or an array of strings or tags.
|
108
|
+
#
|
109
|
+
# Options:
|
110
|
+
# :exclude - Find models that are not tagged with the given tags
|
111
|
+
# :match_all - Find models that match all of the given tags, not just one
|
112
|
+
# :conditions - A piece of SQL conditions to add to the query
|
113
|
+
def find_tagged_with(*args)
|
114
|
+
options = find_options_for_find_tagged_with(*args)
|
115
|
+
options.blank? ? [] : select(options[:select]).where(options[:conditions]).joins(options[:joins]).order(options[:order])
|
116
|
+
# find(:all, options)
|
117
|
+
end
|
118
|
+
alias_method :tagged_with, :find_tagged_with
|
119
|
+
|
120
|
+
def find_options_for_find_tagged_with(tags, options = {})
|
121
|
+
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
|
122
|
+
options = options.dup
|
123
|
+
|
124
|
+
return {} if tags.empty?
|
125
|
+
|
126
|
+
conditions = []
|
127
|
+
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
|
128
|
+
|
129
|
+
taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
|
130
|
+
|
131
|
+
joins = [
|
132
|
+
"INNER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name, nil)}",
|
133
|
+
"INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id"
|
134
|
+
]
|
135
|
+
|
136
|
+
if options.delete(:exclude)
|
137
|
+
conditions << <<-END
|
138
|
+
#{table_name}.id NOT IN
|
139
|
+
(SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name}
|
140
|
+
INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
|
141
|
+
WHERE #{tags_condition(tags)} AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name, nil)})
|
142
|
+
END
|
143
|
+
else
|
144
|
+
if options.delete(:match_all)
|
145
|
+
joins << joins_for_match_all_tags(tags)
|
146
|
+
else
|
147
|
+
conditions << tags_condition(tags, tags_alias)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
{ :select => "DISTINCT #{table_name}.* ",
|
152
|
+
:joins => joins.join(" "),
|
153
|
+
:conditions => conditions.join(" AND ")
|
154
|
+
}.reverse_merge!(options)
|
155
|
+
end
|
156
|
+
|
157
|
+
def joins_for_match_all_tags(tags)
|
158
|
+
joins = []
|
159
|
+
|
160
|
+
tags.each_with_index do |tag, index|
|
161
|
+
taggings_alias, tags_alias = "taggings_#{index}", "tags_#{index}"
|
162
|
+
|
163
|
+
join = <<-END
|
164
|
+
INNER JOIN #{Tagging.table_name} #{taggings_alias} ON
|
165
|
+
#{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND
|
166
|
+
#{taggings_alias}.taggable_type = #{quote_value(base_class.name, nil)}
|
167
|
+
|
168
|
+
INNER JOIN #{Tag.table_name} #{tags_alias} ON
|
169
|
+
#{taggings_alias}.tag_id = #{tags_alias}.id AND
|
170
|
+
#{tags_alias}.name = ?
|
171
|
+
END
|
172
|
+
|
173
|
+
joins << sanitize_sql([join, tag])
|
174
|
+
end
|
175
|
+
|
176
|
+
joins.join(" ")
|
177
|
+
end
|
178
|
+
|
179
|
+
# Calculate the tag counts for all tags.
|
180
|
+
#
|
181
|
+
# See Tag.counts for available options.
|
182
|
+
def tag_counts(options = {})
|
183
|
+
# Tag.find(:all, find_options_for_tag_counts(options))
|
184
|
+
opt = find_options_for_tag_counts(options)
|
185
|
+
Tag.select(opt[:select]).where(opt[:conditions]).joins(opt[:joins]).group(opt[:group]).having(opt[:having]).order(opt[:order]).limit(options[:limit])
|
186
|
+
end
|
187
|
+
alias_method :all_tag_counts, :tag_counts
|
188
|
+
|
189
|
+
def find_options_for_tag_counts(options = {})
|
190
|
+
options = options.dup
|
191
|
+
scope = scope_attributes
|
192
|
+
# scope(:find)
|
193
|
+
|
194
|
+
conditions = []
|
195
|
+
conditions << send(:sanitize_conditions, options.delete(:conditions)) if options[:conditions]
|
196
|
+
conditions << send(:sanitize_conditions, scope) if scope
|
197
|
+
conditions << "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name, nil)}"
|
198
|
+
conditions << type_condition unless descends_from_active_record?
|
199
|
+
conditions.compact!
|
200
|
+
conditions = conditions.join(" AND ")
|
201
|
+
|
202
|
+
joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"]
|
203
|
+
joins << options.delete(:joins) if options[:joins].present?
|
204
|
+
# joins << scope[:joins] if scope && scope[:joins].present?
|
205
|
+
joins = joins.join(" ")
|
206
|
+
|
207
|
+
options = { :conditions => conditions, :joins => joins }.update(options)
|
208
|
+
|
209
|
+
Tag.options_for_counts(options)
|
210
|
+
end
|
211
|
+
|
212
|
+
def caching_tag_list?
|
213
|
+
column_names.include?(cached_tag_list_column_name)
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
def tags_condition(tags, table_name = Tag.table_name)
|
218
|
+
condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ")
|
219
|
+
"(" + condition + ")" unless condition.blank?
|
220
|
+
end
|
221
|
+
|
222
|
+
def merge_conditions(*conditions)
|
223
|
+
segments = []
|
224
|
+
|
225
|
+
conditions.each do |condition|
|
226
|
+
unless condition.blank?
|
227
|
+
sql = sanitize_sql(condition)
|
228
|
+
segments << sql unless sql.blank?
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
"(#{segments.join(') AND (')})" unless segments.empty?
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
module InstanceMethods
|
237
|
+
def tag_list
|
238
|
+
return @tag_list if @tag_list
|
239
|
+
|
240
|
+
if self.class.caching_tag_list? and !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
|
241
|
+
@tag_list = TagList.from(cached_value)
|
242
|
+
else
|
243
|
+
@tag_list = TagList.new(*tags.map(&:name))
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def tag_list=(value)
|
248
|
+
@tag_list = TagList.from(value)
|
249
|
+
end
|
250
|
+
|
251
|
+
def save_cached_tag_list
|
252
|
+
if self.class.caching_tag_list?
|
253
|
+
self[self.class.cached_tag_list_column_name] = tag_list.to_s
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
#build list from related tags
|
258
|
+
def all_tags_list
|
259
|
+
tags.pluck(:name)
|
260
|
+
end
|
261
|
+
|
262
|
+
def save_tags
|
263
|
+
return unless @tag_list
|
264
|
+
|
265
|
+
new_tag_names = @tag_list - tags.map(&:name)
|
266
|
+
old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
|
267
|
+
|
268
|
+
self.class.transaction do
|
269
|
+
if old_tags.any?
|
270
|
+
taggings.where("tag_id IN (?)", old_tags.map(&:id)).each(&:destroy)
|
271
|
+
taggings.reset
|
272
|
+
end
|
273
|
+
|
274
|
+
new_tag_names.each do |new_tag_name|
|
275
|
+
tags << Tag.find_or_create_with_like_by_name(new_tag_name)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
true
|
280
|
+
end
|
281
|
+
|
282
|
+
# Calculate the tag counts for the tags used by this model.
|
283
|
+
#
|
284
|
+
# The possible options are the same as the tag_counts class method.
|
285
|
+
def tag_counts(options = {})
|
286
|
+
return [] if tag_list.blank?
|
287
|
+
|
288
|
+
options[:conditions] = self.class.send(:merge_conditions, options[:conditions], self.class.send(:tags_condition, tag_list))
|
289
|
+
self.class.tag_counts(options)
|
290
|
+
end
|
291
|
+
|
292
|
+
def reload_with_tag_list(*args) #:nodoc:
|
293
|
+
@tag_list = nil
|
294
|
+
reload_without_tag_list(*args)
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
ActiveRecord::Base.send(:include, RedmineCrm::Acts::Taggable)
|