e9_crm 0.1.1 → 0.1.4

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 (60) hide show
  1. data/app/controllers/e9_crm/advertising_campaigns_controller.rb +1 -0
  2. data/app/controllers/e9_crm/affiliate_campaigns_controller.rb +1 -0
  3. data/app/controllers/e9_crm/campaign_groups_controller.rb +8 -0
  4. data/app/controllers/e9_crm/campaigns_controller.rb +38 -1
  5. data/app/controllers/e9_crm/companies_controller.rb +1 -0
  6. data/app/controllers/e9_crm/contact_emails_controller.rb +3 -7
  7. data/app/controllers/e9_crm/contacts_controller.rb +6 -4
  8. data/app/controllers/e9_crm/dated_costs_controller.rb +1 -0
  9. data/app/controllers/e9_crm/deals_controller.rb +66 -2
  10. data/app/controllers/e9_crm/email_campaigns_controller.rb +1 -0
  11. data/app/controllers/e9_crm/email_templates.controller.rb +1 -0
  12. data/app/controllers/e9_crm/offers_controller.rb +1 -0
  13. data/app/controllers/e9_crm/page_views_controller.rb +1 -0
  14. data/app/controllers/e9_crm/resources_controller.rb +2 -1
  15. data/app/controllers/e9_crm/sales_campaigns_controller.rb +1 -0
  16. data/app/helpers/e9_crm/campaign_groups_helper.rb +8 -0
  17. data/app/helpers/e9_crm/campaigns_helper.rb +42 -11
  18. data/app/helpers/e9_crm/contacts_helper.rb +1 -1
  19. data/app/helpers/e9_crm/deals_helper.rb +25 -0
  20. data/app/models/advertising_campaign.rb +0 -1
  21. data/app/models/campaign.rb +40 -8
  22. data/app/models/campaign_group.rb +6 -0
  23. data/app/models/contact.rb +22 -0
  24. data/app/models/contact_email.rb +29 -18
  25. data/app/models/deal.rb +93 -14
  26. data/app/models/no_campaign.rb +5 -0
  27. data/app/models/page_view.rb +13 -44
  28. data/app/models/tracking_cookie.rb +0 -6
  29. data/app/observers/deal_observer.rb +3 -0
  30. data/app/views/e9_crm/advertising_campaigns/_form_inner.html.haml +1 -0
  31. data/app/views/e9_crm/affiliate_campaigns/_form_inner.html.haml +10 -0
  32. data/app/views/e9_crm/campaign_groups/_footer.html.haml +0 -0
  33. data/app/views/e9_crm/campaign_groups/_header.html.haml +3 -0
  34. data/app/views/e9_crm/campaigns/_footer.html.haml +0 -0
  35. data/app/views/e9_crm/campaigns/_form_inner.html.haml +20 -4
  36. data/app/views/e9_crm/campaigns/_header.html.haml +16 -0
  37. data/app/views/e9_crm/campaigns/_reports_table.html.haml +31 -0
  38. data/app/views/e9_crm/campaigns/_table.html.haml +31 -0
  39. data/app/views/e9_crm/campaigns/reports.html.haml +13 -0
  40. data/app/views/e9_crm/contact_emails/_form_inner.html.haml +1 -1
  41. data/app/views/e9_crm/contacts/_header.html.haml +5 -4
  42. data/app/views/e9_crm/deals/_reports_table.html.haml +86 -0
  43. data/app/views/e9_crm/deals/reports.html.haml +19 -0
  44. data/app/views/e9_crm/deals/reports.js.erb +1 -0
  45. data/app/views/e9_crm/email_campaigns/_form_inner.html.haml +1 -0
  46. data/app/views/e9_crm/page_views/_table.html.haml +7 -7
  47. data/app/views/e9_crm/resources/_table.html.haml +1 -1
  48. data/app/views/e9_crm/sales_campaigns/_form_inner.html.haml +11 -0
  49. data/config/locales/e9.en.yml +11 -1
  50. data/config/locales/en.yml +16 -5
  51. data/config/routes.rb +15 -12
  52. data/e9_crm.gemspec +1 -1
  53. data/lib/e9_crm/rails_extensions.rb +7 -0
  54. data/lib/e9_crm/tracking_controller.rb +69 -52
  55. data/lib/e9_crm/version.rb +1 -1
  56. data/lib/generators/e9_crm/install_generator.rb +1 -1
  57. data/lib/generators/e9_crm/templates/{create_e9_crm_tables.rb → migration.rb} +6 -7
  58. metadata +20 -6
  59. data/app/controllers/e9_crm/record_attributes_controller.rb +0 -3
  60. data/app/controllers/e9_crm/reports_controller.rb +0 -2
