e9_crm 0.1.1
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.
- data/.gitignore +3 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +105 -0
- data/README.md +31 -0
- data/Rakefile +2 -0
- data/app/controllers/e9_crm/advertising_campaigns_controller.rb +3 -0
- data/app/controllers/e9_crm/affiliate_campaigns_controller.rb +3 -0
- data/app/controllers/e9_crm/base_controller.rb +5 -0
- data/app/controllers/e9_crm/campaign_groups_controller.rb +3 -0
- data/app/controllers/e9_crm/campaigns_controller.rb +4 -0
- data/app/controllers/e9_crm/companies_controller.rb +3 -0
- data/app/controllers/e9_crm/contact_emails_controller.rb +38 -0
- data/app/controllers/e9_crm/contact_merges_controller.rb +26 -0
- data/app/controllers/e9_crm/contacts_controller.rb +46 -0
- data/app/controllers/e9_crm/dated_costs_controller.rb +4 -0
- data/app/controllers/e9_crm/deals_controller.rb +19 -0
- data/app/controllers/e9_crm/email_campaigns_controller.rb +3 -0
- data/app/controllers/e9_crm/email_templates.controller.rb +3 -0
- data/app/controllers/e9_crm/menu_options_controller.rb +26 -0
- data/app/controllers/e9_crm/offers_controller.rb +3 -0
- data/app/controllers/e9_crm/page_views_controller.rb +20 -0
- data/app/controllers/e9_crm/record_attributes_controller.rb +3 -0
- data/app/controllers/e9_crm/reports_controller.rb +2 -0
- data/app/controllers/e9_crm/resources_controller.rb +43 -0
- data/app/controllers/e9_crm/sales_campaigns_controller.rb +3 -0
- data/app/helpers/e9_crm/base_helper.rb +77 -0
- data/app/helpers/e9_crm/campaigns_helper.rb +14 -0
- data/app/helpers/e9_crm/contact_merges_helper.rb +17 -0
- data/app/helpers/e9_crm/contacts_helper.rb +27 -0
- data/app/helpers/e9_crm/deals_helper.rb +15 -0
- data/app/helpers/e9_crm/menu_options_helper.rb +11 -0
- data/app/helpers/e9_crm/page_views_helper.rb +2 -0
- data/app/models/address_attribute.rb +4 -0
- data/app/models/advertising_campaign.rb +15 -0
- data/app/models/affiliate.rb +4 -0
- data/app/models/affiliate_campaign.rb +15 -0
- data/app/models/campaign.rb +34 -0
- data/app/models/campaign_group.rb +5 -0
- data/app/models/company.rb +4 -0
- data/app/models/contact.rb +258 -0
- data/app/models/contact_email.rb +49 -0
- data/app/models/dated_cost.rb +9 -0
- data/app/models/deal.rb +83 -0
- data/app/models/email_campaign.rb +13 -0
- data/app/models/email_template.rb +7 -0
- data/app/models/instant_messaging_handle_attribute.rb +4 -0
- data/app/models/menu_option.rb +33 -0
- data/app/models/offer.rb +50 -0
- data/app/models/page_view.rb +80 -0
- data/app/models/phone_number_attribute.rb +4 -0
- data/app/models/record_attribute.rb +41 -0
- data/app/models/sales_campaign.rb +15 -0
- data/app/models/sales_person.rb +4 -0
- data/app/models/tracking_cookie.rb +61 -0
- data/app/models/website_attribute.rb +4 -0
- data/app/observers/deal_observer.rb +11 -0
- data/app/uploaders/file_uploader.rb +34 -0
- data/app/views/e9_crm/campaigns/_form_inner.html.haml +5 -0
- data/app/views/e9_crm/contact_emails/_form.html.haml +7 -0
- data/app/views/e9_crm/contact_emails/_form_inner.html.haml +11 -0
- data/app/views/e9_crm/contact_emails/destroy.js.erb +3 -0
- data/app/views/e9_crm/contact_emails/send_email.js.erb +1 -0
- data/app/views/e9_crm/contact_merges/_field.html.haml +10 -0
- data/app/views/e9_crm/contact_merges/_form.html.haml +10 -0
- data/app/views/e9_crm/contact_merges/new.html.haml +2 -0
- data/app/views/e9_crm/contacts/_details.html.haml +22 -0
- data/app/views/e9_crm/contacts/_form_inner.html.haml +51 -0
- data/app/views/e9_crm/contacts/_header.html.haml +19 -0
- data/app/views/e9_crm/contacts/_tag_table.html.haml +15 -0
- data/app/views/e9_crm/contacts/index.html.haml +13 -0
- data/app/views/e9_crm/contacts/index.js.erb +5 -0
- data/app/views/e9_crm/contacts/merge.html.haml +1 -0
- data/app/views/e9_crm/contacts/templates.js.erb +1 -0
- data/app/views/e9_crm/deals/_form_inner.html.haml +5 -0
- data/app/views/e9_crm/deals/_header.html.haml +0 -0
- data/app/views/e9_crm/deals/leads.html.haml +13 -0
- data/app/views/e9_crm/email_templates/_form_inner.html.haml +9 -0
- data/app/views/e9_crm/menu_options/_form_inner.html.haml +6 -0
- data/app/views/e9_crm/menu_options/_header.html.haml +8 -0
- data/app/views/e9_crm/offers/_form.html.haml +7 -0
- data/app/views/e9_crm/offers/_form_inner.html.haml +42 -0
- data/app/views/e9_crm/offers/_form_inner.html.haml.bak +43 -0
- data/app/views/e9_crm/page_views/_table.html.haml +25 -0
- data/app/views/e9_crm/record_attributes/_address_attribute.html.haml +5 -0
- data/app/views/e9_crm/record_attributes/_instant_messaging_handle_attribute.html.haml +5 -0
- data/app/views/e9_crm/record_attributes/_phone_number_attribute.html.haml +5 -0
- data/app/views/e9_crm/record_attributes/_record_attribute.html.haml +10 -0
- data/app/views/e9_crm/record_attributes/_templates.js.erb +12 -0
- data/app/views/e9_crm/record_attributes/_user.html.haml +23 -0
- data/app/views/e9_crm/record_attributes/_website_attribute.html.haml +5 -0
- data/app/views/e9_crm/resources/_footer.html.haml +1 -0
- data/app/views/e9_crm/resources/_form.html.haml +7 -0
- data/app/views/e9_crm/resources/_form_inner.html.haml +5 -0
- data/app/views/e9_crm/resources/_header.html.haml +0 -0
- data/app/views/e9_crm/resources/_table.html.haml +21 -0
- data/app/views/e9_crm/resources/create.js.erb +6 -0
- data/app/views/e9_crm/resources/destroy.js.erb +3 -0
- data/app/views/e9_crm/resources/edit.html.haml +2 -0
- data/app/views/e9_crm/resources/index.html.haml +13 -0
- data/app/views/e9_crm/resources/index.js.erb +1 -0
- data/app/views/e9_crm/resources/new.html.haml +2 -0
- data/app/views/e9_crm/resources/show.html.haml +2 -0
- data/app/views/e9_crm/resources/update.js.erb +5 -0
- data/config/initializers/inflections.rb +3 -0
- data/config/locales/e9.en.yml +28 -0
- data/config/locales/en.yml +63 -0
- data/config/routes.rb +61 -0
- data/e9_crm.gemspec +29 -0
- data/lib/e9_crm/model.rb +63 -0
- data/lib/e9_crm/rails_extensions.rb +98 -0
- data/lib/e9_crm/tracking_controller.rb +78 -0
- data/lib/e9_crm/version.rb +3 -0
- data/lib/e9_crm.rb +59 -0
- data/lib/generators/e9_crm/install_generator.rb +32 -0
- data/lib/generators/e9_crm/templates/create_e9_crm_tables.rb +107 -0
- data/lib/generators/e9_crm/templates/initializer.rb +4 -0
- data/lib/generators/e9_crm/templates/javascript.js +187 -0
- data/test/functional/e9_crm/campaign_types_controller_test.rb +49 -0
- data/test/functional/home_controller_test.rb +8 -0
- metadata +283 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# The base campaign class
|
|
2
|
+
#
|
|
3
|
+
# A Campaign is created with a unique code, which in turn generates PageViews,
|
|
4
|
+
# which maybe become Leads (Offers), which may result in Deals.
|
|
5
|
+
#
|
|
6
|
+
class Campaign < ActiveRecord::Base
|
|
7
|
+
include E9Rails::ActiveRecord::STI
|
|
8
|
+
|
|
9
|
+
belongs_to :campaign_group
|
|
10
|
+
has_many :deals, :inverse_of => :campaign
|
|
11
|
+
has_many :page_views, :inverse_of => :campaign
|
|
12
|
+
|
|
13
|
+
# NOTE tracking cookie code changes with new visits
|
|
14
|
+
has_many :tracking_cookies, :foreign_key => :code, :primary_key => :code, :class_name => 'TrackingCookie'
|
|
15
|
+
|
|
16
|
+
validates :code, :presence => true,
|
|
17
|
+
:length => { :maximum => 32 },
|
|
18
|
+
:uniqueness => { :ignore_case => true }
|
|
19
|
+
|
|
20
|
+
scope :active, lambda { where(:active => true) }
|
|
21
|
+
scope :inactive, lambda { where(:active => false) }
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# The sum cost of this campaign
|
|
25
|
+
# (Must be implemented by subclasses)
|
|
26
|
+
#
|
|
27
|
+
def cost
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s
|
|
32
|
+
name
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
class Contact < ActiveRecord::Base
|
|
2
|
+
include E9Tags::Model
|
|
3
|
+
include E9Rails::ActiveRecord::AttributeSearchable
|
|
4
|
+
|
|
5
|
+
# necessary so contact knows its merge path
|
|
6
|
+
# NOTE in the future we'll probably want to give contacts public urls and make them 'Linkable'
|
|
7
|
+
include Rails.application.routes.url_helpers
|
|
8
|
+
|
|
9
|
+
before_validation :ensure_user_references
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Associations
|
|
13
|
+
#
|
|
14
|
+
belongs_to :company
|
|
15
|
+
|
|
16
|
+
has_many :users, :inverse_of => :contact, :dependent => :nullify do
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Resets the primary user on a contact
|
|
20
|
+
#
|
|
21
|
+
# == Parameters
|
|
22
|
+
#
|
|
23
|
+
# [options (Hash)] An options hash containing either the :id or :index
|
|
24
|
+
# of the User to set as primary. If neither is set it
|
|
25
|
+
# will default the first User designated primary in the
|
|
26
|
+
# list, or the first user in the list if no such user
|
|
27
|
+
# exists.
|
|
28
|
+
#
|
|
29
|
+
# Also accepts a :save option, which will force save
|
|
30
|
+
# the User with no validation.
|
|
31
|
+
#
|
|
32
|
+
def reset_primary(options = {})
|
|
33
|
+
return if empty?
|
|
34
|
+
options.symbolize_keys!
|
|
35
|
+
options.slice!(:id, :index, :save)
|
|
36
|
+
|
|
37
|
+
should_save = options.delete(:save)
|
|
38
|
+
|
|
39
|
+
if options.empty?
|
|
40
|
+
if p = primary.first
|
|
41
|
+
options[:id] = p.id
|
|
42
|
+
else
|
|
43
|
+
options[:index] = 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
each_with_index do |user, i|
|
|
48
|
+
if options[:id]
|
|
49
|
+
user.options.primary = options[:id] == user.id
|
|
50
|
+
else
|
|
51
|
+
user.options.primary = options[:index] == i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
user.save(:validate => false) if should_save && user.options_changed?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Resets the primary User, forcing :save
|
|
60
|
+
#
|
|
61
|
+
def reset_primary!(options = {})
|
|
62
|
+
reset_primary options.merge(:save => true)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Clears all users of primary status, used when merging/resetting Contacts.
|
|
67
|
+
# Does not force save.
|
|
68
|
+
#
|
|
69
|
+
def clear_primary(options = {})
|
|
70
|
+
reset_primary options.merge(:index => -1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
##
|
|
74
|
+
# Clears all users of primary status, used when merging/resetting Contacts,
|
|
75
|
+
# Fores save.
|
|
76
|
+
#
|
|
77
|
+
def clear_primary!
|
|
78
|
+
clear_primary :save => true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
accepts_nested_attributes_for :users, :allow_destroy => true
|
|
82
|
+
|
|
83
|
+
def page_views
|
|
84
|
+
PageView.by_user(users)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
has_many :record_attributes, :as => :record
|
|
88
|
+
RECORD_ATTRIBUTES = %w[users phone_number_attributes instant_messaging_handle_attributes website_attributes address_attributes]
|
|
89
|
+
# NOTE Mind the hack here, "users" are "attributes" but not added in this loop. This was so the RECORD_ATTRIBUTES constant
|
|
90
|
+
# would include :users (for building the templates.js).
|
|
91
|
+
RECORD_ATTRIBUTES.select {|r| r =~ /attributes$/ }.each do |association_name|
|
|
92
|
+
has_many association_name.to_sym, :class_name => association_name.classify, :as => :record
|
|
93
|
+
accepts_nested_attributes_for association_name.to_sym, :allow_destroy => true, :reject_if => :reject_record_attribute?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# Validations
|
|
98
|
+
#
|
|
99
|
+
validates :first_name, :presence => true, :length => { :maximum => 25 }
|
|
100
|
+
|
|
101
|
+
##
|
|
102
|
+
# Scopes
|
|
103
|
+
#
|
|
104
|
+
|
|
105
|
+
# NOTE contact#search feels terribly fragile and needs work.
|
|
106
|
+
#
|
|
107
|
+
# The issue lies in the need to outer join record_attributes because the
|
|
108
|
+
# join is optional.
|
|
109
|
+
#
|
|
110
|
+
# We end up with multiple rows per Contact. This could be solved
|
|
111
|
+
# by a distinct select, but that breaks when we need to do things
|
|
112
|
+
# like User.joins(:contact) & Contact.search("whatever")
|
|
113
|
+
#
|
|
114
|
+
# While it's like this, note that #search groups (hardcoded on "contacts")
|
|
115
|
+
#
|
|
116
|
+
scope :search, lambda {|query|
|
|
117
|
+
join_sql = %{
|
|
118
|
+
LEFT OUTER JOIN record_attributes
|
|
119
|
+
ON record_attributes.record_id = contacts.id
|
|
120
|
+
AND record_attributes.record_type = 'Contact'
|
|
121
|
+
LEFT OUTER JOIN users
|
|
122
|
+
ON users.contact_id = contacts.id
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
joins(join_sql).group('contacts.id').where(
|
|
126
|
+
any_attrs_like_scope_conditions(:first_name, :last_name, :title, query)
|
|
127
|
+
.or(RecordAttribute.attr_like_scope_condition(:value, query))
|
|
128
|
+
.or(User.attr_like_scope_condition(:email, query))
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
scope :by_title, lambda {|val| where(:title => val) }
|
|
132
|
+
scope :by_company, lambda {|val| where(:company_id => val) }
|
|
133
|
+
scope :tagged, lambda {|tags|
|
|
134
|
+
if tags.present?
|
|
135
|
+
tagged_with(tags, :show_hidden => true, :any => true)
|
|
136
|
+
else
|
|
137
|
+
where("1=0")
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# The parameters for building the JS template for associated users
|
|
142
|
+
def self.users_build_parameters # :nodoc:
|
|
143
|
+
{ :status => User::Status::PROSPECT }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# Setting company name will attempt to find a Company or create a new one
|
|
148
|
+
#
|
|
149
|
+
def company_name=(value)
|
|
150
|
+
if value.present?
|
|
151
|
+
if existing_company = Company.find_by_name(value)
|
|
152
|
+
self.company = existing_company
|
|
153
|
+
else
|
|
154
|
+
self.build_company(:name => value)
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
self.company = nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
delegate :name, :to => :company, :prefix => true, :allow_nil => true
|
|
161
|
+
|
|
162
|
+
##
|
|
163
|
+
# Helper to concatenate a Contact's full name
|
|
164
|
+
#
|
|
165
|
+
def name
|
|
166
|
+
[first_name, last_name].join(' ')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def merge_and_destroy!(other_contact)
|
|
170
|
+
other_contact.users.clear_primary!
|
|
171
|
+
|
|
172
|
+
self.users |= other_contact.users
|
|
173
|
+
self.website_attributes |= other_contact.website_attributes
|
|
174
|
+
self.address_attributes |= other_contact.address_attributes
|
|
175
|
+
self.phone_number_attributes |= other_contact.phone_number_attributes
|
|
176
|
+
self.instant_messaging_handle_attributes |= other_contact.instant_messaging_handle_attributes
|
|
177
|
+
|
|
178
|
+
other_contact.destroy
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def valid?(context = nil)
|
|
182
|
+
#
|
|
183
|
+
# NOTE #valid? manages duplicate users and is a destructive process!
|
|
184
|
+
# TODO move the logic that deletes/manages duplicate email'd users out of
|
|
185
|
+
# #valid?, which probably should not be destructive
|
|
186
|
+
#
|
|
187
|
+
# When checking for validity, we're also checking to see if Users are being added
|
|
188
|
+
# which have emails that already exist in the database. If one is found, one of
|
|
189
|
+
# two things will happen, depending on whether or not that User already has a
|
|
190
|
+
# Contact record.
|
|
191
|
+
#
|
|
192
|
+
# A.) If it does, the validation will return false immediately and add an error
|
|
193
|
+
# suggesting a Contact merge.
|
|
194
|
+
#
|
|
195
|
+
# B.) If it does not, no error will be added, but the offending "user" association
|
|
196
|
+
# will be deleted and the Contact will be related to the pre-existing User with
|
|
197
|
+
# that email.
|
|
198
|
+
#
|
|
199
|
+
# If more than one user associations are passed with the same email, it will be treated
|
|
200
|
+
# as a normal uniqueness error, until all emails passed are unique. At which time we
|
|
201
|
+
# go back to the A/B scenario above.
|
|
202
|
+
#
|
|
203
|
+
super || begin
|
|
204
|
+
unless errors.delete(:"users.email").blank?
|
|
205
|
+
users.dup.each_with_index do |user, i|
|
|
206
|
+
user.errors[:email].each do |error|
|
|
207
|
+
if error.taken? && users.select {|u| u.email == user.email }.length == 1
|
|
208
|
+
existing_user = User.find_by_email(user.email)
|
|
209
|
+
|
|
210
|
+
if contact = existing_user.contact
|
|
211
|
+
args = if new_record?
|
|
212
|
+
[contact, 'new', {:contact => self.attributes}]
|
|
213
|
+
else
|
|
214
|
+
[contact, self]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
errors.add(:users, :merge_required, {
|
|
218
|
+
:email => user.email,
|
|
219
|
+
:merge_path => new_contact_merge_path(*args)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return false
|
|
223
|
+
else
|
|
224
|
+
self.users.delete(user)
|
|
225
|
+
self.users << existing_user
|
|
226
|
+
end
|
|
227
|
+
else
|
|
228
|
+
if error.label
|
|
229
|
+
errors.add(:users, error.label.to_sym, :email => user.email)
|
|
230
|
+
else
|
|
231
|
+
errors.add(:users, nil, :message => error, :email => user.email)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
errors[:users].uniq!
|
|
238
|
+
errors[:users].empty?
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
protected
|
|
244
|
+
|
|
245
|
+
def ensure_user_references
|
|
246
|
+
users.each {|u| u.contact = self }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# override has_destroy_flag? to force destroy on persisted associations as well
|
|
250
|
+
def has_destroy_flag?(hash)
|
|
251
|
+
reject_record_attribute?(hash) || super
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def reject_record_attribute?(attributes)
|
|
255
|
+
attributes.keys.member?('value') && attributes['value'].blank?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
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.
|
|
3
|
+
#
|
|
4
|
+
class ContactEmail < Email
|
|
5
|
+
include ScheduledEmail
|
|
6
|
+
before_save :generate_html_body_from_text_body
|
|
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
|
|
12
|
+
|
|
13
|
+
after_create :send_to_user_ids
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def new_from_template(template, attrs = {})
|
|
17
|
+
new({
|
|
18
|
+
:name => "#{template.name} - #{DateTime.now.to_i}",
|
|
19
|
+
:subject => template.subject,
|
|
20
|
+
:html_body => template.html_body,
|
|
21
|
+
:text_body => template.text_body
|
|
22
|
+
}.merge(attrs))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
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
|
|
38
|
+
|
|
39
|
+
@user_ids.map!(&:to_i)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def send_to_user_ids
|
|
46
|
+
Rails.logger.info("ContactEmail##{id} sending to user ids #{user_ids}")
|
|
47
|
+
send!(user_ids)
|
|
48
|
+
end
|
|
49
|
+
end
|
data/app/models/deal.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Generated from a Lead, owned by a Campaign. Deals represent potential
|
|
2
|
+
# "deals" with contacts and track revenue used for marketing reports.
|
|
3
|
+
#
|
|
4
|
+
class Deal < ActiveRecord::Base
|
|
5
|
+
include E9Rails::ActiveRecord::Initialization
|
|
6
|
+
|
|
7
|
+
belongs_to :campaign, :inverse_of => :deal
|
|
8
|
+
belongs_to :tracking_cookie, :inverse_of => :deal
|
|
9
|
+
belongs_to :offer, :inverse_of => :deals
|
|
10
|
+
|
|
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
|
+
}
|
|
16
|
+
|
|
17
|
+
scope :column_eq, lambda {|column, value, reverse=false|
|
|
18
|
+
column_op(:eq, column, value, reverse)
|
|
19
|
+
}
|
|
20
|
+
|
|
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
|
+
|
|
26
|
+
validate do |record|
|
|
27
|
+
return unless record.status_changed?
|
|
28
|
+
|
|
29
|
+
case record.status
|
|
30
|
+
when Status::Lead
|
|
31
|
+
if [Status::Won, Status::Lost].member?(record.status_was)
|
|
32
|
+
record.errors.add(:status, :illegal_reversion)
|
|
33
|
+
elsif record.persisted?
|
|
34
|
+
# "revert" isn't happening on new records
|
|
35
|
+
record.send :_do_revert
|
|
36
|
+
end
|
|
37
|
+
when Status::Pending
|
|
38
|
+
if record.status_was == Status::Lead
|
|
39
|
+
record.send :_do_convert
|
|
40
|
+
end
|
|
41
|
+
when Status::Won, Status::Lost
|
|
42
|
+
if record.status_was == Status::Lead
|
|
43
|
+
record.errors.add(:status, :illegal_conversion)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
record.errors.add(:status, :invalid, :options => Status::OPTIONS.join(', '))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
def method_missing(method_name, *args)
|
|
53
|
+
if method_name =~ /(.*)\?$/ && Status::OPTIONS.member?($1)
|
|
54
|
+
self.status == $1
|
|
55
|
+
else
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def _do_convert
|
|
61
|
+
self.converted_at = Time.now.utc
|
|
62
|
+
notify_observers :before_convert
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def _do_revert
|
|
66
|
+
self.converted_at = nil
|
|
67
|
+
notify_observers :before_revert
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Typically, new Deals are 'pending', with the assumption that offers
|
|
71
|
+
# explicitly create Deals as 'lead' when they convert.
|
|
72
|
+
def _assign_initialization_defaults
|
|
73
|
+
self.status = Status::Pending
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
module Status
|
|
77
|
+
OPTIONS = %w(lead pending won lost)
|
|
78
|
+
Lead = OPTIONS[0]
|
|
79
|
+
Pending = OPTIONS[1]
|
|
80
|
+
Won = OPTIONS[2]
|
|
81
|
+
Lost = OPTIONS[3]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# An "Email" that isn't intended to be sent, but rather as a prototype
|
|
2
|
+
# for other Emails to be generated from.
|
|
3
|
+
#
|
|
4
|
+
class EmailTemplate < Email
|
|
5
|
+
# TODO the email class hierarchy needs a major refactoring, it's backwards and convoluted
|
|
6
|
+
before_save :generate_html_body_from_text_body
|
|
7
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# A simple class to manage menu options, usable by other classes to build their menus.
|
|
2
|
+
#
|
|
3
|
+
class MenuOption < ActiveRecord::Base
|
|
4
|
+
KEYS = [
|
|
5
|
+
'Deal Category',
|
|
6
|
+
'Email',
|
|
7
|
+
'Instant Messaging Handle',
|
|
8
|
+
'Phone Number',
|
|
9
|
+
#'Task Category',
|
|
10
|
+
#'Task Status',
|
|
11
|
+
'Website'
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
validates :value, :presence => true
|
|
15
|
+
validates :key, :presence => true, :inclusion => { :in => KEYS, :allow_blank => true }
|
|
16
|
+
|
|
17
|
+
acts_as_list :scope => 'menu_options.key = \"#{key}\"'
|
|
18
|
+
|
|
19
|
+
scope :options_for, lambda {|key| where(:key => key) }
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# A direct SQL selection of values for a given key
|
|
23
|
+
#
|
|
24
|
+
# MenuOption.fetch('Email') #=> ['Personal','Work']
|
|
25
|
+
#
|
|
26
|
+
# === Parameters
|
|
27
|
+
#
|
|
28
|
+
# [key (String)] The key for the assocated menu options.
|
|
29
|
+
#
|
|
30
|
+
def self.fetch_values(key)
|
|
31
|
+
connection.send(:select_values, options_for(key).order(:position).project('value').to_sql, 'Menu Option Select')
|
|
32
|
+
end
|
|
33
|
+
end
|
data/app/models/offer.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# A +Renderable+ which "offers" the user something. Responses to these
|
|
2
|
+
# offers are tracked as +Leads+
|
|
3
|
+
#
|
|
4
|
+
class Offer < Renderable
|
|
5
|
+
has_many :deals, :inverse_of => :offer
|
|
6
|
+
has_many :leads, :class_name => 'Deal', :conditions => ["deals.status = ?", Deal::Status::Lead]
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def conversion_email
|
|
10
|
+
SystemEmail.find_by_identifier(Identifiers::CONVERSION_EMAIL)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def page
|
|
14
|
+
SystemPage.find_by_identifier(Identifiers::PAGE)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
include E9Rails::ActiveRecord::InheritableOptions
|
|
19
|
+
self.delegate_options_methods = true
|
|
20
|
+
self.options_parameters = [
|
|
21
|
+
:submit_button_text,
|
|
22
|
+
:success_alert_text,
|
|
23
|
+
:download_link_text,
|
|
24
|
+
:conversion_alert_email,
|
|
25
|
+
:success_page_text,
|
|
26
|
+
:custom_form_html
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
mount_uploader :file, FileUploader
|
|
30
|
+
|
|
31
|
+
validates :conversion_alert_email, :email => { :allow_blank => true }
|
|
32
|
+
|
|
33
|
+
def to_s
|
|
34
|
+
name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def as_json(options={})
|
|
38
|
+
{}.tap do |hash|
|
|
39
|
+
hash[:id] = self.id,
|
|
40
|
+
hash[:name] = self.name,
|
|
41
|
+
hash[:template] = self.template,
|
|
42
|
+
hash[:errors] = self.errors
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module Identifiers
|
|
47
|
+
CONVERSION_EMAIL = 'offer_conversion_email'
|
|
48
|
+
PAGE = 'offer_page'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# A visit to the site
|
|
2
|
+
#
|
|
3
|
+
# Page views belong to a tracking cookie, from which it derives its
|
|
4
|
+
# campaign code, and whether or not it is a "new visit" for the given
|
|
5
|
+
# campaign code.
|
|
6
|
+
#
|
|
7
|
+
# === Also stored from the request:
|
|
8
|
+
#
|
|
9
|
+
# [request_path] The full request path
|
|
10
|
+
# [user_agent] The request user agent
|
|
11
|
+
# [referer] The request referer if it exists
|
|
12
|
+
# [remote_ip] The originating ip address of the request
|
|
13
|
+
# [session] The session id of the request
|
|
14
|
+
#
|
|
15
|
+
class PageView < ActiveRecord::Base
|
|
16
|
+
belongs_to :tracking_cookie
|
|
17
|
+
|
|
18
|
+
belongs_to :campaign, :inverse_of => :page_views
|
|
19
|
+
has_one :user, :through => :tracking_cookie
|
|
20
|
+
|
|
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
|
|
43
|
+
|
|
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
|
+
}
|
|
66
|
+
|
|
67
|
+
scope :by_user, lambda {|*users|
|
|
68
|
+
users.flatten!
|
|
69
|
+
users.map! &:to_param
|
|
70
|
+
joins(:tracking_cookie).where(TrackingCookie.arel_table[:user_id].send *(users.length == 1 ? [:eq, users.pop] : [:in, users]))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
scope :by_campaign, lambda {|*campaigns|
|
|
74
|
+
campaigns.flatten!
|
|
75
|
+
campaigns.map! &:to_param
|
|
76
|
+
where(arel_table[:campaign_id].send *(campaigns.length == 1 ? [:eq, campaigns.pop] : [:in, campaigns]))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
delegate :name, :code, :to => :campaign, :prefix => true, :allow_nil => true
|
|
80
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# An arbitrary 'attribute' attachable to records.
|
|
2
|
+
#
|
|
3
|
+
# Gets support for arbitrary options via InheritableOptions. By default it
|
|
4
|
+
# has one option, +type+, but subclasses could extend for further options by
|
|
5
|
+
# overwriting +options_parameters+.
|
|
6
|
+
#
|
|
7
|
+
# By default, the +type+ options are managed via the +MenuOption+ class.
|
|
8
|
+
#
|
|
9
|
+
class RecordAttribute < ActiveRecord::Base
|
|
10
|
+
include E9Rails::ActiveRecord::STI
|
|
11
|
+
include E9Rails::ActiveRecord::AttributeSearchable
|
|
12
|
+
include E9Rails::ActiveRecord::InheritableOptions
|
|
13
|
+
|
|
14
|
+
self.options_parameters = [:type]
|
|
15
|
+
|
|
16
|
+
belongs_to :record, :polymorphic => true
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Looks up the available +types+ for this attribute by fetching a
|
|
20
|
+
# titleized version of the class name from +MenuOption+.
|
|
21
|
+
#
|
|
22
|
+
# e.g.
|
|
23
|
+
#
|
|
24
|
+
# PhoneNumberAttribute.types
|
|
25
|
+
#
|
|
26
|
+
# is equivalent to:
|
|
27
|
+
#
|
|
28
|
+
# MenuOption.fetch_values('Phone Number')
|
|
29
|
+
#
|
|
30
|
+
def self.types
|
|
31
|
+
if name =~ /^(\w+)Attribute$/
|
|
32
|
+
MenuOption.fetch_values($1.titleize)
|
|
33
|
+
else
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
options.type ? "#{value} (#{options.type})" : value
|
|
40
|
+
end
|
|
41
|
+
end
|