diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr index 267a42c38d..32f39eb0b0 100644 --- a/drivers/place/auto_release_spec.cr +++ b/drivers/place/auto_release_spec.cr @@ -6,7 +6,7 @@ class StaffAPI < DriverSpecs::MockDriver self[:rejected] = 0 end - def reject(booking_id : String | Int64, utm_source : String? = nil) + def reject(booking_id : String | Int64, utm_source : String? = nil, instance : Int64? = nil) self[:rejected] = self[:rejected].as_i + 1 end diff --git a/drivers/place/booking_model.cr b/drivers/place/booking_model.cr index e36b0f545f..e2fb51f90c 100644 --- a/drivers/place/booking_model.cr +++ b/drivers/place/booking_model.cr @@ -42,8 +42,8 @@ class Place::Booking property booked_by_name : String property booked_by_email : String - getter checked_out_at : Int64? = nil - getter deleted : Bool? = nil + property checked_out_at : Int64? = nil + property deleted : Bool? = nil property checked_in : Bool { false } property title : String? property description : String? diff --git a/drivers/place/bookings/locker_booking_sync.cr b/drivers/place/bookings/locker_booking_sync.cr index b68f7a7db7..a649a24be1 100644 --- a/drivers/place/bookings/locker_booking_sync.cr +++ b/drivers/place/bookings/locker_booking_sync.cr @@ -166,6 +166,8 @@ class Place::Bookings::LockerBookingSync < PlaceOS::Driver place_bookings.find { |book| book.asset_id == booking.asset_id && (book.user_id == booking.user_id || book.user_email.downcase == booking.user_email.downcase) } end + logger.debug { "planning to allocate #{allocate_lockers.size}, release #{release_lockers.size} and check #{place_bookings.size} against #{lockers.size} allocations -- id:#{unique_id}" } + # remove allocations where a place booking has been checked out # ensure the locker is still allocated to that user allocated = 0 @@ -220,7 +222,8 @@ class Place::Bookings::LockerBookingSync < PlaceOS::Driver # resolve this below if this step failed in a previous run if locker logger.debug { " -- update #{locker.locker_id} booking state on #{place_booking.id} to #{locker.allocation_id} -- id:#{unique_id}" } - staff_api.booking_state(place_booking.id, locker.allocation_id) + staff_api.booking_state(place_booking.id, locker.allocation_id) if place_booking.instance + staff_api.booking_state(place_booking.id, locker.allocation_id, instance: place_booking.instance) allocated += 1 else alloc_failed << place_booking diff --git a/drivers/place/bookings/locker_booking_sync_spec.cr b/drivers/place/bookings/locker_booking_sync_spec.cr index c9d43e367f..9fd30a8b2c 100644 --- a/drivers/place/bookings/locker_booking_sync_spec.cr +++ b/drivers/place/bookings/locker_booking_sync_spec.cr @@ -1,41 +1,510 @@ require "placeos-driver/spec" +require "placeos-driver/interface/lockers" +require "../booking_model" +require "./locker_models" DriverSpecs.mock_driver "Place::Bookings::LockerBookingSync" do system({ - StaffAPI: {StaffAPIMock}, + StaffAPI: {StaffAPIMock}, + LocationServices: {LocationServicesMock}, + Lockers: {LockersMock}, }) - # Start a new meeting - # exec(:fetch_and_check_in).get.should eq "checked-in 2 bookings, failed 1: [3]" + exec :sync_level, "zone-level1" + sleep 1 + + staff_api = system(:StaffAPI).as(StaffAPIMock) + staff_api.created.should eq 0 + staff_api.query_calls.should eq 2 end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver + # always requesting the building zone for timezone info + def zone(id : String) + raise "unexpected id #{id.inspect}, expected zone-building-id" unless id == "zone-building-id" + { + timezone: "Australia/Sydney", + } + end + + def systems_in_building(id : String, ids_only : Bool = true) + raise "unexpected id #{id.inspect}, expected zone-building-id" unless id == "zone-building-id" + raise "only ids supported, unexpected call" unless ids_only + { + "zone-level1" => [] of String, + "zone-level2" => [] of String, + } + end + + def reset + @query_calls = 0 + @created = 0 + @checked_out = 0 + @updated = 0 + @bookings = {} of Int64 => Place::Booking + end + + # emulate a basic database + getter bookings : Hash(Int64, Place::Booking) = {} of Int64 => Place::Booking + getter query_calls : Int32 = 0 + getter created : Int32 = 0 + getter updated : Int32 = 0 + getter checked_out : Int32 = 0 + def query_bookings( - type : String, + type : String? = nil, period_start : Int64? = nil, period_end : Int64? = nil, zones : Array(String) = [] of String, user : String? = nil, email : String? = nil, state : String? = nil, + event_id : String? = nil, + ical_uid : String? = nil, created_before : Int64? = nil, created_after : Int64? = nil, approved : Bool? = nil, rejected : Bool? = nil, - checked_in : Bool? = nil + checked_in : Bool? = nil, + include_checked_out : Bool? = nil, + extension_data : JSON::Any? = nil, + deleted : Bool? = nil ) - [{id: 1}, {id: 2}, {id: 3}] + @query_calls += 1 + # return the bookings in the database + # ignore calls to deleted and return an empty array + return [] of Nil if deleted + @bookings.values + end + + def booking_state(booking_id : String | Int64, state : String, instance : Int64? = nil) + booking = @bookings[booking_id]? + raise "could not find booking #{booking_id}" unless booking + @updated += 1 + booking.process_state = state + booking end - def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil) - logger.debug { "checking in booking #{booking_id} to: #{state} from #{utm_source}" } + # we won't test with a booking instance here as it jsut complicates things + # def update_booking - case booking_id - when 3 - raise "issue updating booking state #{booking_id}: 404" + def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil) + booking = @bookings[booking_id]? + raise "could not find booking #{booking_id}" unless booking + booking.checked_in = state + booking.checked_out_at = Time.utc.to_unix unless state + @checked_out += 1 unless state + booking + end + + def user(id : String) + case id + when "user-1", "user-1@email.com" + { + id: "user-1", + email: "user-1@email.com", + } + when "user-2", "user-2@email.com" + { + id: "user-2", + email: "user-2@email.com", + } else + raise "unexpected user id requested #{id}" + end + end + + def create_booking( + booking_type : String, + asset_id : String, + user_id : String, + user_email : String, + user_name : String, + zones : Array(String), + booking_start : Int64? = nil, + booking_end : Int64? = nil, + checked_in : Bool = false, + approved : Bool? = nil, + title : String? = nil, + description : String? = nil, + time_zone : String? = nil, + extension_data : JSON::Any? = nil, + utm_source : String? = nil, + limit_override : Int64? = nil, + event_id : String? = nil, + ical_uid : String? = nil, + attendees : Array(Nil)? = nil, + process_state : String? = nil, + recurrence_type : String? = nil, + recurrence_days : Int32? = nil, + recurrence_nth_of_month : Int32? = nil, + recurrence_interval : Int32? = nil, + recurrence_end : Int64? = nil + ) + @created += 0 + id = rand(Int64::MAX) + @bookings[id] = Place::Booking.new( + id: id, + booking_type: booking_type, + asset_id: asset_id, + user_id: user_id, + user_email: user_email, + user_name: user_name, + zones: zones, + booked_by_name: user_name, + booked_by_email: user_email, + booking_start: booking_start.not_nil!, + booking_end: booking_end.not_nil!, + timezone: time_zone.not_nil!, + process_state: process_state + ) + end + + def get_booking(booking_id : String | Int64, instance : Int64? = nil) + # this function shouldn't really be called + logger.warn { "UNEXPECTED CALL TO staff_api.get_booking(#{booking_id.inspect}, #{instance.inspect})" } + @bookings[booking_id.to_i] + end +end + +# re-open classes to add some helpers +class ::Place::Locker + def initialize(@id, @name, @bank_id, @bookable, @level_id) + end + + # for tracking, not part of metadata + property allocated_to : String? = nil + property allocated_at : Time? = nil + property allocated_until : Time? = nil + property shared_with : Array(String) = [] of String + + def release + @allocated_to = nil + @allocated_at = nil + @allocated_until = nil + @shared_with = [] of String + end + + def allocated? : Bool + if time = self.allocated_until + if time > Time.utc + true + else + false + end + elsif self.allocated_to.presence true + else + false end end + + def not_allocated? : Bool + !allocated? + end +end + +class ::PlaceOS::Driver::Interface::Lockers::PlaceLocker + def initialize(@bank_id, locker : ::Place::Locker, @building = nil) + @locker_id = locker.id + @locker_name = locker.name + @mac = "lb=#{@bank_id}&lk=#{locker.id}" + if time = locker.allocated_until + if time > Time.utc + in_use = true + @expires_at = time + else + in_use = false + @expires_at = nil + end + elsif allocated_to = locker.allocated_to + in_use = true + @expires_at = nil + else + in_use = false + @expires_at = nil + end + @allocated = in_use + @allocation_id = "#{locker.allocated_to}--#{@mac}" if in_use + @level = locker.level_id + end +end + +class Place::LockerBank + def initialize(@id, @name, @zones, @level_id, @lockers) + end +end + +# :nodoc: +class LockersMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Lockers + + alias LockerBank = Place::LockerBank + alias Locker = Place::Locker + + def reset + @locker_banks = nil + @locker_details = nil + end + + def invoked_by_user_id + "user-1" + end + + # implement the locker metadata parser methods + getter locker_banks : Hash(String, LockerBank) do + { + "bank-1" => LockerBank.new("bank-1", "Bank 1", ["zone-building-id", "zone-level1"], "zone-level1", [ + Locker.new("locker-1", "Lock 1", "bank-1", true, "zone-level1"), + Locker.new("locker-2", "Lock 2", "bank-1", true, "zone-level1"), + ]), + "bank-2" => LockerBank.new("bank-2", "Bank 2", ["zone-building-id", "zone-level2"], "zone-level2", [ + Locker.new("locker-3", "Lock 3", "bank-2", true, "zone-level2"), + Locker.new("locker-4", "Lock 4", "bank-2", true, "zone-level2"), + ]), + } + end + + getter locker_details : Hash(String, Locker) do + lockers = {} of String => Locker + locker_banks.each_value do |bank| + bank.lockers.each do |locker| + lockers[locker.id] = locker + end + end + lockers + end + + def building_id : String + "zone-building-id" + end + + def levels : Array(String) + ["zone-level1", "zone-level2"] + end + + # allocates a locker now, the allocation may expire + def locker_allocate( + # PlaceOS user id + user_id : String, + + # the locker location + bank_id : String | Int64, + + # allocates a random locker if this is nil + locker_id : String | Int64? = nil, + + # attempts to create a booking that expires at the time specified + expires_at : Int64? = nil + ) : PlaceLocker + bank = locker_banks[bank_id.to_s] + locker_id = locker_id ? locker_id : bank.locker_hash.values.select(&.not_allocated?).sample.id + locker = bank.locker_hash[locker_id.to_s] + locker.allocated_to = user_id + locker.allocated_at = Time.utc + locker.allocated_until = Time.unix(expires_at) if expires_at + PlaceLocker.new(bank_id, locker, building_id) + rescue + raise "no available lockers" + end + + # return the locker to the pool + def locker_release( + bank_id : String | Int64, + locker_id : String | Int64, + + # release / unshare just this user - otherwise release the whole locker + owner_id : String? = nil + ) : Nil + locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s] + if locker.allocated_to == owner_id + locker.release + else + locker.shared_with.delete(owner_id) + end + end + + # a list of lockers that are allocated to the user + def lockers_allocated_to(user_id : String) : Array(PlaceLocker) + now = Time.utc + building = building_id + + locker_banks.values.flat_map do |bank| + bank.locker_hash.values.compact_map do |locker| + if locker.allocated_to == user_id + if time = locker.allocated_until + PlaceLocker.new(bank.id, locker, building) if time > now + else + PlaceLocker.new(bank.id, locker, building) + end + end + end + end + end + + def locker_share( + bank_id : String | Int64, + locker_id : String | Int64, + owner_id : String, + share_with : String + ) : Nil + locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s] + perform_share = false + if locker.allocated_to == owner_id + if time = locker.allocated_until + perform_share = time > Time.utc + else + perform_share = true + end + end + + if perform_share + locker.shared_with << share_with + locker.shared_with.uniq! + end + end + + def locker_unshare( + bank_id : String | Int64, + locker_id : String | Int64, + owner_id : String, + # the individual you previously shared with (optional) + shared_with_id : String? = nil + ) : Nil + locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s] + perform_share = false + if locker.allocated_to == owner_id + if time = locker.allocated_until + perform_share = time > Time.utc + else + perform_share = true + end + end + + if perform_share + if shared_with_id + locker.shared_with.delete shared_with_id + else + locker.shared_with = [] of String + end + end + end + + # a list of user-ids that the locker is shared with. + # this can be placeos user ids or emails + def locker_shared_with( + bank_id : String | Int64, + locker_id : String | Int64, + owner_id : String + ) : Array(String) + locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s] + perform_share = false + if locker.allocated_to == owner_id + if time = locker.allocated_until + perform_share = time > Time.utc + else + perform_share = true + end + end + + if perform_share + locker.shared_with + else + [] of String + end + end + + def locker_unlock( + bank_id : String | Int64, + locker_id : String | Int64, + + # sometimes required by locker systems + owner_id : String? = nil, + # time in seconds the locker should be unlocked + # (can be ignored if not implemented) + open_time : Int32 = 60, + # optional pin code - if user entered from a kiosk + pin_code : String? = nil + ) : Nil + end + + # =================================== + # Locatable Interface functions + # =================================== + def locate_user(email : String? = nil, username : String? = nil) + logger.debug { "sensor incapable of locating #{email} or #{username}" } + [] of Nil + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + logger.debug { "sensor incapable of tracking #{email} or #{username}" } + # we could find the floorsense user, grab the reservations the user has + # and list them here, but probably not amazingly useful + [] of String + end + + USER_EMAILS = { + "user-1" => "user-1@email.com", + "user-2" => "user-2@email.com", + } + + def check_ownership_of(mac_address : String) : OwnershipMAC? + # "lb=#{@bank_id}&lk=#{locker.id}" + return nil unless mac_address.starts_with?("lb=") + floor_mac = URI::Params.parse mac_address + locker_bank = floor_mac["lb"] + locker_key = floor_mac["lk"] + locker = locker_banks[locker_bank].locker_hash[locker_key] + + has_reservation = false + if user_id = locker.allocated_to + if time = locker.allocated_until + has_reservation = time > Time.utc + else + has_reservation = true + end + end + + if has_reservation + { + location: "locker", + assigned_to: USER_EMAILS[locker.allocated_to], + mac_address: mac_address, + } + end + rescue + nil + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "searching lockers in zone #{zone_id}" } + return [] of Nil if location && location != "locker" + + building = building_id + level_zone = zone_id == building ? nil : zone_id + return [] of Nil if level_zone && !level_zone.in?(levels) + + now = Time.utc + locker_banks.values.flat_map do |bank| + next [] of PlaceLocker if level_zone && bank.level_id != level_zone + + bank.locker_hash.values.compact_map do |locker| + if locker.allocated_to + if time = locker.allocated_until + PlaceLocker.new(bank.id, locker, building) if time > now + else + PlaceLocker.new(bank.id, locker, building) + end + end + end + end + end +end + +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + def building_id : String + "zone-building-id" + end end