@@ -2,4 +2,10 @@
2
2
  #
3
3
  class CampaignGroup < ActiveRecord::Base
4
4
  has_many :campaigns
5
+
6
+ validates :name, :uniqueness => { :ignore_case => true }
7
+
8
+ def to_s
9
+ name
10
+ end
5
11
  end
@@ -1,6 +1,7 @@
1
1
  class Contact < ActiveRecord::Base
2
2
  include E9Tags::Model
3
3
  include E9Rails::ActiveRecord::AttributeSearchable
4
+ include E9Rails::ActiveRecord::Initialization
4
5
 
5
6
  # necessary so contact knows its merge path
6
7
  # NOTE in the future we'll probably want to give contacts public urls and make them 'Linkable'
@@ -128,6 +129,11 @@ class Contact < ActiveRecord::Base
128
129
  .or(User.attr_like_scope_condition(:email, query))
129
130
  )
130
131
  }
132
+
133
+ scope :sales_persons, lambda { where(:status => Status::SalesPerson) }
134
+ scope :affiliates, lambda { where(:status => Status::Affiliate) }
135
+ scope :contacts, lambda { where(:status => Status::Contact) }
136
+
131
137
  scope :by_title, lambda {|val| where(:title => val) }
132
138
  scope :by_company, lambda {|val| where(:company_id => val) }
133
139
  scope :tagged, lambda {|tags|
@@ -138,6 +144,12 @@ class Contact < ActiveRecord::Base
138
144
  end
139
145
  }
140
146
 
147
+ #
148
+ # Carrierwave
149
+ #
150
+ mount_uploader :avatar, AvatarUploader
151
+ def thumb(options = {}); self.avatar end
152
+
141
153
  # The parameters for building the JS template for associated users
142
154
  def self.users_build_parameters # :nodoc:
143
155
  { :status => User::Status::PROSPECT }
@@ -242,6 +254,10 @@ class Contact < ActiveRecord::Base
242
254
 
243
255
  protected
244
256
 
257
+ def _assign_initialization_defaults
258
+ self.status ||= Status::Contact
259
+ end
260
+
245
261
  def ensure_user_references
246
262
  users.each {|u| u.contact = self }
247
263
  end
@@ -255,4 +271,10 @@ class Contact < ActiveRecord::Base
255
271
  attributes.keys.member?('value') && attributes['value'].blank?
256
272
  end
257
273
 
274
+ module Status
275
+ VALUES = %w(contact sales_person affiliate)
276
+ Contact = VALUES[0]
277
+ SalesPerson = VALUES[1]
278
+ Affiliate = VALUES[2]
279
+ end
258
280
  end
@@ -1,14 +1,12 @@
1
1
  # A ContactEmail is a one time mail generated from an EmailTemplate, sent not to a
2
- # list, but to a set of user_ids.
2
+ # list, but to a set of contact_ids.
3
3
  #
4
4
  class ContactEmail < Email
5
5
  include ScheduledEmail
6
6
  before_save :generate_html_body_from_text_body
7
7
 
8
- # NOTE perhaps contact_email should validate contact ids? Then again,
9
- # then it would be necessary to ensure the contacts had primary email
10
- # addresses
11
- validates :user_ids, :presence => true
8
+ validates :contact_ids, :presence => true
9
+ validates :user_ids, :presence => { :unless => lambda {|r| r.contact_ids.blank? } }
12
10
 
13
11
  after_create :send_to_user_ids
14
12
 
@@ -23,23 +21,36 @@ class ContactEmail < Email
23
21
  end
24
22
  end
25
23
 
26
- attr_reader :user_ids
27
-
28
- # user_ids only gets set if it's an array or a properly formatted string
29
- def user_ids=(val)
30
- @user_ids = case val
31
- when /^\[?((\d+,?\s?)+)\]?$/
32
- $1.split(',')
33
- when Array
34
- val
35
- else
36
- []
37
- end
24
+ # contact_ids only gets set if it's an array or a properly formatted string
25
+ def contact_ids=(val)
26
+ @contact_ids = case val
27
+ when /^\[?((\d+,?\s?)+)\]?$/
28
+ $1.split(',')
29
+ when Array
30
+ val
31
+ else
32
+ []
33
+ end
34
+
35
+ # clear user_ids cache
36
+ @user_ids = nil
37
+ end
38
38
 
39
- @user_ids.map!(&:to_i)
39
+ def contact_ids
40
+ (@contact_ids ||= []).map(&:to_i)
40
41
  end
41
42
 
