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

310 lines
11 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.

module JamRuby
class LessonSessionAnalyser
SUCCESS = 'success'
SESSION_ONGOING = 'session_ongoing'
THRESHOLD_MET = 'threshold_met'
WAITED_CORRECTLY = 'waited_correctly'
MINIMUM_TIME_MET = 'minimum_time_met' # for a teacher primarily; they waited around for the student sufficiently
MINIMUM_TIME_NOT_MET = 'mininum_time_not_met'
LATE_CANCELLATION = 'late_cancellation'
TEACHER_FAULT = 'teacher_fault'
STUDENT_FAULT = 'student_fault'
BOTH_FAULT = 'both_fault'
STUDENT_NOT_THERE_WHEN_JOINED = 'student_not_there_when_joined'
JOINED_LATE = 'did_not_join_on_time'
NO_SHOW = 'no_show'
NEITHER_SHOW = 'neither_show'
# what are the potential results?
# bill: true/false
# teacher: 'no_show'
# teacher: 'late'
# teacher: 'early_leave'
# teacher: 'waited_correctly'
# teacher: 'late_cancellation'
# student: 'no_show'
# student: 'late'
# student: 'early_leave'
# student: 'minimum_time_not_met'
# student: 'threshold_met'
# reason: 'session_ongoing'
# reason: 'success'
# reason: 'student_fault'
# reason: 'teacher_fault'
# reason: 'both_fault'
def self.analyse(lesson_session)
reason = nil
teacher = nil
student = nil
bill = false
music_session = lesson_session.music_session
student_histories = MusicSessionUserHistory.where(music_session_id: music_session.id, user_id: lesson_session.student.id)
teacher_histories = MusicSessionUserHistory.where(music_session_id: music_session.id, user_id: lesson_session.teacher.id)
# create ranges from music session user history
all_student_ranges = time_ranges(student_histories)
all_teacher_ranges = time_ranges(teacher_histories)
# flatten ranges into non-overlapping ranges to simplifly logic
student_ranges = merge_overlapping_ranges(all_student_ranges)
teacher_ranges = merge_overlapping_ranges(all_teacher_ranges)
intersecting = intersecting_ranges(student_ranges, teacher_ranges)
student_analysis = analyse_intersection(lesson_session, student_ranges)
teacher_analysis = analyse_intersection(lesson_session, teacher_ranges)
together_analysis = analyse_intersection(lesson_session, intersecting)
# spec: https://jamkazam.atlassian.net/wiki/display/PS/Product+Specification+-+JamClass#ProductSpecification-JamClass-TeacherReceives&RespondstoLessonBookingRequest
if music_session.session_removed_at.nil? && !((music_session.scheduled_start + (lesson_session.duration * 60)) < Time.now)
reason = SESSION_ONGOING
bill = false
else
#if lesson_session.is_canceled? && lesson_session.canceled_by_teacher? && lesson_session.canceled_late?
# # If the lesson was cancelled less than 24 hours before the start time by the teacher, then we do not bill the student.
# teacher = LATE_CANCELLATION
# bill = false
#elsif lesson_session.is_canceled? && lesson_session.canceled_by_student? && lesson_session.canceled_late?
# # If the lesson was cancelled less than 24 hours before the start time by the student (if that is even possible, I cant remember now), then we do bill the student.
# student = LATE_CANCELLATION
# bill = true
if together_analysis[:session_time] / 60 > APP_CONFIG.lesson_together_threshold_minutes
bill = true
reason = SUCCESS
elsif teacher_analysis[:joined_on_time] && teacher_analysis[:waited_correctly]
# if the teacher was present in the session within the first 5 minutes of the scheduled start time and stayed in the session for 10 minutes;
# and if either:
if student_analysis[:no_show]
# the student no-showed entirely, then we bill the student.
student = NO_SHOW
bill = true
elsif student_analysis[:joined_late]
# the student joined the lesson more than 10 minutes after the teacher did, regardless of whether the teacher was still in the lesson session at that point; then we bill the student
student = JOINED_LATE
bill = true
end
end
end
if reason.nil?
if student
reason = STUDENT_FAULT
elsif teacher
reason = TEACHER_FAULT
else
reason = NEITHER_SHOW
end
end
{
reason: reason,
teacher: teacher,
student: student,
bill: bill,
student_ranges: student_ranges,
teacher_ranges: teacher_ranges,
intersecting: intersecting,
student_analysis: student_analysis,
teacher_analysis: teacher_analysis,
together_analysis: together_analysis,
}
end
def self.annotate_timeline(lesson_session, analysis, ranges)
start = lesson_session.scheduled_start
end
def self.intersecting_ranges(ranges_a, ranges_b)
intersections = []
ranges_a.each do |range_a|
ranges_b.each do |range_b|
intersection = intersect(range_a, range_b)
intersections << intersection if intersection
end
end
merge_overlapping_ranges(intersections)
end
# needs to add to joined_on_time
# joined_on_time bool
# waited_correctly bool
# no_show bool
# joined_late bool
# minimum_time_met bool
# present_at_end bool
def self.analyse_intersection(lesson_session, ranges)
# be sure to call .to_time on any ActiveRecord time, because we get a ton of deprecation warninsg about Time#succ if you use ActiveSupport:: TimeZone
start = lesson_session.scheduled_start.to_time
planned_duration_seconds = lesson_session.duration * 60
end_time = start + planned_duration_seconds
join_start_boundary_begin = start
join_start_boundary_end = start + (APP_CONFIG.lesson_join_time_window_minutes * 60)
wait_boundary_begin = start
wait_boundary_end = start + (APP_CONFIG.lesson_wait_time_window_minutes * 60)
initial_join_window = Range.new(join_start_boundary_begin, join_start_boundary_end)
initial_wait_window = Range.new(wait_boundary_begin, wait_boundary_end)
session_window = Range.new(start, end_time)
# let's see how much time they spent together, irrespective of scheduled time
# and also, based on scheduled time
total = 0
in_scheduled_time = 0
in_wait_window_time = 0
# the initial time joined in the initial 'waiting window'
initial_join_in_scheduled_time = nil
# the amount of time spent in the initial 'waiting window'
initial_wait_time_in_scheduled_time = 0
last_wait_time_out = nil
joined_on_time = false
waited_correctly = false
no_show = true
joined_late = false
joined_in_wait_window = false
ranges.each do |range|
time = range.end - range.begin
total += time
in_session_range = intersect(range, session_window)
in_join_window_range = intersect(range, initial_join_window)
in_wait_window_range = intersect(range, initial_wait_window)
if in_session_range
in_scheduled_time += in_session_range.end - in_session_range.begin
no_show = false
end
if in_join_window_range
if initial_join_in_scheduled_time.nil?
initial_join_in_scheduled_time = in_join_window_range.begin
end
joined_on_time = true
end
if in_wait_window_range
in_wait_window_time += in_wait_window_range.end - in_wait_window_range.begin
last_wait_time_out = range.end
joined_in_wait_window = true
end
end
if joined_in_wait_window && !joined_on_time
joined_late = true
end
if last_wait_time_out && last_wait_time_out > wait_boundary_end
last_wait_time_out = wait_boundary_end
end
initial_waiting_time_pct = nil
potential_waiting_time = nil
# let's see if this person was hanging around for the bulk of this waiting window (to rule out someone coming/going very fast, trying to miss someone)
if last_wait_time_out && initial_join_in_scheduled_time
total_in_waiting_time = 0
potential_waiting_range = Range.new(initial_join_in_scheduled_time, last_wait_time_out)
ranges.each do |range|
in_waiting = intersect(potential_waiting_range, range)
if in_waiting
total_in_waiting_time += in_waiting.end - in_waiting.begin
end
end
potential_waiting_time = last_wait_time_out - initial_join_in_scheduled_time
initial_waiting_time_pct = total_in_waiting_time.to_f / potential_waiting_time.to_f
# finally with all this stuff calculated, we can check:
# 1) did they wait a solid % of time between the time they joined, and left, during the initial 10 minute waiting window?
# 2) did they
if (initial_waiting_time_pct >= APP_CONFIG.wait_time_window_pct) &&
(last_wait_time_out >= (wait_boundary_end - (APP_CONFIG.end_of_wait_window_forgiveness_minutes * 60)))
waited_correctly = true
end
end
# percentage computation of time spent during the session time
in_scheduled_percentage = in_scheduled_time.to_f / planned_duration_seconds.to_f
joined_on_time = joined_on_time
{
total_time: total,
session_time: in_scheduled_time,
session_pct: in_scheduled_percentage,
joined_on_time: joined_on_time,
waited_correctly: waited_correctly,
no_show: no_show,
joined_late: joined_late,
initial_join_in_scheduled_time: initial_join_in_scheduled_time,
last_wait_time_out: last_wait_time_out,
in_wait_window_time: in_wait_window_time,
initial_waiting_pct: initial_waiting_time_pct,
potential_waiting_time: potential_waiting_time
}
end
def self.intersect(a, b)
min, max = a.first, a.exclude_end? ? a.max : a.last
other_min, other_max = b.first, b.exclude_end? ? b.max : b.last
new_min = a === other_min ? other_min : b === min ? min : nil
new_max = a === other_max ? other_max : b === max ? max : nil
new_min && new_max ? Range.new(new_min, new_max) : nil
end
def self.time_ranges(histories)
ranges = []
histories.each do |history|
ranges << history.range
end
ranges
end
def self.ranges_overlap?(a, b)
a.cover?(b.begin) || b.cover?(a.begin)
end
def self.merge_ranges(a, b)
[a.begin, b.begin].min..[a.end, b.end].max
end
def self.merge_overlapping_ranges(ranges)
ranges.sort_by(&:begin).inject([]) do |ranges, range|
if !ranges.empty? && ranges_overlap?(ranges.last, range)
ranges[0...-1] + [merge_ranges(ranges.last, range)]
else
ranges + [range]
end
end
end
end
end