e9_crm 0.1.1 → 0.1.4

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