42
43
 
44
+ def user_ids
45
+ @user_ids ||= if @contact_ids.present?
46
+ user_scope = (User.primary.joins(:contact) & Contact.where(:id => @contact_ids))
47
+ user_id_sql = user_scope.select('users.id').to_sql
48
+ User.connection.send(:select_values, user_id_sql, 'User ID Load')
49
+ else
50
+ []
51
+ end
52
+ end
53
+
43
54
  protected
44
55
 
45
56
  def send_to_user_ids
data/app/models/deal.rb CHANGED
@@ -3,25 +3,80 @@
3
3
  #
4
4
  class Deal < ActiveRecord::Base
5
5
  include E9Rails::ActiveRecord::Initialization
6
+ include E9Rails::ActiveRecord::Scopes::Times
6
7
 
7
- belongs_to :campaign, :inverse_of => :deal
8
- belongs_to :tracking_cookie, :inverse_of => :deal
8
+ belongs_to :campaign, :inverse_of => :deals
9
+ belongs_to :tracking_cookie, :inverse_of => :deals
9
10
  belongs_to :offer, :inverse_of => :deals
10
11
 
11
- scope :column_op, lambda {|op, column, value, reverse=false|
12
- conditions = arel_table[column].send(op, value)
13
- conditions = conditions.not if reverse
14
- where(conditions)
15
- }
12
+ money_columns :value
13
+ validates :value, :numericality => true
16
14
 
