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.
Files changed (120) hide show
  1. data/.gitignore +3 -0
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +105 -0
  4. data/README.md +31 -0
  5. data/Rakefile +2 -0
  6. data/app/controllers/e9_crm/advertising_campaigns_controller.rb +3 -0
  7. data/app/controllers/e9_crm/affiliate_campaigns_controller.rb +3 -0
  8. data/app/controllers/e9_crm/base_controller.rb +5 -0
  9. data/app/controllers/e9_crm/campaign_groups_controller.rb +3 -0
  10. data/app/controllers/e9_crm/campaigns_controller.rb +4 -0
  11. data/app/controllers/e9_crm/companies_controller.rb +3 -0
  12. data/app/controllers/e9_crm/contact_emails_controller.rb +38 -0
  13. data/app/controllers/e9_crm/contact_merges_controller.rb +26 -0
  14. data/app/controllers/e9_crm/contacts_controller.rb +46 -0
  15. data/app/controllers/e9_crm/dated_costs_controller.rb +4 -0
  16. data/app/controllers/e9_crm/deals_controller.rb +19 -0
  17. data/app/controllers/e9_crm/email_campaigns_controller.rb +3 -0
  18. data/app/controllers/e9_crm/email_templates.controller.rb +3 -0
  19. data/app/controllers/e9_crm/menu_options_controller.rb +26 -0
  20. data/app/controllers/e9_crm/offers_controller.rb +3 -0
  21. data/app/controllers/e9_crm/page_views_controller.rb +20 -0
  22. data/app/controllers/e9_crm/record_attributes_controller.rb +3 -0
  23. data/app/controllers/e9_crm/reports_controller.rb +2 -0
  24. data/app/controllers/e9_crm/resources_controller.rb +43 -0
  25. data/app/controllers/e9_crm/sales_campaigns_controller.rb +3 -0
  26. data/app/helpers/e9_crm/base_helper.rb +77 -0
  27. data/app/helpers/e9_crm/campaigns_helper.rb +14 -0
  28. data/app/helpers/e9_crm/contact_merges_helper.rb +17 -0
  29. data/app/helpers/e9_crm/contacts_helper.rb +27 -0
  30. data/app/helpers/e9_crm/deals_helper.rb +15 -0
  31. data/app/helpers/e9_crm/menu_options_helper.rb +11 -0
  32. data/app/helpers/e9_crm/page_views_helper.rb +2 -0
  33. data/app/models/address_attribute.rb +4 -0
  34. data/app/models/advertising_campaign.rb +15 -0
  35. data/app/models/affiliate.rb +4 -0
  36. data/app/models/affiliate_campaign.rb +15 -0
  37. data/app/models/campaign.rb +34 -0
  38. data/app/models/campaign_group.rb +5 -0
  39. data/app/models/company.rb +4 -0
  40. data/app/models/contact.rb +258 -0
  41. data/app/models/contact_email.rb +49 -0
  42. data/app/models/dated_cost.rb +9 -0
  43. data/app/models/deal.rb +83 -0
  44. data/app/models/email_campaign.rb +13 -0
  45. data/app/models/email_template.rb +7 -0
  46. data/app/models/instant_messaging_handle_attribute.rb +4 -0
  47. data/app/models/menu_option.rb +33 -0
  48. data/app/models/offer.rb +50 -0
  49. data/app/models/page_view.rb +80 -0
  50. data/app/models/phone_number_attribute.rb +4 -0
  51. data/app/models/record_attribute.rb +41 -0
  52. data/app/models/sales_campaign.rb +15 -0
  53. data/app/models/sales_person.rb +4 -0
  54. data/app/models/tracking_cookie.rb +61 -0
  55. data/app/models/website_attribute.rb +4 -0
  56. data/app/observers/deal_observer.rb +11 -0
  57. data/app/uploaders/file_uploader.rb +34 -0
  58. data/app/views/e9_crm/campaigns/_form_inner.html.haml +5 -0
  59. data/app/views/e9_crm/contact_emails/_form.html.haml +7 -0
  60. data/app/views/e9_crm/contact_emails/_form_inner.html.haml +11 -0
  61. data/app/views/e9_crm/contact_emails/destroy.js.erb +3 -0
  62. data/app/views/e9_crm/contact_emails/send_email.js.erb +1 -0
  63. data/app/views/e9_crm/contact_merges/_field.html.haml +10 -0
  64. data/app/views/e9_crm/contact_merges/_form.html.haml +10 -0
  65. data/app/views/e9_crm/contact_merges/new.html.haml +2 -0
  66. data/app/views/e9_crm/contacts/_details.html.haml +22 -0
  67. data/app/views/e9_crm/contacts/_form_inner.html.haml +51 -0
  68. data/app/views/e9_crm/contacts/_header.html.haml +19 -0
  69. data/app/views/e9_crm/contacts/_tag_table.html.haml +15 -0
  70. data/app/views/e9_crm/contacts/index.html.haml +13 -0
  71. data/app/views/e9_crm/contacts/index.js.erb +5 -0
  72. data/app/views/e9_crm/contacts/merge.html.haml +1 -0
  73. data/app/views/e9_crm/contacts/templates.js.erb +1 -0
  74. data/app/views/e9_crm/deals/_form_inner.html.haml +5 -0
  75. data/app/views/e9_crm/deals/_header.html.haml +0 -0
  76. data/app/views/e9_crm/deals/leads.html.haml +13 -0
  77. data/app/views/e9_crm/email_templates/_form_inner.html.haml +9 -0
  78. data/app/views/e9_crm/menu_options/_form_inner.html.haml +6 -0
  79. data/app/views/e9_crm/menu_options/_header.html.haml +8 -0
  80. data/app/views/e9_crm/offers/_form.html.haml +7 -0
  81. data/app/views/e9_crm/offers/_form_inner.html.haml +42 -0
  82. data/app/views/e9_crm/offers/_form_inner.html.haml.bak +43 -0
  83. data/app/views/e9_crm/page_views/_table.html.haml +25 -0
  84. data/app/views/e9_crm/record_attributes/_address_attribute.html.haml +5 -0
  85. data/app/views/e9_crm/record_attributes/_instant_messaging_handle_attribute.html.haml +5 -0
  86. data/app/views/e9_crm/record_attributes/_phone_number_attribute.html.haml +5 -0
  87. data/app/views/e9_crm/record_attributes/_record_attribute.html.haml +10 -0
  88. data/app/views/e9_crm/record_attributes/_templates.js.erb +12 -0
  89. data/app/views/e9_crm/record_attributes/_user.html.haml +23 -0
  90. data/app/views/e9_crm/record_attributes/_website_attribute.html.haml +5 -0
  91. data/app/views/e9_crm/resources/_footer.html.haml +1 -0
  92. data/app/views/e9_crm/resources/_form.html.haml +7 -0
  93. data/app/views/e9_crm/resources/_form_inner.html.haml +5 -0
  94. data/app/views/e9_crm/resources/_header.html.haml +0 -0
  95. data/app/views/e9_crm/resources/_table.html.haml +21 -0
  96. data/app/views/e9_crm/resources/create.js.erb +6 -0
  97. data/app/views/e9_crm/resources/destroy.js.erb +3 -0
  98. data/app/views/e9_crm/resources/edit.html.haml +2 -0
  99. data/app/views/e9_crm/resources/index.html.haml +13 -0
  100. data/app/views/e9_crm/resources/index.js.erb +1 -0
  101. data/app/views/e9_crm/resources/new.html.haml +2 -0
  102. data/app/views/e9_crm/resources/show.html.haml +2 -0
  103. data/app/views/e9_crm/resources/update.js.erb +5 -0
  104. data/config/initializers/inflections.rb +3 -0
  105. data/config/locales/e9.en.yml +28 -0
  106. data/config/locales/en.yml +63 -0
  107. data/config/routes.rb +61 -0
  108. data/e9_crm.gemspec +29 -0
  109. data/lib/e9_crm/model.rb +63 -0
  110. data/lib/e9_crm/rails_extensions.rb +98 -0
  111. data/lib/e9_crm/tracking_controller.rb +78 -0
  112. data/lib/e9_crm/version.rb +3 -0
  113. data/lib/e9_crm.rb +59 -0
  114. data/lib/generators/e9_crm/install_generator.rb +32 -0
  115. data/lib/generators/e9_crm/templates/create_e9_crm_tables.rb +107 -0
  116. data/lib/generators/e9_crm/templates/initializer.rb +4 -0
  117. data/lib/generators/e9_crm/templates/javascript.js +187 -0
  118. data/test/functional/e9_crm/campaign_types_controller_test.rb +49 -0
  119. data/test/functional/home_controller_test.rb +8 -0
  120. 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,5 @@
