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

827 lines
28 KiB
Ruby

# represenst the type of lesson package
module JamRuby
class LessonBooking < ActiveRecord::Base
include HtmlSanitize
html_sanitize strict: [:description, :cancel_message]
include ActiveModel::Dirty
@@log = Logging.logger[LessonBooking]
attr_accessor :accepting, :countering, :countered_slot, :countered_lesson
STATUS_REQUESTED = 'requested'
STATUS_CANCELED = 'canceled'
STATUS_APPROVED = 'approved'
STATUS_SUSPENDED = 'suspended'
STATUS_COUNTERED = 'countered'
STATUS_TYPES = [STATUS_REQUESTED, STATUS_CANCELED, STATUS_APPROVED, STATUS_SUSPENDED, STATUS_COUNTERED]
LESSON_TYPE_FREE = 'single-free'
LESSON_TYPE_TEST_DRIVE = 'test-drive'
LESSON_TYPE_PAID = 'paid'
LESSON_TYPES = [LESSON_TYPE_FREE, LESSON_TYPE_TEST_DRIVE, LESSON_TYPE_PAID]
PAYMENT_STYLE_ELSEWHERE = 'elsewhere'
PAYMENT_STYLE_SINGLE = 'single'
PAYMENT_STYLE_WEEKLY = 'weekly'
PAYMENT_STYLE_MONTHLY = 'monthly'
PAYMENT_STYLES = [PAYMENT_STYLE_ELSEWHERE, PAYMENT_STYLE_SINGLE, PAYMENT_STYLE_WEEKLY, PAYMENT_STYLE_MONTHLY]
belongs_to :user, class_name: "JamRuby::User"
belongs_to :teacher, class_name: "JamRuby::User"
belongs_to :accepter, class_name: "JamRuby::User"
belongs_to :canceler, class_name: "JamRuby::User"
belongs_to :default_slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :default_slot_id, inverse_of: :defaulted_booking
belongs_to :counter_slot, class_name: "JamRuby::LessonBookingSlot", foreign_key: :counter_slot_id, inverse_of: :countered_booking
has_many :lesson_booking_slots, class_name: "JamRuby::LessonBookingSlot"
has_many :lesson_sessions, class_name: "JamRuby::LessonSession"
has_many :lesson_package_purchases, class_name: "JamRuby::LessonPackagePurchase"
validates :user, presence: true
validates :teacher, presence: true
validates :lesson_type, inclusion: {in: LESSON_TYPES}
validates :status, presence: true, inclusion: {in: STATUS_TYPES}
validates :recurring, inclusion: {in: [true, false]}
validates :sent_notices, inclusion: {in: [true, false]}
validates :card_presumed_ok, inclusion: {in: [true, false]}
validates :active, inclusion: {in: [true, false]}
validates :lesson_length, inclusion: {in: [30, 45, 60, 90, 120]}
validates :payment_style, inclusion: {in: PAYMENT_STYLES}
validates :booked_price, presence: true
validates :description, no_profanity: true, length: {minimum: 10, maximum: 20000}
validate :validate_user, on: :create
validate :validate_recurring
validate :validate_lesson_booking_slots
validate :validate_lesson_length
validate :validate_payment_style
validate :validate_accepted, :if => :accepting
before_save :before_save
before_validation :before_validation
after_create :after_create
around_save :around_update
scope :test_drive, -> { where(lesson_type: LESSON_TYPE_TEST_DRIVE) }
scope :active, -> { where(active: true) }
scope :approved, -> { where(status: STATUS_APPROVED) }
scope :requested, -> { where(status: STATUS_REQUESTED) }
scope :canceled, -> { where(status: STATUS_CANCELED) }
scope :suspended, -> { where(status: STATUS_SUSPENDED) }
scope :engaged, -> { where("status = '#{STATUS_APPROVED}' OR status = '#{STATUS_REQUESTED}' OR status = '#{STATUS_SUSPENDED}'") }
def before_validation
if self.booked_price.nil?
self.booked_price = compute_price
end
end
def after_create
if card_presumed_ok && !sent_notices
send_notices
end
end
def before_save
automatically_default_slot
end
def around_update
@default_slot_did_change = self.default_slot_id_changed?
yield
sync_lessons
sync_remaining_test_drives
@default_slot_did_change = nil
@accepting = nil
@countering = nil
end
# here for shopping_cart
def product_info
{price: booked_price, real_price: booked_price, total_price: booked_price}
end
# here for shopping_cart
def price
booked_price
end
def alt_slot
found = nil
lesson_booking_slots.each do |slot|
if slot.id != default_slot.id
found = slot
break
end
end
found
end
def student
user
end
def next_lesson
if recurring
session = lesson_sessions.joins(:music_session).where("scheduled_start is not null").where("scheduled_start > ?", Time.now).order(:created_at).first
LessonSession.find(session.id) if session
else
lesson_sessions[0]
end
end
def accept(lesson_session, slot, accepter)
if !is_active?
self.accepting = true
end
self.active = true
self.status = STATUS_APPROVED
self.counter_slot = nil
self.default_slot = slot
self.accepter = accepter
success = self.save
if !success
puts "unable to accept lesson booking #{errors.inspect}"
end
success
end
def counter(lesson_session, proposer, slot)
self.countering = true
self.lesson_booking_slots << slot
self.counter_slot = slot
#self.status = STATUS_COUNTERED
self.save
end
def automatically_default_slot
if is_requested?
if lesson_booking_slots.length > 0
self.default_slot = lesson_booking_slots[0]
end
end
end
def sync_remaining_test_drives
if is_test_drive? || is_single_free?
if card_presumed_ok && !user_decremented
self.user_decremented = true
self.save(validate: false)
if is_single_free?
user.remaining_free_lessons = user.remaining_free_lessons - 1
elsif is_test_drive?
user.remaining_test_drives = user.remaining_test_drives - 1
end
user.save(validate: false)
end
end
end
def create_minimum_booking_time
# trying to be too smart
#(Time.now + APP_CONFIG.minimum_lesson_booking_hrs * 60*60)
Time.now
end
def sync_lessons
if is_canceled?
# don't create new sessions if cancelled
return
end
if @default_slot_did_change
end
# Here we go; let's create a lesson(s) as needed
# we need to make lessons into the future a bit, to give time for everyone involved
minimum_start_time = create_minimum_booking_time
# get all sessions that are already scheduled for this booking ahead of the minimum time
sessions = MusicSession.joins(:lesson_session).where("lesson_sessions.lesson_booking_id = ?", id).where("scheduled_start is not null").where("scheduled_start > ?", minimum_start_time).order(:created_at)
if @default_slot_did_change
# # adjust all session times
offset = 0
sessions.each_with_index do |item, i|
item.lesson_session.slot = default_slot
result = item.lesson_session.update_next_available_time(offset)
if result
offset = result
offset += 1
end
end
end
needed_sessions = determine_needed_sessions(sessions)
# if the latest scheduled session is after the minimum start time, then bump up minimum start time
last_session = sessions.last
last_session.reload if last_session # because of @default_slot_did_change logic above, this can be necessary
if last_session && last_session.scheduled_start && last_session.scheduled_start > minimum_start_time
minimum_start_time = last_session.scheduled_start
end
times = default_slot.scheduled_times(needed_sessions, minimum_start_time)
scheduled_lessons(times)
end
# sensitive to current time
def predicted_times_for_month(year, month)
first_day = Date.new(year, month, 1)
last_day = Date.new(year, month, -1)
sessions = MusicSession.joins(:lesson_session).where("lesson_sessions.lesson_booking_id = ?", id).where("scheduled_start >= ?", first_day).where("scheduled_start <= ?", last_day).order(:created_at)
times = []
sessions.each do |session|
times << session.scheduled_start
end
last_session = sessions.last
start_day = first_day
if last_session
start_day = last_session.scheduled_start.to_date + 1
end
# now flesh out the rest of the month with predicted times
more_times = default_slot.scheduled_times(5, start_day)
more_times.each do |time|
if time.to_date >= first_day && time.to_date <= last_day
times << time
end
end
times
end
def determine_needed_sessions(sessions)
needed_sessions = 0
if is_requested?
# in the case of a requested booking (not approved) only make one, even if it's recurring. This is for UI considerations
if sessions.count == 0
needed_sessions = 1
end
elsif is_active?
expected_num_sessions = recurring ? 2 : 1
needed_sessions = expected_num_sessions - sessions.count
end
needed_sessions
end
def scheduled_lessons(times)
times.each do |time|
lesson_session = LessonSession.create(self)
if lesson_session.errors.any?
puts "JamClass lesson session creation errors #{lesson_session.errors.inspect}"
@@log.error("JamClass lesson session creation errors #{lesson_session.errors.inspect}")
raise ActiveRecord::Rollback
end
ms_tz = ActiveSupport::TimeZone.new(default_slot.timezone)
ms_tz = "#{ms_tz.name},#{default_slot.timezone}"
rsvps = [{instrument_id: 'other', proficiency_level: 0, approve: true}]
music_session = MusicSession.create(student, {
name: "#{display_type2} JamClass taught by #{teacher.name}",
description: "This is a #{lesson_length}-minute #{display_type2} lesson with #{teacher.name}.",
musician_access: false,
fan_access: false,
genres: ['other'],
approval_required: false,
fan_chat: false,
legal_policy: "standard",
language: 'eng',
duration: lesson_length,
recurring_mode: false,
timezone: ms_tz,
create_type: MusicSession::CREATE_TYPE_LESSON,
is_unstructured_rsvp: true,
scheduled_start: time,
invitations: [teacher.id],
lesson_session: lesson_session,
rsvp_slots: rsvps
})
if music_session.errors.any?
puts "JamClass lesson scheduling errors #{music_session.errors.inspect}"
@@log.error("JamClass lesson scheduling errors #{music_session.errors.inspect}")
raise ActiveRecord::Rollback
end
if lesson_session.is_active?
# send out email to student to act as something they can add to their calendar
Notification.send_student_jamclass_invitation(music_session, student)
end
end
end
def is_weekly_payment?
payment_style == PAYMENT_STYLE_WEEKLY
end
def is_monthly_payment?
payment_style == PAYMENT_STYLE_MONTHLY
end
def requires_per_session_billing?
is_normal? && !is_monthly_payment?
end
def requires_teacher_distribution?(target)
if target.is_a?(JamRuby::LessonSession)
is_test_drive? || (is_normal? && !is_monthly_payment?)
elsif target.is_a?(JamRuby::LessonPackagePurchase)
is_monthly_payment?
else
raise "unable to determine object type of #{target}"
end
end
def is_requested?
status == STATUS_REQUESTED
end
def is_canceled?
status == STATUS_CANCELED
end
def is_approved?
status == STATUS_APPROVED
end
def is_suspended?
status == STATUS_SUSPENDED
end
def is_active?
active
end
def validate_accepted
# accept is multipe purpose; either accept the initial request, or a counter slot
if self.status_was != STATUS_REQUESTED && counter_slot.nil? # && self.status_was != STATUS_COUNTERED
self.errors.add(:status, "This lesson is already #{self.status}.")
end
self.accepting = false
end
def send_notices
UserMailer.student_lesson_request(self).deliver
UserMailer.teacher_lesson_request(self).deliver
Notification.send_lesson_message('requested', lesson_sessions[0], false) # TODO: this isn't quite an 'accept'
self.sent_notices = true
self.save
end
def lesson_package_type
if is_single_free?
LessonPackageType.single_free
elsif is_test_drive?
LessonPackageType.test_drive
elsif is_normal?
LessonPackageType.single
end
end
def display_type2
if is_single_free?
"Free"
elsif is_test_drive?
"TestDrive"
elsif is_normal?
"Single"
end
end
def display_type
if is_single_free?
"Free"
elsif is_test_drive?
"TestDrive"
elsif is_normal?
if recurring
"recurring"
else
"single"
end
end
end
# determine the price of this booking based on what the user wants, and the teacher's pricing
def compute_price
if is_single_free?
0
elsif is_test_drive?
LessonPackageType.test_drive.price
elsif is_normal?
teacher.teacher.booking_price(lesson_length, payment_style != PAYMENT_STYLE_MONTHLY)
end
end
def distribution_price_in_cents
if is_single_free?
0
elsif is_test_drive?
10 * 100
elsif is_normal?
booked_price * 100
end
end
def is_single_free?
lesson_type == LESSON_TYPE_FREE
end
def is_test_drive?
lesson_type == LESSON_TYPE_TEST_DRIVE
end
def is_normal?
lesson_type == LESSON_TYPE_PAID
end
def dayWeekDesc(slot = default_slot)
day = case slot.day_of_week
when 0 then "Sunday"
when 1 then "Monday"
when 2 then "Tuesday"
when 3 then "Wednesday"
when 4 then "Thursday"
when 5 then "Friday"
when 6 then "Saturday"
end
if slot.hour > 11
hour = slot.hour - 12
if hour == 0
hour = 12
end
am_pm = 'pm'
else
hour = slot.hour
if hour == 0
hour = 12
end
am_pm = 'am'
end
"#{day} at #{hour}:#{slot.minute}#{am_pm}"
end
def approved_before?
!self.accepter_id.nil?
end
def cancel(canceler, other, message)
self.active = false
self.status = STATUS_CANCELED
self.cancel_message = message
self.canceler = canceler
success = save
if success
if approved_before?
# just tell both people it's cancelled, to act as confirmation
Notification.send_lesson_message('canceled', next_lesson, false)
Notification.send_lesson_message('canceled', next_lesson, true)
UserMailer.student_lesson_booking_canceled(self, message).deliver
UserMailer.teacher_lesson_booking_canceled(self, message).deliver
chat_message_prefix = "Lesson Canceled"
else
if canceler == student
# if it's the first time acceptance student canceling, we call it a 'cancel'
Notification.send_lesson_message('canceled', next_lesson, false)
UserMailer.teacher_lesson_booking_canceled(self, message).deliver
chat_message_prefix = "Lesson Canceled"
else
# if it's the first time acceptance teacher, it was declined
UserMailer.student_lesson_booking_declined(self, message).deliver
Notification.send_lesson_message('declined', next_lesson, true)
chat_message_prefix = "Lesson Declined"
end
end
chat_message = message.nil? ? chat_message_prefix : "#{chat_message_prefix}: #{message}"
msg = ChatMessage.create(canceler, nil, chat_message, ChatMessage::CHANNEL_LESSON, nil, other, self)
end
success
end
def card_approved
self.card_presumed_ok = true
if self.save && !sent_notices
send_notices
end
end
def validate_user
if card_presumed_ok && is_single_free?
if !user.has_free_lessons?
errors.add(:user, 'have no remaining free lessons')
end
#if !user.has_stored_credit_card?
# errors.add(:user, 'has no credit card stored')
#end
elsif is_test_drive?
if user.has_requested_test_drive?(teacher) && !user.admin
errors.add(:user, "has a requested TestDrive with this teacher")
end
if !user.has_test_drives? && !user.can_buy_test_drive?
errors.add(:user, "have no remaining test drives")
end
elsif is_normal?
#if !user.has_stored_credit_card?
# errors.add(:user, 'has no credit card stored')
#end
end
end
def validate_teacher
# shouldn't we check if the teacher already has a booking in this time slot, or at least warn the user
end
def validate_recurring
if is_single_free? || is_test_drive?
if recurring
errors.add(:recurring, "can not be true for this type of lesson")
end
end
false
end
def validate_lesson_booking_slots
if lesson_booking_slots.length == 0 || lesson_booking_slots.length == 1
errors.add(:lesson_booking_slots, "must have two times specified")
end
end
def validate_lesson_length
if is_single_free? || is_test_drive?
if lesson_length != 30
errors.add(:lesson_length, "must be 30 minutes")
end
end
end
def validate_payment_style
if is_normal?
if payment_style.nil?
errors.add(:payment_style, "can't be blank")
end
end
end
def self.book_free(user, teacher, lesson_booking_slots, description)
self.book(user, teacher, LessonBooking::LESSON_TYPE_FREE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description)
end
def self.book_test_drive(user, teacher, lesson_booking_slots, description)
self.book(user, teacher, LessonBooking::LESSON_TYPE_TEST_DRIVE, lesson_booking_slots, false, 30, PAYMENT_STYLE_ELSEWHERE, description)
end
def self.book_normal(user, teacher, lesson_booking_slots, description, recurring, payment_style, lesson_length)
self.book(user, teacher, LessonBooking::LESSON_TYPE_PAID, lesson_booking_slots, recurring, lesson_length, payment_style, description)
end
def self.book(user, teacher, lesson_type, lesson_booking_slots, recurring, lesson_length, payment_style, description)
lesson_booking = nil
LessonBooking.transaction do
lesson_booking = LessonBooking.new
lesson_booking.user = user
lesson_booking.card_presumed_ok = user.has_stored_credit_card?
lesson_booking.sent_notices = false
lesson_booking.teacher = teacher
lesson_booking.lesson_type = lesson_type
lesson_booking.recurring = recurring
lesson_booking.lesson_length = lesson_length
lesson_booking.payment_style = payment_style
lesson_booking.description = description
lesson_booking.status = STATUS_REQUESTED
# two-way association slots, for before_validation loic in slot to work
lesson_booking.lesson_booking_slots = lesson_booking_slots
lesson_booking_slots.each do |slot|
slot.lesson_booking = lesson_booking
slot.message = description
end if lesson_booking_slots
if lesson_booking.save
msg = ChatMessage.create(user, lesson_booking.lesson_sessions[0], description, ChatMessage::CHANNEL_LESSON, nil, teacher, lesson_booking)
end
end
lesson_booking
end
def self.unprocessed(current_user)
LessonBooking.where(user_id: current_user.id).where(card_presumed_ok: false)
end
def self.requested(current_user)
LessonBooking.where(user_id: current_user.id).where(status: STATUS_REQUESTED)
end
def self.find_bookings_needing_sessions(minimum_start_time)
MusicSession.select([:lesson_booking_id]).joins(:lesson_session => :lesson_booking).where("lesson_bookings.active = true").where('lesson_bookings.recurring = true').where("scheduled_start is not null").where("scheduled_start > ?", minimum_start_time).group(:lesson_booking_id).having('count(lesson_booking_id) < 2')
end
# check for any recurring sessions where there are not at least 2 sessions into the future. If not, we need to make sure they get made
def self.hourly_check
schedule_upcoming_lessons
bill_monthlies
end
def self.bill_monthlies
now = Time.now
billable_monthlies(now).each do |lesson_booking|
lesson_booking.bill_monthly(now)
end
today = now.to_date
seven_days_in_future = today + 7
is_different_month = seven_days_in_future.month != today.month
if is_different_month
next_month = seven_days_in_future.to_time
billable_monthlies(next_month).each do |lesson_booking|
lesson_booking.bill_monthly(next_month)
end
end
end
def self.billable_monthlies(now)
current_month_first_day = Date.new(now.year, now.month, 1)
current_month_last_day = Date.new(now.year, now.month, -1)
#next_month_last_day = now.month == 12 ? Date.new(now.year + 1, 1, -1) : Date.new(now.year, now.month + 1, -1)
LessonBooking
.joins(:lesson_sessions => :music_session)
.joins("LEFT JOIN lesson_package_purchases ON (lesson_package_purchases.lesson_booking_id = lesson_bookings.id AND (lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}))")
.where("lesson_package_purchases.id IS NULL OR (lesson_package_purchases.id IS NOT NULL AND lesson_package_purchases.post_processed = false)")
.where(payment_style: PAYMENT_STYLE_MONTHLY)
.active
.where('music_sessions.scheduled_start >= ?', current_month_first_day)
.where('music_sessions.scheduled_start <= ?', current_month_last_day).uniq
=begin
today = now.to_date
seven_days_in_future = today + 7
is_different_month = seven_days_in_future.month != today.month
if is_different_month
condition = "(((lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{current_month_first_day.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{current_month_first_day.month} ) )
OR ((lesson_package_purchases.year = #{seven_days_in_future.year} AND lesson_package_purchases.month = #{seven_days_in_future.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{seven_days_in_future.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{seven_days_in_future.month} ) ) )"
else
condition = "((lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) AND ( (EXTRACT(YEAR FROM lesson_sessions.created_at)) = #{current_month_first_day.year} AND (EXTRACT(MONTH FROM lesson_sessions.created_at)) = #{current_month_first_day.month} ) )"
end
# .where("(lesson_package_purchases.year = #{current_month_first_day.year} AND lesson_package_purchases.month = #{current_month_first_day.month}) OR (lesson_package_purchases.year = #{next_month_last_day.year} AND lesson_package_purchases.month = #{next_month_last_day.month})")
# find any monthly-billed bookings that have a session coming up within 7 days, and if so, attempt to bill them
LessonBooking
.joins(:lesson_sessions)
.joins("LEFT JOIN lesson_package_purchases ON (lesson_package_purchases.lesson_booking_id = lesson_bookings.id AND #{condition})")
.where("lesson_package_purchases.id IS NULL OR (lesson_package_purchases.id IS NOT NULL AND lesson_package_purchases.post_processed = false)")
.where(payment_style: PAYMENT_STYLE_MONTHLY)
.where(status: STATUS_APPROVED)
.where('lesson_sessions.created_at >= ?', current_month_first_day)
.where('lesson_sessions.created_at <= ?', seven_days_in_future).uniq
=end
end
def self.bookings(student, teacher, since_at = nil)
bookings = LessonBooking.where(user_id: student.id, teacher_id: teacher.id)
if since_at
bookings = bookings.where('created_at >= ?', since_at)
end
bookings
end
def self.engaged_bookings(student, teacher, since_at = nil)
bookings = bookings(student, teacher, since_at)
bookings.engaged
end
def bill_monthly(now)
LessonBooking.transaction do
self.lock!
current_month = Date.new(now.year, now.month, 1)
bill_for_month(current_month)
today = now.to_date
seven_days_in_future = today + 7
is_different_month = seven_days_in_future.month != today.month
if is_different_month
bill_for_month(seven_days_in_future)
end
end
end
def bill_for_month(day_in_month)
# try to find lesson package purchase for this month, and last month, and see if they need processing
current_month_purchase = lesson_package_purchases.where(lesson_booking_id: self.id, user_id: student.id, year: day_in_month.year, month: day_in_month.month).first
if current_month_purchase.nil?
current_month_purchase = LessonPackagePurchase.create(user, self, lesson_package_type, day_in_month.year, day_in_month.month)
end
current_month_purchase.bill_monthly
end
def suspend!
# when this is called, the calling code sends out a email to let the student and teacher know (it feels unnatural it's not here, though)
self.status = STATUS_SUSPENDED
self.active = false
if self.save
future_sessions.each do |lesson_session|
LessonSession.find(lesson_session.id).suspend!
end
end
end
def unsuspend!
if self.status == STATUS_SUSPENDED
self.status = STATUS_APPROVED
self.active = true
if self.save
future_sessions.each do |lesson_session|
LessonSession.find(lesson_session.id).unsuspend!
end
end
end
end
def future_sessions
lesson_sessions.joins(:music_session).where('scheduled_start > ?', Time.now).order(:created_at)
end
def self.schedule_upcoming_lessons
minimum_start_time = (Time.now + APP_CONFIG.minimum_lesson_booking_hrs * 60*60)
lesson_bookings = find_bookings_needing_sessions(minimum_start_time)
lesson_bookings.each do |data|
lesson_booking = LessonBooking.find(data["lesson_booking_id"])
lesson_booking.sync_lessons
end
end
def home_url
APP_CONFIG.external_root_url + "/client#/jamclass"
end
def web_url
APP_CONFIG.external_root_url + "/client#/jamclass/lesson-booking/" + 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_bookings/" + id
end
end
end