17
- scope :column_eq, lambda {|column, value, reverse=false|
18
- column_op(:eq, column, value, reverse)
15
+ %w(total_value average_value total_cost average_cost).each do |money_column|
16
+ class_eval("def #{money_column}; (r = read_attribute(:#{money_column})) && Money.new(r) end")
17
+ end
18
+
19
+ scope :reports, lambda {
20
+ select_sql = <<-SELECT.gsub(/\s+/, ' ')
21
+ campaigns.id campaign_id,
22
+ campaigns.type campaign_type,
23
+ campaigns.name campaign_name,
24
+ campaign_groups.name campaign_group,
25
+ SUM(IF(deals.status != 'lead',1,0)) deal_count,
26
+ COUNT(deals.id) lead_count,
27
+ SUM(IF(deals.status='won',1,0)) won_deal_count,
28
+ campaigns.new_visits new_visits,
29
+ campaigns.repeat_visits repeat_visits,
30
+
31
+ SUM(IF(deals.status='won',value,0)) total_value,
32
+ AVG(IF(deals.status='won',value,NULL)) average_value,
33
+
34
+ (CASE campaigns.type
35
+ WHEN "SalesCampaign"
36
+ THEN SUM(campaigns.sales_fee)
37
+ WHEN "AdvertisingCampaign"
38
+ THEN dated_costs.cost
39
+ WHEN "AffiliateCampaign"
40
+ THEN SUM(campaigns.sales_fee +
41
+ campaigns.affiliate_fee)
42
+ ELSE
43
+ 0
44
+ END) total_cost,
45
+
46
+ (CASE campaigns.type
47
+ WHEN "SalesCampaign"
48
+ THEN campaigns.sales_fee
49
+ WHEN "AdvertisingCampaign"
50
+ THEN dated_costs.cost
51
+ WHEN "AffiliateCampaign"
52
+ THEN campaigns.sales_fee +
53
+ campaigns.affiliate_fee
54
+ ELSE
55
+ 0
56
+ END / COUNT(deals.id)) average_cost,
57
+
58
+ FLOOR(AVG(
59
+ DATEDIFF(
60
+ deals.closed_at,
61
+ deals.created_at))) average_elapsed
62
+
63
+ SELECT
64
+
65
+ join_sql = <<-JOINS.gsub(/\s+/, ' ')
66
+ LEFT OUTER JOIN dated_costs
67
+ ON deals.campaign_id = dated_costs.costable_id
68
+ AND dated_costs.costable_type = "Campaign"
69
+
70
+ LEFT OUTER JOIN campaigns
71
+ ON campaigns.id = deals.campaign_id
72
+
73
+ LEFT OUTER JOIN campaign_groups
74
+ ON campaign_groups.id = campaigns.campaign_group_id
75
+ JOINS
76
+
77
+ select(select_sql).joins(join_sql).group(:campaign_id)
19
78
  }
20
79
 
21
- scope :leads, lambda {|reverse=true| column_eq(:status, Status::Lead, !reverse) }
22
- scope :pending, lambda {|reverse=true| column_eq(:status, Status::Pending, !reverse) }
23
- scope :won, lambda {|reverse=true| column_eq(:status, Status::Won, !reverse) }
24
- scope :lost, lambda {|reverse=true| column_eq(:status, Status::Lost, !reverse) }
25
80
 
26
81
  validate do |record|
27
82
  return unless record.status_changed?
@@ -41,12 +96,31 @@ class Deal < ActiveRecord::Base
41
96
  when Status::Won, Status::Lost
42
97
  if record.status_was == Status::Lead
43
98
  record.errors.add(:status, :illegal_conversion)
99
+ elsif record.persisted?
100
+ # "close" isn't happening on new records
101
+ record.send :_do_close
44
102
  end
45
103
  else
46
104
  record.errors.add(:status, :invalid, :options => Status::OPTIONS.join(', '))
47
105
  end
48
106
  end
49
107
 
108
+ scope :column_op, lambda {|op, column, value, reverse=false|
109
+ conditions = arel_table[column].send(op, value)
110
+ conditions = conditions.not if reverse
111
+ where(conditions)
112
+ }
113
+
114
+ scope :column_eq, lambda {|column, value, reverse=false|
115
+ column_op(:eq, column, value, reverse)
116
+ }
117
+
118
+ scope :leads, lambda {|reverse=true| column_eq(:status, Status::Lead, !reverse) }
119
+ scope :pending, lambda {|reverse=true| column_eq(:status, Status::Pending, !reverse) }
120
+ scope :won, lambda {|reverse=true| column_eq(:status, Status::Won, !reverse) }
121
+ scope :lost, lambda {|reverse=true| column_eq(:status, Status::Lost, !reverse) }
122
+
123
+
50
124
  protected
51
125
 
52
126
  def method_missing(method_name, *args)
@@ -66,11 +140,16 @@ class Deal < ActiveRecord::Base
66
140
  self.converted_at = nil
67
141
  notify_observers :before_revert
68
142
  end
143
+
144
+ def _do_close
145
+ self.closed_at = nil
146
+ notify_observers :before_close
147
+ end
69
148
 
70
149
  # Typically, new Deals are 'pending', with the assumption that offers
71
150
  # explicitly create Deals as 'lead' when they convert.
72
151
  def _assign_initialization_defaults
73
- self.status = Status::Pending
152
+ self.status ||= Status::Pending
74
153
  end
75
154
 
76
155
  module Status
@@ -0,0 +1,5 @@
1
+ class NoCampaign < Campaign
2
+ def name
3
+ self.class.human_attribute_name(:name)
4
+ end
5
+ end
@@ -13,56 +13,16 @@
13
13
  # [session] The session id of the request
14
14
  #
15
15
  class PageView < ActiveRecord::Base
16
+ include E9Rails::ActiveRecord::Scopes::Times
17
+
16
18
  belongs_to :tracking_cookie
17
19
 
18
20
  belongs_to :campaign, :inverse_of => :page_views
19
21
  has_one :user, :through => :tracking_cookie
20
22
 
21
- scope :new_visits, lambda {|v=true| where(:new_visit => v) }
22
- scope :repeat_visits, lambda { new_visits(false) }
23
-
24
- scope :from_time, lambda {|*args|
25
- args.flatten!
26
- for_time_range(args.shift, nil, args.extract_options!)
27
- }
28
-
29
- scope :until_time, lambda {|*args|
30
- args.flatten!
31
- for_time_range(nil, args.shift, args.extract_options!)
32
- }
33
-
34
- scope :for_time_range, lambda {|*args|
35
- opts = args.extract_options!
36
-
37
- args.flatten!
38
-
39
- # try to determine a datetime from each arg, skipping #to_time on passed strings because
40
- # it doesn't handle everything DateTime.parse can, e.g. 'yyyy/mm'
41
- args.map! do |t|
42
- t.presence and
23
+ attr_accessor :should_cache
43
24
 
44
- # handle string years 2010, etc.
45
- t.is_a?(String) && /^\d{4}$/.match(t) && Date.civil(t.to_i) ||
46
-
47
- # handle Time etc. (String#to_time doesn't handle yyyy/mm properly)
48
- !t.is_a?(String) && t.respond_to?(:to_time) && t.to_time ||
49
-
50
- # try to parse it
51
- DateTime.parse(t) rescue nil
52
- end
53
-
54
- time_column = opts[:column] || :created_at
55
-
56
- if !args.any?
57
- where('1=0')
58
- elsif args.all?
59
- where(time_column => args[0]..args[1])
60
- elsif args[0]
61
- where(arel_table[time_column].gteq(args[0]))
62
- else
63
- where(arel_table[time_column].lteq(args[1]))
64
- end
65
- }
25
+ after_create :increment_campaign_visit_cache, :if => '!!should_cache'
66
26
 
67
27
  scope :by_user, lambda {|*users|
68
28
  users.flatten!
@@ -76,5 +36,14 @@ class PageView < ActiveRecord::Base
76
36
  where(arel_table[:campaign_id].send *(campaigns.length == 1 ? [:eq, campaigns.pop] : [:in, campaigns]))
77
37
  }
78
38
 
39
+ scope :new_visits, lambda {|v=true| where(:new_visit => v) }
40
+ scope :repeat_visits, lambda { new_visits(false) }
41
+
79
42
  delegate :name, :code, :to => :campaign, :prefix => true, :allow_nil => true
43
+
44
+ protected
45
+
46
+ def increment_campaign_visit_cache
47
+ Campaign.increment_counter(new_visit ? :new_visits : :repeat_visits, campaign_id)
48
+ end
80
49
  end
@@ -45,12 +45,6 @@ class TrackingCookie < ActiveRecord::Base
45
45
  has_many :page_views
46
46
  after_save :generate_hid, :on => :create
47
47
 
48
- attr_accessor :new_visit
49
-
50
- def new_visit?
51
- @new_visit.present?
52
- end
53
-
54
48
  protected
55
49
 
56
50
  def generate_hid
@@ -2,6 +2,9 @@ class DealObserver < ActiveRecord::Observer
2
2
  def before_revert(record)
3
3
  end
4
4
 
5
+ def before_close(record)
6
+ end
7
+
5
8
  def before_convert(record)
6
9
  if email = record.offer && record.offer.conversion_alert_email.presence
7
10
  Rails.logger.debug("Sending Offer Conversion Alert to [#{email}]")
@@ -0,0 +1 @@
1
+ = render 'e9_crm/campaigns/form_inner', :f => f
@@ -0,0 +1,10 @@
1
+ = render 'e9_crm/sales_campaigns/form_inner', :f => f
2
+
3
+ %fieldset
4
+ %legend= e9_t(:affiliate_information_legend, :scope => 'e9_crm.campaigns')
5
+ .field
6
+ = f.label :affilate_id
7
+ = f.collection_select :affiliate_id, Contact.affiliates.all, :id, :name, :prompt => true
8
+ .field
9
+ = f.label :affiliate_fee
10
+ = f.text_field :affiliate_fee
@@ -0,0 +1,3 @@
1
+ .toolbar
2
+ .toolbar-right
3
+ = link_to_new_resource(CampaignGroup)
File without changes
@@ -1,5 +1,21 @@
1
- - resource.attributes.each_pair do |field, value|
2
- - next if ['id', 'created_at', 'updated_at'].include?(field)
1
+ %fieldset
2
+ %legend= e9_t(:campaign_information_legend, :scope => 'e9_crm.campaigns')
3
3
  .field
4
- = f.label field
5
- = f.text_field field
4
+ = f.label :campaign_group_id
5
+ = f.collection_select :campaign_group_id, CampaignGroup.all, :id, :name, :include_blank => 'No Group'
6
+ .field
7
+ = f.label :name
8
+ = f.text_field :name
9
+ - if f.object.new_record?
10
+ .field#campaign_code_field
11
+ = f.label :code
12
+ = f.text_field :code
13
+ .field
14
+ %label{:for => 'campaign_code_hint'}
15
+ = resource_class.human_attribute_name(:code_hint)
16
+ %span.help{:title => e9_t(:code_help, :scope => 'e9_crm.campaigns', :code => E9Crm.query_param)}= t(:inline_help_link)
17
+ #campaign_code_hint= "?#{E9Crm.query_param}=#{f.object.code}"
18
+ .field
19
+ = f.label :active
20
+ = f.check_box :active
21
+
@@ -0,0 +1,16 @@
1
+ .toolbar
2
+ .toolbar-left
3
+ = form_tag(resource_class, :method => :get, :id => 'campaign_search_form') do
4
+ %select{:name => 'type'}
5
+ = campaign_type_select_options
6
+ %select{:name => 'group'}
7
+ = campaign_group_select_options
8
+ %select{:name => 'active'}
9
+ = campaign_active_select_options
10
+
11
+ .toolbar-right
12
+ = form_tag new_advertising_campaign_path, :method => :get, :id => 'new_campaign_form' do
13
+ = label_tag 'new_campaign_type_select', t('activerecord.links.new', :model => Campaign.model_name.human)
14
+ %select{:name => 'type', :id => 'new_campaign_type_select'}
15
+ = campaign_type_select_options(false)
16
+ = submit_tag t(:go)