1
+ # An arbitrary grouping of campaigns for organizational use
2
+ #
3
+ class CampaignGroup < ActiveRecord::Base
4
+ has_many :campaigns
5
+ end
@@ -0,0 +1,4 @@
1
+ # A company record, mainly an organizational tool for Contacts
2
+ #
3
+ class Company < ActiveRecord::Base
4
+ 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
@@ -0,0 +1,9 @@
1
+ #
2
+ # Encapulates a Cost and a Date, used to track costs by
3
+ # date so sums for date ranges can be generated.
4
+ #
5
+ class DatedCost < ActiveRecord::Base
6
+ money_columns :cost
7
+ belongs_to :costable, :polymorphic => true
8
+ validates :date, :date => true
9
+ end
@@ -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,13 @@
1
+ # An email campaign.
2
+ #
3
+ class EmailCampaign < Campaign
4
+
5
+ ##
6
+ # The sum cost of this campaign
7
+ #
8
+ # NOTE How much does an email campaign 'cost'?
9
+ #
10
+ def cost
11
+ Money.new(1)
12
+ end
13
+ 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,4 @@
1
+ # An instant messaging handle type, e.g. AIM, Skype
2
+ #
3
+ class InstantMessagingHandleAttribute < RecordAttribute
4
+ 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
@@ -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,4 @@
1
+ # A phone number type, e.g. (Home, Work, Mobile)
2
+ #
3
+ class PhoneNumberAttribute < RecordAttribute
4
+ 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