jam-cloud/ruby/lib/jam_ruby/models/lesson_session.rb

602 lines
19 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# represenst the type of lesson package
module JamRuby
class LessonSession < ActiveRecord::Base
attr_accessor :accepting, :creating, :countering, :countered_slot, :countered_lesson
@@log = Logging.logger[LessonSession]
STATUS_REQUESTED = 'requested'
STATUS_CANCELED = 'canceled'
STATUS_MISSED = 'missed'
STATUS_COMPLETED = 'completed'
STATUS_APPROVED = 'approved'
STATUS_SUSPENDED = 'suspended'
STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_MISSED, STATUS_COMPLETED, STATUS_APPROVED, STATUS_SUSPENDED]
LESSON_TYPE_SINGLE = 'paid'
LESSON_TYPE_SINGLE_FREE = 'single-free'
LESSON_TYPE_TEST_DRIVE = 'test-drive'
LESSON_TYPES = [LESSON_TYPE_SINGLE, LESSON_TYPE_SINGLE_FREE, LESSON_TYPE_TEST_DRIVE]
has_one :music_session, class_name: "JamRuby::MusicSession"
belongs_to :teacher, class_name: "JamRuby::User", foreign_key: :teacher_id, inverse_of: :taught_lessons
belongs_to :lesson_package_purchase, class_name: "JamRuby::LessonPackagePurchase"
belongs_to :lesson_booking, class_name: "JamRuby::LessonBooking"
belongs_to :slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :slot_id
has_many :lesson_booking_slots, class_name: "JamRuby::LessonBookingSlot"
validates :duration, presence: true, numericality: {only_integer: true}
validates :lesson_booking, presence: true
validates :lesson_type, inclusion: {in: LESSON_TYPES}
validates :booked_price, presence: true
validates :status, presence: true, inclusion: {in: STATUS_TYPES}
validates :teacher_complete, inclusion: {in: [true, false]}
validates :student_complete, inclusion: {in: [true, false]}
validates :teacher_canceled, inclusion: {in: [true, false]}
validates :student_canceled, inclusion: {in: [true, false]}
validates :success, inclusion: {in: [true, false]}
validates :bill, inclusion: {in: [true, false]}
validates :billed, inclusion: {in: [true, false]}
validates :post_processed, inclusion: {in: [true, false]}
validate :validate_creating, :if => :creating
validate :validate_accepted, :if => :accepting
after_save :after_counter, :if => :countering
after_save :manage_slot_changes
def manage_slot_changes
# if this slot changed, we need to update the time. But LessonBooking does this for us, for requested/accepted .
# TODO: what to do, what to do.
end
def suspend!
self.status = STATUS_SUSPENDED
self.save
end
def self.hourly_check
analyse_sessions
complete_sessions
end
def self.analyse_sessions
MusicSession.joins(lesson_session: :lesson_booking).where('session_removed_at IS NOT NULL').where('analysed = false').each do |music_session|
lession_session = music_session.lesson_session
lession_session.analyse
end
end
def self.complete_sessions
MusicSession.joins(lesson_session: :lesson_booking).where('session_removed_at IS NOT NULL').where('analysed = true').where('post_processed = false').where('billing_should_retry = true').each do |music_session|
lession_session = music_session.lesson_session
lession_session.session_completed
end
end
def analyse
if self.analysed
return
end
analysis = LessonSessionAnalyser.analyse(self)
self.analysis = analysis_to_json(analysis)
self.success = analysis[:bill]
self.analysed_at = Time.now
self.analysed = true
if lesson_booking.requires_per_session_billing?
self.bill = true
end
if self.save
# send out emails appropriate for this type of session
session_completed
end
end
def amount_charged
if lesson_package_purchase
lesson_package_purchase.sale_line_item.sale.recurly_total_in_cents / 100.0
else
nil
end
end
def analysis_to_json(analysis)
json = {}
analysis.each do |k, v|
if v.is_a?(Array)
array = []
v.each do |item|
if item.is_a?(Range)
array << {begin: item.begin, end: item.end}
else
raise "expected range"
end
end
json[k] = array
else
json[k] = v
end
end
json.to_json
end
def session_completed
LessonSession.transaction do
self.lock!
if post_processed
# nothing to do. because this is an async job, it's possible this was queued up with another session_completed fired
return
end
begin
if lesson_booking.is_test_drive?
test_drive_completed
elsif lesson_booking.is_normal?
if lesson_booking.is_weekly_payment? || lesson_booking.is_monthly_payment?
recurring_completed
else
normal_lesson_completed
end
end
rescue Exception => e
self.unhandled_handler(e)
end
end
end
def bill_lesson
if !self.billed
# check if we can bill at the moment
if last_billing_attempt_at && (24.hours.ago < last_billing_attempt_at)
return
end
if !billing_should_retry
return
end
# bill the user right now. if it fails, move on; will be tried again
self.billing_attempts = self.billing_attempts + 1
self.billing_should_retry = self.billing_attempts < 5
self.last_billing_attempt_at = Time.now
self.save(validate: false)
begin
sale = Sale.purchase_lesson(student, lesson_booking, lesson_booking.lesson_package_type, self)
if sale.errors.any?
self.billing_error_reason = 'sale_error'
self.billing_error_detail = sale.errors.inspect
line_item = sale.sale_line_items[0]
if line_item && line_item.errors.any?
self.billing_error_detail = "#{self.billing_error_detail}\n\n#{line_item.errors.inspect}"
end
self.save(validate: false)
return false
else
self.billed = true
self.billed_at = Time.now
self.save(validate: false)
end
rescue Stripe::StripeError => e
stripe_handler(e)
subject = "Unable to charge user #{student.email} for lesson #{self.id} (stripe)"
body = "teacher=#{teacher.email}\n\nbilling_error_reason=#{billing_error_reason}\n\nbilling_error_detail = #{billing_error_detail}"
AdminMailer.alerts({subject: subject, body: body})
UserMailer.student_unable_charge(self)
return false
rescue Exception => e
subject = "Unable to charge user #{student.email} for lesson #{self.id} (unhandled)"
body = "teacher=#{teacher.email}\n\nbilling_error_detail = #{billing_error_detail}"
AdminMailer.alerts({subject: subject, body: body})
unhandled_handler(e)
return false
end
end
if !self.sent_billing_notices
# If the charge is successful, then we post the charge to the students payment history,
# and associate the charge with the lesson, so that everyone knows the student has paid, and we send an email
UserMailer.student_lesson_normal_done(self).deliver
UserMailer.teacher_lesson_normal_done(self).deliver
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(validate: false)
end
return true
end
def unhandled_handler(e, reason = 'unhandled_exception')
self.billing_error_reason = reason
if e.cause
self.billing_error_detail = e.cause.to_s + "\n" + e.cause.backtrace.join("\n\t") if e.cause.backtrace
self.billing_error_detail << "\n\n"
self.billing_error_detail << e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace
else
self.billing_error_detail = e.to_s + "\n" + e.backtrace.join("\n\t") if e.backtrace
end
self.save(validate: :false)
end
def is_card_declined?
billed == false && billing_error_reason == 'card_declined'
end
def is_card_expired?
billed == false && billing_error_reason == 'card_expired'
end
def last_billed_at_date
last_billing_attempt_at.strftime("%B %d, %Y") if last_billing_attempt_at
end
def stripe_handler(e)
msg = e.to_s
if msg.include?('declined')
self.billing_error_reason = 'card_declined'
self.billing_error_detail = msg
elsif msg.include?('expired')
self.billing_error_reason = 'card_expired'
self.billing_error_detail = msg
elsif msg.include?('processing')
self.billing_error_reason = 'processing_error'
self.billing_error_detail = msg
else
self.billing_error_reason = 'stripe'
self.billing_error_detail = msg
end
self.save(validate: false)
end
def test_drive_completed
if !sent_billing_notices
if success
student.test_drive_succeeded(self)
else
student.test_drive_failed(self)
end
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(:validate => false)
end
end
def recurring_completed
if success
if lesson_booking.is_monthly_payment?
# monthly payments are handled at beginning of month; just poke with email, and move on
if !sent_billing_notices
# not in spec; just poke user and tell them we saw it was successfully completed
UserMailer.monthly_recurring_done(user, lesson_session).deliver
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(:validate => false)
end
else
bill_lesson
end
else
if lesson_booking.is_monthly_payment?
# bad session; just poke user
if !sent_billing_notices
UserMailer.monthly_recurring_no_bill(user, self).deliver
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(:validate => false)
end
else
if !sent_billing_notices
# bad session; just poke user
UserMailer.student_weekly_recurring_no_bill(user, self).deliver
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(:validate => false)
end
end
end
end
def normal_lesson_completed
if success
bill_lesson
else
if !sent_billing_notices
UserMailer.student_lesson_normal_no_bill(self).deliver
UserMailer.teacher_lesson_no_bill(self).deliver
self.sent_billing_notices = true
self.sent_billing_notices_at = Time.now
self.post_processed = true
self.post_processed_at = Time.now
self.save(:validate => false)
end
end
end
def after_counter
send_counter(@countered_lesson, @countered_slot)
end
def scheduled_start
music_session.scheduled_start
end
def send_counter(countered_lesson, countered_slot)
if countered_slot.is_teacher_created?
UserMailer.student_lesson_counter(countered_lesson, countered_slot).deliver
else
UserMailer.teacher_lesson_counter(countered_lesson, countered_slot).deliver
end
self.countering = false
end
default_scope { order(:created_at) }
def is_requested?
status == STATUS_REQUESTED
end
def is_canceled?
status == STATUS_CANCELED
end
def is_completed?
status == STATUS_COMPLETED
end
def is_missed?
status == STATUS_MISSED
end
def is_approved?
status == STATUS_APPROVED
end
def is_suspended?
status == STATUS_SUSPENDED
end
def validate_creating
if !is_requested? && !is_approved?
self.errors.add(:status, "is not valid for a new lesson session.")
end
if is_approved? && lesson_booking && lesson_booking.is_test_drive? && lesson_package_purchase.nil?
self.errors.add(:lesson_package_purchase, "must be specified for a test drive purchase")
end
end
def validate_accepted
if !is_requested?
self.errors.add(:status, "This session is already #{self.status}.")
end
if lesson_booking && lesson_booking.is_test_drive? && lesson_package_purchase.nil?
self.errors.add(:lesson_package_purchase, "must be specified for a test drive purchase")
end
self.accepting = false
self.status = STATUS_APPROVED
end
def self.create(booking)
lesson_session = LessonSession.new
lesson_session.creating = true
lesson_session.duration = booking.lesson_length
lesson_session.lesson_type = booking.lesson_type
lesson_session.lesson_booking = booking
lesson_session.booked_price = booking.booked_price
lesson_session.teacher = booking.teacher
lesson_session.status = booking.status
lesson_session.slot = booking.default_slot
if booking.is_test_drive?
lesson_session.lesson_package_purchase = booking.student.most_recent_test_drive_purchase
end
lesson_session.save
if lesson_session.errors.any?
puts "Lesson Session errors #{lesson_session.errors.inspect}"
end
lesson_session
end
def student
music_session.creator
end
def self.index(user, params = {})
limit = params[:per_page]
limit ||= 100
limit = limit.to_i
query = LessonSession.joins(:music_session).joins(music_session: :creator)
query = query.includes([:teacher, :music_session])
query = query.order('music_sessions.scheduled_start DESC')
if params[:as_teacher]
query = query.where('lesson_sessions.teacher_id = ?', user.id)
else
query = query.where('music_sessions.user_id = ?', user.id)
end
current_page = params[:page].nil? ? 1 : params[:page].to_i
next_page = current_page + 1
# will_paginate gem
query = query.paginate(:page => current_page, :per_page => limit)
if query.length == 0 # no more results
{query: query, next_page: nil}
elsif query.length < limit # no more results
{query: query, next_page: nil}
else
{query: query, next_page: next_page}
end
end
def update_scheduled_start(week_offset)
music_session.scheduled_start = slot.scheduled_time(week_offset)
music_session.save
end
# grabs the next available time that's after the present, to avoid times being scheduled in the past
def update_next_available_time(attempt = 0)
max_attempts = attempt + 10
while attempt < max_attempts
test = slot.scheduled_time(attempt)
if test >= Time.now
time = test
# valid time found!
break
end
attempt += 1
end
if time
music_session.scheduled_start = time
music_session.save
end
time.nil? ? nil : attempt
end
# teacher accepts the lesson
def accept(params)
LessonSession.transaction do
message = params[:message]
slot = params[:slot]
slot = LessonBookingSlot.find(slot)
self.slot = slot
if is_approved?
# this implies a new slot has been countered, and now approved
if self.save
if slot.update_all
lesson_booking.accept(self, slot)
msg = ChatMessage.create(teacher, nil, message, ChatMessage::CHANNEL_LESSON, nil, student, lesson_booking)
Notification.send_lesson_message('accept', self, true) # TODO: this isn't quite an 'accept'
UserMailer.student_lesson_update_all(self, message, slot).deliver
UserMailer.teacher_lesson_update_all(self, message, slot).deliver
else
# nothing to do with the original booking (since we are not changing all times), so we update just ourself
time = update_next_available_time # XXX: week offset as 0? This *could* still be in the past. But the user is approving it. So do we just trust them and get out of their way?
if time.nil?
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id} because it's in the past")
puts("unable to accept slot #{slot.id} for lesson #{self.id} because it's in the past")
raise ActiveRecord::Rollback
end
UserMailer.student_lesson_accepted(self, message, slot).deliver
UserMailer.teacher_lesson_accepted(self, message, slot).deliver
end
else
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id}")
puts("unable to accept slot #{slot.id} for lesson #{self.id}")
end
else
self.accepting = true
if lesson_package_purchase.nil? && lesson_booking.is_test_drive?
self.lesson_package_purchase = student.most_recent_test_drive_purchase
end
if self.save
lesson_booking.accept(self, slot)
msg = ChatMessage.create(teacher, nil, message, ChatMessage::CHANNEL_LESSON, nil, student, lesson_booking)
Notification.send_lesson_message('accept', self, true)
UserMailer.student_lesson_accepted(self, message, slot).deliver
UserMailer.teacher_lesson_accepted(self, message, slot).deliver
else
@@log.error("unable to accept slot #{slot.id} for lesson #{self.id}")
puts("unable to accept slot #{slot.id} for lesson #{self.id}")
end
end
end
end
def counter(params)
proposer = params[:proposer]
slot = params[:slot]
message = params[:message]
self.countering = true
slot.proposer = proposer
slot.lesson_session = self
self.lesson_booking_slots << slot
self.countered_slot = slot
self.countered_lesson = self
if self.save
if slot.update_all || lesson_booking.is_requested?
lesson_booking.counter(self, proposer, slot)
end
else
raise ActiveRecord::Rollback
end
msg = ChatMessage.create(slot.proposer, music_session, message, ChatMessage::CHANNEL_LESSON, nil, slot.recipient, lesson_booking)
Notification.send_lesson_message('counter', self, slot.is_teacher_created?)
end
def home_url
APP_CONFIG.external_root_url + "/client#/jamclass"
end
def web_url
APP_CONFIG.external_root_url + "/client#/jamclass/lesson-request/" + id
end
def update_payment_url
APP_CONFIG.external_root_url + "/client#/jamclass/update-payment"
end
def admin_url
APP_CONFIG.admin_root_url + "/admin/lesson_sessions/" + id
end
end
end