diff --git a/drivers/floorsense/custom_bookings_sync.cr b/drivers/floorsense/custom_bookings_sync.cr index 48ae64ece5..f7cab9f950 100644 --- a/drivers/floorsense/custom_bookings_sync.cr +++ b/drivers/floorsense/custom_bookings_sync.cr @@ -5,6 +5,7 @@ require "json" require "oauth2" require "./models" require "placeos" +require "../place/desk_source" class Floorsense::CustomBookingsSync < PlaceOS::Driver descriptive_name "Floorsense Desk Bookings Sync" @@ -20,6 +21,8 @@ class Floorsense::CustomBookingsSync < PlaceOS::Driver bind Floorsense_1, :event_50, :booking_released bind Floorsense_1, :event_53, :booking_confirmed + include Place::DeskSource + alias PlaceUser = PlaceOS::Client::API::Models::User DESK_SOURCE_EXTENSION_DATA_MODIFICATION = "floorsense" @@ -46,6 +49,9 @@ class Floorsense::CustomBookingsSync < PlaceOS::Driver "features" => "deskAttributes", }, meta_ext_static: {} of String => String, + + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, }) @user_ids_cache : Hash(String, String) = {} of String => String @@ -280,33 +286,26 @@ class Floorsense::CustomBookingsSync < PlaceOS::Driver desk_lookup = {} of String => DeskMeta begin - metadata = staff_api.metadata( - zone_id, - "desks" - ).get["desks"]["details"].as_a - - lookup_key = @floorsense_lookup_key - metadata.each do |desk| - desk = desk.as_h - place_id = desk["id"]?.try(&.as_s.presence) - next unless place_id - - floor_id = desk[lookup_key]?.try(&.as_s.presence) + level_desks(zone_id).each do |desk| + floor_id = desk.value(@floorsense_lookup_key).try(&.as_s?).presence next unless floor_id # Additional data for adhoc bookings ext_data = { "floorsense_id" => JSON::Any.new(floor_id), } - title = desk["name"]?.try(&.as_s) || place_id @meta_ext_mappings.each do |meta_key, ext_key| - if value = desk[meta_key]? + if value = desk.value(meta_key) ext_data[ext_key] = value end end - ids = DeskMeta.new(place_id, floor_id, building_id, title, ext_data) - desk_lookup[place_id] = ids + @meta_ext_static.each do |key, value| + ext_data[key] = JSON::Any.new(value) + end + + ids = DeskMeta.new(desk.id, floor_id, building_id, desk.name, ext_data) + desk_lookup[desk.id] = ids desk_lookup[floor_id] = ids end desk_lookup @@ -921,48 +920,4 @@ class Floorsense::CustomBookingsSync < PlaceOS::Driver logger.warn(exception: error) { "desk #{desk_id} not found" } raise "Desk not found" end - - def placeos_desk_metadata(zone_id : String, building_id : String?) - desk_lookup = {} of String => DeskMeta - - begin - metadata = staff_api.metadata( - zone_id, - "desks" - ).get["desks"]["details"].as_a - - lookup_key = @floorsense_lookup_key - metadata.each do |desk| - desk = desk.as_h - place_id = desk["id"]?.try(&.as_s.presence) - next unless place_id - - floor_id = desk[lookup_key]?.try(&.as_s.presence) - next unless floor_id - - # Additional data for adhoc bookings - ext_data = { - "floorsense_id" => JSON::Any.new(floor_id), - } - title = desk["name"]?.try(&.as_s) || place_id - @meta_ext_mappings.each do |meta_key, ext_key| - if value = desk[meta_key]? - ext_data[ext_key] = value - end - end - - @meta_ext_static.each do |key, value| - ext_data[key] = JSON::Any.new(value) - end - - ids = DeskMeta.new(place_id, floor_id, building_id, title, ext_data) - desk_lookup[place_id] = ids - desk_lookup[floor_id] = ids - end - desk_lookup - rescue error - logger.warn(exception: error) { "unable to obtain desk metadata for #{zone_id}" } - desk_lookup - end - end end diff --git a/drivers/floorsense/custom_bookings_sync_spec.cr b/drivers/floorsense/custom_bookings_sync_spec.cr index 3aea02dc78..ea165dceea 100644 --- a/drivers/floorsense/custom_bookings_sync_spec.cr +++ b/drivers/floorsense/custom_bookings_sync_spec.cr @@ -1,4 +1,163 @@ require "placeos-driver/spec" +require "./models" + +class StaffAPIMock < DriverSpecs::MockDriver + def metadata(id : String, key : String? = nil) + JSON.parse({ + desks: { + name: "desks", + parent_id: id, + details: [ + {id: "desk-1", name: "Desk 1", floorsense_id: "fs-1", neighbourhoodID: "north", features: ["standing"]}, + {id: "desk-2", name: "Desk 2", floorsense_id: "fs-2"}, + # no floorsense id, not synced + {id: "desk-3", name: "Desk 3"}, + ], + }, + }.to_json) + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + JSON.parse([{id: "type-desk", name: "_DESKS_"}].to_json) + end + + def assets( + type_id : String? = nil, + zone_id : String? = nil, + order_id : String? = nil, + barcode : String? = nil, + serial_number : String? = nil, + bookable : Bool? = nil, + accessible : Bool? = nil, + features : Array(String)? = nil, + zones : Array(String)? = nil, + limit : Int32 = 1000, + next_page : String? = nil, + ) + return JSON.parse("[]") unless type_id == "type-desk" && zone_id == "zone-level" + + if next_page == "page-2" + JSON.parse([ + {id: "asset-2", identifier: "desk-2", map_id: "desk-2", zone_id: zone_id, other_data: {floorsense_id: "fs-2"}}, + {id: "asset-3", identifier: "desk-3", map_id: "desk-3", zone_id: zone_id, other_data: {} of String => String}, + ].to_json) + else + JSON.parse([ + { + id: "asset-1", identifier: "desk-1", map_id: "desk-1", zone_id: zone_id, next_page: "page-2", + features: ["standing"], other_data: {floorsense_id: "fs-1", neighbourhoodID: "north"}, + }, + ].to_json) + end + end + + # background floor syncs + def query_bookings( + 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, + include_checked_out : Bool? = nil, + extension_data : JSON::Any? = nil, + deleted : Bool? = nil, + asset_id : String? = nil, + limit : Int32? = nil, + ) + JSON.parse("[]") + end +end DriverSpecs.mock_driver "Floorsense::CustomBookingsSync" do + system({ + StaffAPI: {StaffAPIMock}, + }) + + # `settings` replaces all settings + base_settings = { + floor_mappings: { + "plan-1": { + building_id: "zone-building", + level_id: "zone-level", + }, + }, + floorsense_lookup_key: "floorsense_id", + meta_ext_mappings: { + "neighbourhoodID" => "neighbourhood", + "features" => "deskAttributes", + }, + meta_ext_static: {"source" => "placeos"}, + use_desk_assets: false, + } + settings(base_settings) + + it "should map desk metadata to floorsense desks" do + lookup = Hash(String, Floorsense::DeskMeta).from_json exec(:placeos_desk_metadata, "zone-level", "zone-building").get.to_json + + # desk-3 has no floorsense id + lookup.keys.sort!.should eq ["desk-1", "desk-2", "fs-1", "fs-2"] + + desk = lookup["desk-1"] + lookup["fs-1"].place_id.should eq "desk-1" + desk.place_id.should eq "desk-1" + desk.floor_id.should eq "fs-1" + desk.building.should eq "zone-building" + desk.title.should eq "Desk 1" + desk.ext_data.should eq({ + "floorsense_id" => JSON::Any.new("fs-1"), + "neighbourhood" => JSON::Any.new("north"), + "deskAttributes" => JSON.parse(%(["standing"])), + "source" => JSON::Any.new("placeos"), + }) + + # desks without features or a neighbourhood don't map them + lookup["desk-2"].ext_data.should eq({ + "floorsense_id" => JSON::Any.new("fs-2"), + "source" => JSON::Any.new("placeos"), + }) + end + + it "should map desk assets to floorsense desks" do + settings(base_settings.merge({use_desk_assets: true})) + + lookup = Hash(String, Floorsense::DeskMeta).from_json exec(:placeos_desk_metadata, "zone-level", "zone-building").get.to_json + + # assets across both pages, asset-3 has no floorsense id + lookup.keys.sort!.should eq ["asset-1", "asset-2", "fs-1", "fs-2"] + + desk = lookup["fs-1"] + desk.place_id.should eq "asset-1" + desk.floor_id.should eq "fs-1" + desk.building.should eq "zone-building" + desk.title.should eq "desk-1" + desk.ext_data.should eq({ + "floorsense_id" => JSON::Any.new("fs-1"), + "neighbourhood" => JSON::Any.new("north"), + "deskAttributes" => JSON.parse(%(["standing"])), + "source" => JSON::Any.new("placeos"), + }) + lookup["fs-2"].place_id.should eq "asset-2" + lookup["fs-2"].ext_data.should eq({ + "floorsense_id" => JSON::Any.new("fs-2"), + "source" => JSON::Any.new("placeos"), + }) + end + + it "should match the desk lookup key against desk fields" do + settings(base_settings.merge({use_desk_assets: true, floorsense_lookup_key: "map_id"})) + + lookup = Hash(String, Floorsense::DeskMeta).from_json exec(:placeos_desk_metadata, "zone-level", "zone-building").get.to_json + lookup.keys.sort!.should eq ["asset-1", "asset-2", "asset-3", "desk-1", "desk-2", "desk-3"] + lookup["desk-3"].place_id.should eq "asset-3" + lookup["desk-3"].ext_data["floorsense_id"].should eq JSON::Any.new("desk-3") + end end diff --git a/drivers/floorsense/mobile_checkin_logic.cr b/drivers/floorsense/mobile_checkin_logic.cr index 74af76364b..85c8177e46 100644 --- a/drivers/floorsense/mobile_checkin_logic.cr +++ b/drivers/floorsense/mobile_checkin_logic.cr @@ -1,4 +1,5 @@ require "placeos-driver" +require "../place/desk_source" class Floorsense::MobileCheckinLogic < PlaceOS::Driver descriptive_name "Floorsense Mobile Checkin Logic" @@ -8,6 +9,8 @@ class Floorsense::MobileCheckinLogic < PlaceOS::Driver accessor booking_sync : FloorsenseBookingSync_1 accessor staff_api : StaffAPI_1 + include Place::DeskSource + default_settings({ time_zone: "Australia/Sydney", booking_period: 120, @@ -15,6 +18,9 @@ class Floorsense::MobileCheckinLogic < PlaceOS::Driver "neighbourhoodID" => "neighbourhood", "features" => "deskAttributes", }, + + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, }) @time_zone : Time::Location = Time::Location.load("Australia/Sydney") @@ -89,16 +95,13 @@ class Floorsense::MobileCheckinLogic < PlaceOS::Driver ext_data = {} of String => JSON::Any begin logger.debug { "obtaining metadata for desk #{place_desk} on level #{level_zone}" } - if desk_details = placeos_desk_metadata(level_zone, place_desk) - # check if the desk is bookable - if bookable = desk_details["bookable"]? - return "forbidden" if (bookable.as_s?.try(&.upcase) == "FALSE") || (bookable.as_bool? == false) - end + if desk = placeos_desk_metadata(level_zone, place_desk) + return "forbidden" unless desk.bookable? - title = desk_details["name"]?.try(&.as_s) || place_desk + title = desk.name @meta_ext_mappings.each do |meta_key, ext_key| - if value = desk_details[meta_key]? + if value = desk.value(meta_key) ext_data[ext_key] = value end end @@ -130,17 +133,7 @@ class Floorsense::MobileCheckinLogic < PlaceOS::Driver end end - def placeos_desk_metadata(zone_id : String, asset_id : String) - metadata = staff_api.metadata( - zone_id, - "desks" - ).get["desks"]["details"].as_a - - metadata.each do |desk| - place_id = desk["id"]?.try(&.as_s) - next unless place_id - return desk.as_h if place_id == asset_id - end - nil + def placeos_desk_metadata(zone_id : String, asset_id : String) : Desk? + level_desks(zone_id).find { |desk| desk.id == asset_id } end end diff --git a/drivers/floorsense/mobile_checkin_logic_spec.cr b/drivers/floorsense/mobile_checkin_logic_spec.cr index 25de155244..44fcba9540 100644 --- a/drivers/floorsense/mobile_checkin_logic_spec.cr +++ b/drivers/floorsense/mobile_checkin_logic_spec.cr @@ -6,6 +6,20 @@ DriverSpecs.mock_driver "Floorsense::MobileCheckinLogic" do StaffAPI: {StaffAPIMock}, }) + # `settings` replaces all settings + base_settings = { + time_zone: "Australia/Sydney", + booking_period: 120, + meta_ext_mappings: { + "neighbourhoodID" => "neighbourhood", + "features" => "deskAttributes", + }, + use_desk_assets: false, + } + + # desk metadata + settings(base_settings) + resp = exec(:eui64_scanned, "euid-rand-chars", "test-user").get resp.should eq("checked-in") @@ -14,12 +28,39 @@ DriverSpecs.mock_driver "Floorsense::MobileCheckinLogic" do resp = exec(:eui64_scanned, "euid-rand-chars", "test-user").get resp.should eq("forbidden") + + # desk assets, bookings reference the Asset#id + settings(base_settings.merge({use_desk_assets: true})) + + resp = exec(:eui64_scanned, "euid-asset", "test-user").get + resp.should eq("checked-in") + + # desk found on the second page of assets + resp = exec(:eui64_scanned, "euid-asset", "test-user").get + resp.should eq("adhoc") + system(:StaffAPI_1)[:created].should eq({ + "asset_id" => "asset-1", + "title" => "Asset Desk", + "extension_data" => {"deskAttributes" => ["standing"], "neighbourhood" => "north"}, + }) + + resp = exec(:eui64_scanned, "euid-asset", "test-user").get + resp.should eq("forbidden") + + # desk asset is not bookable + resp = exec(:eui64_scanned, "euid-locked", "test-user").get + resp.should eq("forbidden") end # :nodoc: class FloorsenseBookingSyncMock < DriverSpecs::MockDriver def eui64_to_desk_id(id : String) - {level: "level_zone", desk_id: "place_desk", building_id: "building_zone"} + desk_id = case id + when "euid-asset" then "asset-1" + when "euid-locked" then "asset-2" + else "place_desk" + end + {level: "level_zone", desk_id: desk_id, building_id: "building_zone"} end end @@ -32,11 +73,19 @@ class StaffAPIMock < DriverSpecs::MockDriver case @query_count when 1 [{id: 1234, asset_id: "place_desk", user_id: "test-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}] - when 2 - [] of Nil when 3 # desk owned by a different user [{id: 1234, asset_id: "place_desk", user_id: "other-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}] + when 4 + [{id: 1234, asset_id: "asset-1", user_id: "test-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}] + when 6 + # the legacy desk id no longer matches + [ + {id: 1234, asset_id: "place_desk", user_id: "test-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}, + {id: 1234, asset_id: "asset-1", user_id: "other-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}, + ] + else + [] of Nil end end @@ -53,6 +102,39 @@ class StaffAPIMock < DriverSpecs::MockDriver } end + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets( + type_id : String? = nil, + zone_id : String? = nil, + order_id : String? = nil, + barcode : String? = nil, + serial_number : String? = nil, + bookable : Bool? = nil, + accessible : Bool? = nil, + features : Array(String)? = nil, + zones : Array(String)? = nil, + limit : Int32 = 1000, + next_page : String? = nil, + ) + return [] of Nil unless type_id == "type-desk" && zone_id == "level_zone" + + if next_page == "page-2" + JSON.parse([ + { + id: "asset-1", identifier: "Asset Desk", zone_id: zone_id, + features: ["standing"], other_data: {neighbourhoodID: "north"}, + }, + ].to_json) + else + JSON.parse([ + {id: "asset-2", identifier: "Locked Desk", zone_id: zone_id, bookable: false, next_page: "page-2"}, + ].to_json) + end + end + def user(user_id : String) raise "unexpected user #{user_id}" unless user_id == "test-user" { @@ -81,8 +163,11 @@ class StaffAPIMock < DriverSpecs::MockDriver time_zone : String, extension_data : Hash(String, JSON::Any), ) - raise "bad data" unless user_id == "test-user" && asset_id == "place_desk" - raise "bad data2" unless checked_in && title == "Cool Desk" && extension_data["deskAttributes"].as_a.map(&.as_s) == ["standing"] + raise "bad data" unless user_id == "test-user" && asset_id.in?("place_desk", "asset-1") + if asset_id == "place_desk" + raise "bad data2" unless checked_in && title == "Cool Desk" && extension_data["deskAttributes"].as_a.map(&.as_s) == ["standing"] + end + self[:created] = {asset_id: asset_id, title: title, extension_data: extension_data} true end end diff --git a/drivers/gobright/location_service.cr b/drivers/gobright/location_service.cr index 9ddb3fdaa4..b1f302df56 100644 --- a/drivers/gobright/location_service.cr +++ b/drivers/gobright/location_service.cr @@ -1,9 +1,11 @@ require "placeos-driver" require "placeos-driver/interface/locatable" require "./models" +require "../place/desk_source" class GoBright::LocationService < PlaceOS::Driver include Interface::Locatable + include Place::DeskSource descriptive_name "GoBright Location Service" generic_name :GoBrightLocationService @@ -23,6 +25,9 @@ class GoBright::LocationService < PlaceOS::Driver return_empty_spaces: true, desk_space_types: ["desk"], space_cache_cron: "0 5 * * *", + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) # place_zone_id => gobright_location_id @@ -158,21 +163,17 @@ class GoBright::LocationService < PlaceOS::Driver end # retrieve desk details and perform gobright_desk_name to departments (groups) mapping - desks_meta = staff_api.metadata(zone_id, "desks").get.as_h["desks"].as_h - desk_groups_mapping = desks_meta["details"].as_a.map do |desk| - desk_h = desk.as_h - gobright_desk_name = desk_h["gobright_desk_name"].as_s - departments = begin - if (arr = desk_h["departments"].as_a?) - arr.empty? ? ["none"] : arr.map(&.as_s) - elsif desk_h.has_key?("departments") && desk_h["departments"].raw.is_a?(String) - [desk_h["departments"].as_s] - else - ["none"] - end - end - {gobright_desk_name, departments} - end.to_h + desk_groups_mapping = level_desks(zone_id).each_with_object({} of String => Array(String)) do |desk, mapping| + next unless gobright_desk_name = desk["gobright_desk_name"]?.try(&.as_s?).presence + + departments = desk["departments"]? + groups = if list = departments.try(&.as_a?) + list.compact_map(&.as_s?) + elsif department = departments.try(&.as_s?) + [department] + end + mapping[gobright_desk_name] = groups.nil? || groups.empty? ? ["none"] : groups + end # mark if the space is occupied occupancy = Array(Occupancy).from_json(gobright.live_occupancy(gobright_location_id, @default_space_type).get_json) diff --git a/drivers/gobright/location_service_spec.cr b/drivers/gobright/location_service_spec.cr index 9f64c0d55c..b8bc98b6e6 100644 --- a/drivers/gobright/location_service_spec.cr +++ b/drivers/gobright/location_service_spec.cr @@ -8,6 +8,22 @@ DriverSpecs.mock_driver "GoBright::LocationService" do AreaManagement: {AreaManagementMock}, }) + base_settings = { + gobright_floor_mappings: { + "placeos_zone_id": { + location_id: "level", + name: "friendly name for documentation", + }, + }, + return_empty_spaces: true, + desk_space_types: ["desk"], + space_cache_cron: "0 5 * * *", + } + staff_api = system(:StaffAPI).as(StaffAPIMock) + + # =================================================== + # desk metadata + # =================================================== exec(:device_locations, "placeos_zone_id").get.should eq([ { "location" => "desk", @@ -35,6 +51,16 @@ DriverSpecs.mock_driver "GoBright::LocationService" do "gobright_space_id" => "space-4567", }, ]) + + # =================================================== + # desk assets + # =================================================== + settings(base_settings.merge({use_desk_assets: true})) + + # the gobright desk name and departments are read from the asset other_data + locations = exec(:device_locations, "placeos_zone_id").get.not_nil!.as_a + locations.map { |loc| {loc["map_id"], loc["group"]} }.should eq [{"desk-1", "Finance"}, {"room-1", "none"}] + staff_api.asset_requests.should eq [nil, "page-2"] end # :nodoc: @@ -76,6 +102,8 @@ end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver + getter asset_requests : Array(String?) = [] of String? + def zones(tags : String) logger.info { "zones requested from staff api" } raise "unexpected tags, expected building, got: #{tags}" unless tags == "building" @@ -100,10 +128,38 @@ class StaffAPIMock < DriverSpecs::MockDriver "bookable": true, "gobright_desk_name": "desk-1", }, + # desks without a gobright desk name are ignored + { + "id": "table-07.002.status", + "name": "Desk 07.002", + "map_id": "table-07.002.status", + }, ], }, } end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset request #{type_id} #{zone_id}" unless type_id == "type-desk" && zone_id == "placeos_zone_id" + @asset_requests << next_page + + if next_page + [{id: "asset-2", identifier: "Desk 07.002", map_id: "table-07.002.status", zone_id: zone_id, other_data: {} of String => String}] + else + [{ + id: "asset-1", + identifier: "Desk 07.001", + map_id: "table-07.001.status", + zone_id: zone_id, + other_data: {gobright_desk_name: "desk-1", departments: ["Finance"]}, + next_page: "page-2", + }] + end + end end # :nodoc: diff --git a/drivers/orbility/approvals.cr b/drivers/orbility/approvals.cr index 7263a3b2b7..1278821ffd 100644 --- a/drivers/orbility/approvals.cr +++ b/drivers/orbility/approvals.cr @@ -3,6 +3,7 @@ require "placeos-driver/interface/mailer" require "placeos-driver/interface/mailer_templates" require "place_calendar" require "../place/booking_model" +require "../place/desk_source" require "./parking_rest_api_models" require "goban" @@ -14,6 +15,7 @@ require "goban/exporters/png" class Place::Parking::Approvals < PlaceOS::Driver include PlaceOS::Driver::Interface::MailerTemplates + include Place::DeskSource descriptive_name "PlaceOS Parking Approvals" generic_name :ParkingApprovals @@ -50,6 +52,9 @@ class Place::Parking::Approvals < PlaceOS::Driver date_time_format: "%c", time_format: "%l:%M%p", date_format: "%A, %-d %B", + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) @timezone : Time::Location = Time::Location::UTC @@ -93,6 +98,7 @@ class Place::Parking::Approvals < PlaceOS::Driver @date_format = setting?(String, :date_format) || "%A, %-d %B" @approval_period = setting?(Int32, :cache_days) || 30 + @assigned_desks = nil schedule.clear schedule.every(@poll_rate) { process_parking_bookings } @@ -140,33 +146,12 @@ class Place::Parking::Approvals < PlaceOS::Driver # =================================== # Assigned desks (used to check if user works in the building) - # TODO:: migrate to asset version # =================================== - getter building_level_ids : Array(String) do - staff_api.zones(parent: building_id, tags: {"level"}).get.as_a.map do |zone| - zone["id"].as_s - end - end - # return users who have assigned desks getter assigned_desks : Array(String) do logger.debug { "getting list of assigned desks in this building" } - assigned = [] of String - building_level_ids.each do |level| - logger.debug { " - processing level #{level}" } - - all_desks = staff_api.metadata(level, "desks").get.dig?("desks", "details") - if all_desks - desks = all_desks.as_a - desks.each do |desk| - if assignment = desk["assigned_to"]?.try(&.as_s?.presence.try(&.downcase)) - assigned << assignment - end - end - end - end - + assigned = building_desks(building_id).values.flatten.compact_map(&.assigned_to.try(&.downcase)) logger.debug { " - found #{assigned.size} assignments" } assigned end @@ -738,7 +723,7 @@ class Place::Parking::Approvals < PlaceOS::Driver self[:daily_entries] = @crossings.daily_entries self[:daily_exits] = @crossings.daily_exits - # adjust the count if we see more + # adjust the count if we see more count = (@crossings.weekly_entries + @crossings.adjustment) - @crossings.weekly_exits if count < 0 @crossings.adjustment -= count diff --git a/drivers/orbility/approvals_spec.cr b/drivers/orbility/approvals_spec.cr index e3f1acd3c8..cbc334e652 100644 --- a/drivers/orbility/approvals_spec.cr +++ b/drivers/orbility/approvals_spec.cr @@ -1,4 +1,213 @@ require "placeos-driver/spec" +require "placeos-driver/interface/mailer" DriverSpecs.mock_driver "Place::Parking::Approvals" do + system({ + StaffAPI: {StaffAPIMock}, + LocationServices: {LocationServicesMock}, + Mailer: {MailerMock}, + }) + + base_settings = { + poll_rate: 60, + car_license_ext: "car_license", + auto_approval_groups: [] of String, + analytics_cron: "0 0 1 1 *", + time_zone: "Australia/Sydney", + } + settings(base_settings) + staff_api = system(:StaffAPI).as(StaffAPIMock) + mailer = system(:Mailer).as(MailerMock) + + # =================================================== + # desk metadata + # =================================================== + # alice has an assigned desk so works in the building and requires approval, + # carl is visiting so is auto approved (and wait listed as there are no spaces) + exec(:process_parking_bookings).get.should eq "parking allocated" + exec(:assigned_desks).get.should eq ["alice@org.com"] + staff_api.metadata_requests.should eq ["zone-building"] + staff_api.states.should eq [{1_i64, "waiting_approval"}, {2_i64, "wait_list"}] + mailer.sent.should eq [ + {"alice@org.com", "approval_required"}, + {"carl@org.com", "wait_list"}, + ] + + # =================================================== + # desk assets + # =================================================== + staff_api.reset + mailer.reset + settings(base_settings.merge({use_desk_assets: true})) + + # carl is now assigned a desk asset and alice is not + exec(:assigned_desks).get.should eq ["carl@org.com"] + staff_api.asset_requests.should eq [nil, "page-2"] + + exec(:process_parking_bookings).get.should eq "parking allocated" + staff_api.metadata_requests.should be_empty + staff_api.states.should eq [{1_i64, "wait_list"}, {2_i64, "waiting_approval"}] + mailer.sent.should eq [ + {"alice@org.com", "wait_list"}, + {"carl@org.com", "approval_required"}, + ] +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + getter metadata_requests : Array(String) = [] of String + getter asset_requests : Array(String?) = [] of String? + getter states : Array(Tuple(Int64, String)) = [] of Tuple(Int64, String) + + def reset + @metadata_requests.clear + @asset_requests.clear + @states.clear + end + + def zone(zone_id : String) + {id: zone_id, name: "Building", tags: ["building"]} + end + + def metadata_children(id : String, key : String? = nil) + @metadata_requests << id + [ + {zone: {id: "zone-level-1"}, metadata: {desks: {name: "desks", details: [ + {id: "desk-1", name: "Desk 1", assigned_to: "Alice@org.com"}, + {id: "desk-2", name: "Desk 2", assigned_to: ""}, + ]}}}, + {zone: {id: "zone-level-2"}, metadata: {desks: {name: "desks", details: [ + {id: "desk-3", name: "Desk 3"}, + ]}}}, + ] + end + + def asset_categories(hidden : Bool? = nil) + [{id: "category-parking", name: "_PARKING_", hidden: true}] + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [ + {id: "type-desk", name: "_DESKS_"}, + {id: "type-parking", name: "_PARKING_SPACES_", category_id: "category-parking"}, + ] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + # no parking spaces are available + return [] of String unless type_id == "type-desk" + raise "unexpected zones #{zones}" unless zones == ["zone-building"] + @asset_requests << next_page + + if next_page + [{id: "asset-3", identifier: "desk-3", zone_id: "zone-level-2", assigned_to: "Carl@org.com"}] + else + [ + {id: "asset-1", identifier: "desk-1", zone_id: "zone-level-1", next_page: "page-2"}, + {id: "asset-2", identifier: "desk-2", zone_id: "zone-level-1", assigned_to: ""}, + ] + end + end + + def query_bookings( + 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, + include_checked_out : Bool? = nil, + extension_data : JSON::Any? = nil, + deleted : Bool? = nil, + asset_id : String? = nil, + limit : Int32? = nil, + ) + # only active booking requests + return [] of String unless approved.nil? && rejected.nil? && deleted.nil? + + now = Time.utc.to_unix + {"alice", "carl"}.map_with_index(1) do |name, id| + { + id: id, + booking_type: "parking", + booking_start: now + 1.hour.total_seconds.to_i64, + booking_end: now + 2.hours.total_seconds.to_i64, + asset_id: "unallocated-#{id}", + asset_ids: ["unallocated-#{id}"], + user_id: name, + user_email: "#{name}@org.com", + user_name: name, + booked_by_name: name, + booked_by_email: "#{name}@org.com", + zones: ["zone-building"], + created: now + id, + } + end + end + + def booking_state(booking_id : String | Int64, state : String, instance : Int64? = nil) + @states << {booking_id.to_i64, state} + true + end +end + +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + def building_id : String + "zone-building" + end + + # everyone has a meeting in the building + def bookings_for(email : String) + [{id: "event-1", host: email}] + end +end + +# :nodoc: +class MailerMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + + getter sent : Array(Tuple(String, String)) = [] of Tuple(String, String) + + def reset + @sent.clear + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) + @sent << {to.to_s, template[1]} + true + end + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : String | Array(String) | Nil = nil, + reply_to : String | Array(String) | Nil = nil, + ) : Bool + true + end end diff --git a/drivers/place/area_management.cr b/drivers/place/area_management.cr index 38d8a80f59..b741a401ec 100644 --- a/drivers/place/area_management.cr +++ b/drivers/place/area_management.cr @@ -3,11 +3,14 @@ require "placeos" require "placeos-driver" require "./area_config" require "./area_polygon" +require "./desk_source" require "placeos-driver/interface/sensor" ::PlaceOS::Driver::Interface::Sensor.include_unit_conversions class Place::AreaManagement < PlaceOS::Driver + include Place::DeskSource + descriptive_name "PlaceOS Area Management" generic_name :AreaManagement description %(counts trackable objects, such as laptops, in building areas) @@ -44,6 +47,9 @@ class Place::AreaManagement < PlaceOS::Driver # desk metadata and then specify the alternative field names here: # desk_id_mappings: ["floorsensedeskid", "vergesensedeskid"] + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, + units: { "Temperature" => "Cel", }, @@ -286,46 +292,34 @@ class Place::AreaManagement < PlaceOS::Driver # =============================== # Updates a single zone, syncing the metadata - protected def update_level_details(level_details, zone, metadata) + protected def update_level_details(level_details, zone, metadata, desks : Array(Desk)) return unless zone.tags.includes?("level") - desks_meta = metadata["desks"]? - if desks_meta && (desk_array = desks_meta.details.as_a?) + if desks.empty? + level_details[zone.id] = { + total_desks: zone.count, + total_capacity: zone.capacity, + desk_ids: [] of String, + desk_mappings: {} of String => String, + } + else + # locations reference desks by their map id + ids = desks.map(&.map_id) desk_map = {} of String => String - - if @desk_id_mappings.empty? - ids = desk_array.map { |desk| desk["id"].as_s } - else - ids = Array(String).new(desk_array.size) - - desk_array.each do |desk| - desk_id = desk["id"].as_s - ids << desk_id - @desk_id_mappings.each do |mapping| - if alt_id = desk[mapping]? - desk_map[alt_id.as_s] = desk_id - end + desks.each do |desk| + @desk_id_mappings.each do |mapping| + if alt_id = desk.value(mapping).try(&.as_s?) + desk_map[alt_id] = desk.map_id end end end - ids = desk_array.map { |desk| desk["id"].as_s } level_details[zone.id] = { total_desks: ids.size, total_capacity: zone.capacity, desk_ids: ids, desk_mappings: desk_map, } - else - if desks_meta - logger.warn { "desks metadata for zone #{zone.id} has unexpected format: expected Array, got #{desks_meta.details.raw.class}" } - end - level_details[zone.id] = { - total_desks: zone.count, - total_capacity: zone.capacity, - desk_ids: [] of String, - desk_mappings: {} of String => String, - } end if regions = metadata["map_regions"]? @@ -365,9 +359,11 @@ class Place::AreaManagement < PlaceOS::Driver buildings.each do |b_id| # Attempt to obtain the latest version of the metadata response = ChildMetadata.from_json(staff_api.metadata_children(b_id).get_json) + desks = building_desks(b_id) response.each do |meta| - level_buildings[meta[:zone].id] = b_id - update_level_details(level_details, meta[:zone], meta[:metadata]) + zone = meta[:zone] + level_buildings[zone.id] = b_id + update_level_details(level_details, zone, meta[:metadata], desks[zone.id]? || [] of Desk) end end @@ -602,7 +598,7 @@ class Place::AreaManagement < PlaceOS::Driver end metadata = Metadata.from_json(staff_api.metadata(level_id).get_json) - update_level_details @level_details, zone, metadata + update_level_details @level_details, zone, metadata, level_desks(level_id) update_level_locations @level_counts, level_id, @level_details[level_id], sensor_data update_overview if overview end diff --git a/drivers/place/area_management_spec.cr b/drivers/place/area_management_spec.cr index ca9f4f08f7..485efbaa85 100644 --- a/drivers/place/area_management_spec.cr +++ b/drivers/place/area_management_spec.cr @@ -1,8 +1,17 @@ require "placeos-driver/spec" -DriverSpecs.mock_driver "Place::AreaCount" do - settings({ - areas: { +DriverSpecs.mock_driver "Place::AreaManagement" do + system({ + StaffAPI: {StaffAPIMock}, + LocationServices: {LocationServicesMock}, + }) + + base_settings = { + building_zone_override: "zone-building", + desk_id_mappings: ["floorsensedeskid"], + include_sensors: false, + poll_rate: 600, + areas: { "zone-1234" => [ { id: "lobby1", @@ -12,7 +21,8 @@ DriverSpecs.mock_driver "Place::AreaCount" do }, ], }, - }) + } + settings(base_settings) # Used this tool to work out coordinates: https://www.mathsisfun.com/geometry/polygons-interactive.html exec(:is_inside?, 4, 5, "lobby1").get.should eq(true) @@ -28,4 +38,137 @@ DriverSpecs.mock_driver "Place::AreaCount" do exec(:is_inside?, 5.2, 5.4, "lobby1").get.should eq(false) exec(:is_inside?, 5.5, 1.5, "lobby1").get.should eq(false) exec(:is_inside?, 5.9, 2, "lobby1").get.should eq(false) + + staff_api = system(:StaffAPI).as(StaffAPIMock) + + # =================================================== + # desk metadata + # =================================================== + # the scheduler syncs the building on load (rate limited to every 3 seconds) + sleep 4.seconds + staff_api.children_requests.should contain({"zone-building", nil}) + staff_api.children_requests.should contain({"zone-building", "desks"}) + staff_api.asset_requests.should be_empty + + exec(:request_level_locations, "zone-level-1").get + status["zone-level-1:desk_ids"].should eq ["desk-1", "desk-2"] + # the floorsense desk id is mapped to the metadata desk + locations = status["zone-level-1"]["value"].as_a + locations.map(&.["map_id"]).should eq ["desk-1", "desk-2", "desk-1"] + status["zone-level-1:desk_bookings"]["desk-1"].as_a.map(&.["booking_id"]).should eq [1] + status[:overview]["zone-level-1"]["desk_count"].should eq 2 + status[:overview]["zone-level-1"]["desk_usage"].should eq 1 + + # =================================================== + # desk assets + # =================================================== + staff_api.reset + settings(base_settings.merge({use_desk_assets: true})) + exec(:synchronize_all_levels).get + sleep 4.seconds + + # building desks come from both pages of assets, other metadata is still used + staff_api.children_requests.should eq [{"zone-building", nil}] + staff_api.asset_requests.should contain({["zone-building"], nil, nil}) + staff_api.asset_requests.should contain({["zone-building"], nil, "page-2"}) + staff_api.asset_requests.should contain({nil, "zone-level-1", nil}) + + exec(:request_level_locations, "zone-level-1").get + # assets are placed on the map using their map_id + status["zone-level-1:desk_ids"].should eq ["table-1", "table-2"] + locations = status["zone-level-1"]["value"].as_a + locations.map(&.["map_id"]).should eq ["table-1", "table-2", "table-1"] + # bookings without a map_id are keyed by the Asset#id + status["zone-level-1:desk_bookings"]["asset-2"].as_a.map(&.["booking_id"]).should eq [2] + status[:overview]["zone-level-1"]["desk_count"].should eq 2 +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + getter children_requests = [] of Tuple(String, String?) + getter asset_requests = [] of Tuple(Array(String)?, String?, String?) + + def reset + @children_requests.clear + @asset_requests.clear + end + + LEVEL = { + id: "zone-level-1", + name: "Level 1", + count: 0, + capacity: 20, + tags: ["level"], + triggers: [] of String, + parent_id: "zone-building", + created_at: 1, + updated_at: 1, + } + + DESKS = [ + {id: "desk-1", name: "Desk 1", floorsensedeskid: "fs-1"}, + {id: "desk-2", name: "Desk 2", floorsensedeskid: "fs-2"}, + ] + + def zone(zone_id : String) + LEVEL.merge({id: zone_id}) + end + + def zones(q : String? = nil, limit : Int32 = 1000, offset : Int32 = 0, parent : String? = nil, tags : Array(String) | String? = nil) + [] of Nil + end + + protected def level_metadata(key : String?) + meta = {} of String => JSON::Any + if key.nil? || key == "desks" + meta["desks"] = JSON.parse({name: "desks", description: "", parent_id: "zone-level-1", details: DESKS}.to_json) + end + meta + end + + def metadata(id : String, key : String? = nil) + level_metadata(key) + end + + def metadata_children(id : String, key : String? = nil) + @children_requests << {id, key} + [{zone: LEVEL, metadata: level_metadata(key)}] + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + @asset_requests << {zones, zone_id, next_page} + + if next_page + [{id: "asset-2", identifier: "desk-2", map_id: "table-2", zone_id: "zone-level-1", other_data: {floorsensedeskid: "fs-2"}}] + else + [{id: "asset-1", identifier: "desk-1", map_id: "table-1", zone_id: "zone-level-1", other_data: {floorsensedeskid: "fs-1"}, next_page: "page-2"}] + end + end +end + +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + def device_locations(zone_id : String, location : String? = nil) + [ + {location: "desk", map_id: "fs-1", at_location: 1, level: zone_id}, + {location: "desk", map_id: "fs-2", at_location: 0, level: zone_id}, + {location: "booking", type: "desk", map_id: "fs-1", checked_in: true, level: zone_id}, + ] + end + + def upcoming_bookings(zone_id : String) + [ + {location: "booking", type: "desk", map_id: "desk-1", booking_id: 1, started_at: 10}, + {location: "booking", type: "desk", asset_id: "asset-2", booking_id: 2, started_at: 20}, + ] + end + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) + [] of Nil + end end diff --git a/drivers/place/at_capacity_mailer.cr b/drivers/place/at_capacity_mailer.cr index 666c6bb90d..0fb5a49c1e 100644 --- a/drivers/place/at_capacity_mailer.cr +++ b/drivers/place/at_capacity_mailer.cr @@ -23,6 +23,9 @@ class Place::AtCapacityMailer < PlaceOS::Driver email_template: "at_capacity", unique_templates: false, # this appends the booking type to the template name asset_cache_timeout: 3600_i64, # 1 hour + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) accessor staff_api : StaffAPI_1 diff --git a/drivers/place/at_capacity_mailer_spec.cr b/drivers/place/at_capacity_mailer_spec.cr index 6600325d18..801dd838ed 100644 --- a/drivers/place/at_capacity_mailer_spec.cr +++ b/drivers/place/at_capacity_mailer_spec.cr @@ -6,6 +6,7 @@ class StaffAPI < DriverSpecs::MockDriver getter failing_type : String? = nil getter asset_calls = 0 getter type_calls = 0 + getter metadata_calls = 0 def configure(metadata_shape : String, failing_type : String? = nil) @metadata_shape = metadata_shape @@ -32,7 +33,7 @@ class StaffAPI < DriverSpecs::MockDriver ].select { |type| category_id.nil? || type[:category_id] == category_id }.to_json) end - def assets(type_id : String? = nil, zone_id : String? = nil) + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) @asset_calls += 1 raise "assets unavailable" if type_id == failing_type ids = case type_id @@ -95,6 +96,7 @@ class StaffAPI < DriverSpecs::MockDriver end def metadata(id : String, key : String? = nil) + @metadata_calls += 1 zone = ZONES.find! { |z| z["id"] == id } key = key.not_nil! @@ -365,6 +367,18 @@ DriverSpecs.mock_driver "Place::AtCapacityMailer" do api.type_calls.should eq type_calls + 2 end + it "uses desk assets instead of legacy metadata when use_desk_assets is set" do + api.configure("legacy") + settings({booking_type: "desk", zones: ["level-1"], use_desk_assets: true}) + metadata_calls = api.metadata_calls + exec(:get_asset_ids).get.should eq({"level-1" => ["asset-desk-1", "asset-desk-2"]}) + api.metadata_calls.should eq metadata_calls + + # parking is unaffected by the desk setting + settings({booking_type: "parking", zones: ["level-1"], use_desk_assets: true}) + exec(:get_asset_ids).get.should eq({"level-1" => ["park-1", "park-2"]}) + end + it "retains available assets when one duplicate type cannot be queried" do api.configure("migrated", "type-parking-duplicate") settings({booking_type: "parking", zones: ["level-1"]}) diff --git a/drivers/place/auto_release.cr b/drivers/place/auto_release.cr index 94fe421f5c..217ca529a4 100644 --- a/drivers/place/auto_release.cr +++ b/drivers/place/auto_release.cr @@ -30,6 +30,9 @@ class Place::AutoRelease < PlaceOS::Driver skip_same_day: false, # Skip bookings created on the same day as the booking skip_all_day: false, # Skip all day bookings asset_cache_timeout: 3600_i64, # 1 hour + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) accessor staff_api : StaffAPI_1 diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr index d5c0f0a26a..e1ed386af6 100644 --- a/drivers/place/auto_release_spec.cr +++ b/drivers/place/auto_release_spec.cr @@ -7,6 +7,7 @@ class StaffAPI < DriverSpecs::MockDriver getter asset_name : String? = "Fallback name" getter assets_fail : Bool = false getter asset_calls = 0 + getter metadata_calls = 0 def configure(metadata_shape : String, identifier : String? = "Desk from Asset", name : String? = "Fallback name", assets_fail : Bool = false) @metadata_shape = metadata_shape @@ -16,6 +17,7 @@ class StaffAPI < DriverSpecs::MockDriver end def metadata(id : String, key : String? = nil) + @metadata_calls += 1 details = if metadata_shape == "migrated" JSON.parse(%({"migrated":true,"migrated_at":1765438509624})) else @@ -32,7 +34,7 @@ class StaffAPI < DriverSpecs::MockDriver JSON.parse([{id: "type-desk", name: "_DESKS_", category_id: "category-desk"}].to_json) end - def assets(type_id : String? = nil, zone_id : String? = nil) + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) @asset_calls += 1 raise "assets unavailable" if assets_fail return JSON.parse("[]") unless type_id == "type-desk" && zone_id == "zone-1234" @@ -1377,6 +1379,25 @@ DriverSpecs.mock_driver "Place::AutoRelease" do end end + [ + {"asset-desk", "Asset identifier"}, + {"legacy-desk", "Asset name for legacy desk"}, + {"unknown-desk", "unknown-desk"}, + ].each do |asset_id, expected| + it "resolves #{asset_id} from desk assets when use_desk_assets is set" do + # the legacy metadata would resolve legacy-desk as "Legacy desk" + api.configure("legacy", "Asset identifier", "Asset name") + settings({auto_release: {time_before: 10, time_after: 10, resources: ["desk"]}, use_desk_assets: true}) + status[:pending_release] = [StaffAPI::BOOKINGS[1].merge({asset_id: asset_id})] + status[:released_booking_ids] = [] of Int64 + status[:emailed_booking_ids] = [] of Int64 + metadata_calls = api.metadata_calls + exec(:send_release_emails).get.should eq [2] + system(:Mailer_1)[:last_args]["asset_name"].should eq expected + api.metadata_calls.should eq metadata_calls + end + end + it "still sends the email with the raw id when the Asset request fails" do api.configure("migrated", assets_fail: true) settings({auto_release: {time_before: 10, time_after: 10, resources: ["desk"]}}) diff --git a/drivers/place/booking_approval_workflows.cr b/drivers/place/booking_approval_workflows.cr index 49cacc8309..dfdc903261 100644 --- a/drivers/place/booking_approval_workflows.cr +++ b/drivers/place/booking_approval_workflows.cr @@ -6,9 +6,11 @@ require "placeos" require "file" require "./booking_model" +require "./desk_source" class Place::BookingApprovalWorkflows < PlaceOS::Driver include PlaceOS::Driver::Interface::MailerTemplates + include Place::DeskSource descriptive_name "Desk Booking Approval Workflows" generic_name :BookingApproval @@ -26,6 +28,9 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver escalate_after: 48, disable_attachments: true, + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, + approval_type: { zone_id1: { name: "Sydney Building 1", @@ -678,18 +683,9 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver @[Security(Level::Support)] def users_with_invalid_desk_bookings(building_zone : String, ending : Int64) - # [] of {zone: {id:}, metadata: {desks: {details: [{id:, groups: [] of String}]}}} - meta_raw = staff_api.metadata_children(building_zone, "desks").get.as_a - # Zone => Desk_id => Groups - metadata = {} of String => Hash(String, Array(String)) - meta_raw.each do |zone| - desks = {} of String => Array(String) - zone_id = zone["zone"]["id"].as_s - zone["metadata"]["desks"]["details"].as_a.each do |desk| - desks[desk["id"].as_s] = desk["groups"].as_a.map(&.as_s.downcase) - end - metadata[zone_id] = desks + metadata = building_desks(building_zone).transform_values do |desks| + desks.to_h { |desk| {desk.id, desk.groups.map(&.downcase)} } end # User email, Desk ID, zone, booking id, starting, starting friendly @@ -720,7 +716,7 @@ class Place::BookingApprovalWorkflows < PlaceOS::Driver logger.debug { "Email,Desk ID,Zone,Booking id,Starting,Start date\n#{users.map { |u| "#{u[0]},#{u[1]},#{u[2]},#{u[3]},#{u[4]},#{u[5]}" }.join("\n")}" } - nil + users end @[Security(Level::Support)] diff --git a/drivers/place/booking_approval_workflows_spec.cr b/drivers/place/booking_approval_workflows_spec.cr index 7f7c1084fe..93eea9a4e4 100644 --- a/drivers/place/booking_approval_workflows_spec.cr +++ b/drivers/place/booking_approval_workflows_spec.cr @@ -7,7 +7,7 @@ DriverSpecs.mock_driver "Place::BookingApprovalWorkflows" do Mailer: {MailerMock}, }) - settings({ + base_settings = { booking_type: "desk", notify_managers: false, approval_type: { @@ -18,7 +18,8 @@ DriverSpecs.mock_driver "Place::BookingApprovalWorkflows" do attachments: nil, }, }, - }) + } + settings(base_settings) now = Time.utc.to_unix payload = { @@ -37,12 +38,33 @@ DriverSpecs.mock_driver "Place::BookingApprovalWorkflows" do }.to_json publish("staff/booking/changed", payload) - sleep 1 + sleep 1.second # the cancellation email's reply-to should be the person who created the booking system(:Mailer)[:template].should eq ["bookings", "cancelled"] system(:Mailer)[:to].should eq "user@org.com" system(:Mailer)[:reply_to].should eq "booker@org.com" + + # ============================================================= + # invalid desk bookings: user isn't in any of the desk's groups + # ============================================================= + staff_api = system(:StaffAPI).as(StaffAPIMock) + ending = now + 86400 + + # {email, desk id, zone, booking id} + invalid = ->(users : JSON::Any) { users.as_a.map { |user| user.as_a[0..3].map(&.raw) } } + + # metadata mode: desk-2 is for sales, desk-3 has no groups, desk-unknown isn't defined + users = exec(:users_with_invalid_desk_bookings, "zone-1", ending).get.not_nil! + invalid.call(users).should eq [["user@org.com", "desk-2", "zone-level-1", 11_i64]] + staff_api.asset_calls.should eq 0 + + # assets mode: bookings reference the Asset#id and groups come from place_groups + staff_api.migrate_bookings + settings(base_settings.merge({use_desk_assets: true})) + users = exec(:users_with_invalid_desk_bookings, "zone-1", ending).get.not_nil! + invalid.call(users).should eq [["user@org.com", "asset-2", "zone-level-1", 11_i64]] + staff_api.asset_calls.should eq 2 end # :nodoc: @@ -79,8 +101,78 @@ class MailerMock < DriverSpecs::MockDriver self[:to] = to self[:reply_to] = reply_to end + + def get_groups(user_id : String) + [{name: "Engineering"}] + end end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver + getter use_asset_ids : Bool = false + getter asset_calls : Int32 = 0 + + # bookings reference the Asset#id once desks are migrated + def migrate_bookings : Nil + @use_asset_ids = true + end + + def metadata_children(id : String, key : String? = nil) + raise "unexpected building #{id}" unless id == "zone-1" + [ + { + zone: {id: "zone-level-1"}, + metadata: { + desks: { + details: [ + {id: "desk-1", name: "Desk 1", groups: ["Engineering"]}, + {id: "desk-2", name: "Desk 2", groups: ["Sales"]}, + {id: "desk-3", name: "Desk 3", groups: [] of String}, + ], + }, + }, + }, + ] + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + @asset_calls += 1 + raise "unexpected asset query" unless type_id == "type-desk" && zones == ["zone-1"] + if next_page == "page-2" + [{id: "asset-3", identifier: "desk-3", zone_id: "zone-level-1", place_groups: [] of String}] + else + [ + {id: "asset-1", identifier: "desk-1", zone_id: "zone-level-1", place_groups: ["ENGINEERING"], next_page: "page-2"}, + {id: "asset-2", identifier: "desk-2", zone_id: "zone-level-1", place_groups: ["Sales"]}, + ] + end + end + + def query_bookings( + type : String? = nil, + period_start : Int64? = nil, + period_end : Int64? = nil, + zones : Array(String) = [] of String, + approved : Bool? = nil, + rejected : Bool? = nil, + created_before : Int64? = nil, + ) + now = Time.utc.to_unix + desks = use_asset_ids ? {"asset-1", "asset-2", "asset-3", "desk-2"} : {"desk-1", "desk-2", "desk-3", "desk-unknown"} + desks.map_with_index do |desk_id, index| + { + id: 10 + index, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + asset_id: desk_id, + user_email: "user@org.com", + zones: ["zone-1", "zone-level-1"], + } + end.to_a + end end diff --git a/drivers/place/booking_approver.cr b/drivers/place/booking_approver.cr index 403db72b91..eeead2bb23 100644 --- a/drivers/place/booking_approver.cr +++ b/drivers/place/booking_approver.cr @@ -1,10 +1,13 @@ require "placeos-driver" require "./booking_model" +require "./desk_source" class Place::BookingApprover < PlaceOS::Driver + include Place::DeskSource + descriptive_name "Booking Auto Approver" generic_name :BookingApprover - description %(Automatically approves all PlaceOS bookings, optionally rejecting bookings for assets not defined in zone metadata) + description %(Automatically approves all PlaceOS bookings, optionally rejecting bookings for assets not defined in zone metadata (or desk assets when use_desk_assets is set)) accessor staff_api : StaffAPI_1 @@ -18,10 +21,14 @@ class Place::BookingApprover < PlaceOS::Driver # time_zone: "Australia/Sydney", # booking_type => zone metadata key + # (booking types mapped to "desks" use Place::DeskSource) asset_metadata_keys: { "desk" => "desks", "parking" => "parking-spaces", }, + + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, }) def on_load @@ -77,7 +84,7 @@ class Place::BookingApprover < PlaceOS::Driver nil end - # Caches the asset ids defined in the metadata of the levels of every building + # Caches the asset ids defined in the metadata of the levels of every building (desks via Place::DeskSource) def cache_bookable_assets building_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s) assets = {} of String => Set(String) @@ -87,6 +94,11 @@ class Place::BookingApprover < PlaceOS::Driver ids = Set(String).new building_ids.each do |building_id| + if metadata_key == DESK_METADATA + building_desks(building_id).each_value { |desks| desks.each { |desk| ids << desk.id } } + next + end + staff_api.metadata_children(building_id, metadata_key).get.as_a.each do |level| next unless details = level.dig?("metadata", metadata_key, "details").try(&.as_a?) details.each do |asset| @@ -139,7 +151,7 @@ class Place::BookingApprover < PlaceOS::Driver undefined = asset_ids.reject { |asset_id| bookable.includes?(asset_id) } if !undefined.empty? staff_api.reject(booking.id, "booking_approval_driver").get - logger.debug { "Rejected Booking #{booking.id} as assets #{undefined} are not defined in zone metadata" } + logger.debug { "Rejected Booking #{booking.id} as assets #{undefined} are not defined" } @bookings_rejected += 1 return false end diff --git a/drivers/place/booking_approver_spec.cr b/drivers/place/booking_approver_spec.cr index d3fea5ec9d..60684ef2db 100644 --- a/drivers/place/booking_approver_spec.cr +++ b/drivers/place/booking_approver_spec.cr @@ -141,6 +141,39 @@ DriverSpecs.mock_driver "Place::BookingApprover" do exec(:status).get.not_nil!["bookable_assets"].should eq({"desk" => 0}) id = create_booking(booking_id, ["desk-1"], "desk", false) staff_api.rejected.should eq [id] + + # =================================================== + # desk assets: desk bookings reference the Asset#id + # =================================================== + staff_api.reset + settings({ + approve_booking_types: ["desk", "parking"], + approve_zones: ["zone-building"], + reject_undefined_assets: true, + use_desk_assets: true, + }) + sleep 2.seconds + + # desks come from the assets (two pages for zone-building), other types from metadata + exec(:status).get.not_nil!["bookable_assets"].should eq({"desk" => 3, "parking" => 1}) + staff_api.metadata_requests.sort.should eq [ + {"zone-building", "parking-spaces"}, + {"zone-building-2", "parking-spaces"}, + ] + staff_api.asset_requests.should eq [ + {"zone-building", nil}, + {"zone-building", "page-2"}, + {"zone-building-2", nil}, + ] + + asset_desk = create_booking(booking_id, ["asset-1"], "desk", false) + second_page_desk = create_booking(booking_id, ["asset-2"], "desk", false) + other_building_asset = create_booking(booking_id, ["asset-3"], "desk", false) + metadata_desk = create_booking(booking_id, ["desk-1"], "desk", false) + parking = create_booking(booking_id, ["park-1"], "parking", false) + + staff_api.approved.should eq [asset_desk, second_page_desk, other_building_asset, parking] + staff_api.rejected.should eq [metadata_desk] end # :nodoc: @@ -179,6 +212,7 @@ class StaffAPIMock < DriverSpecs::MockDriver getter rejected = [] of Int64 getter reject_sources = [] of String? getter metadata_requests = [] of Tuple(String, String) + getter asset_requests = [] of Tuple(String, String?) @fail_metadata : Bool = false def fail_metadata(state : Bool) @@ -190,6 +224,7 @@ class StaffAPIMock < DriverSpecs::MockDriver @rejected.clear @reject_sources.clear @metadata_requests.clear + @asset_requests.clear @fail_metadata = false end @@ -206,6 +241,27 @@ class StaffAPIMock < DriverSpecs::MockDriver end end + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}, {id: "type-locker", name: "Lockers"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + building = zones.try(&.first?).to_s + @asset_requests << {building, next_page} + + case {building, next_page} + when {"zone-building", nil} + [{id: "asset-1", identifier: "desk-1", zone_id: "zone-level-1", zones: [building, "zone-level-1"], next_page: "page-2"}] + when {"zone-building", "page-2"} + [{id: "asset-2", identifier: "desk-2", zone_id: "zone-level-1", zones: [building, "zone-level-1"]}] + when {"zone-building-2", nil} + [{id: "asset-3", identifier: "desk-3", zone_id: "zone-level-3", zones: [building, "zone-level-3"]}] + else + [] of Nil + end + end + def approve(booking_id : Int64, instance : Int64? = nil) @approved << booking_id true diff --git a/drivers/place/booking_notifier.cr b/drivers/place/booking_notifier.cr index 106f122f7a..c919b68917 100644 --- a/drivers/place/booking_notifier.cr +++ b/drivers/place/booking_notifier.cr @@ -65,6 +65,9 @@ class Place::BookingNotifier < PlaceOS::Driver notify_booking_owner: true, }, }, + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) accessor staff_api : StaffAPI_1 @@ -142,6 +145,7 @@ class Place::BookingNotifier < PlaceOS::Driver def on_update @building_id = nil @levels = nil + clear_asset_cache @booking_type = setting?(String, :booking_type).presence || "desk" @unique_templates = setting?(Bool, :unique_templates) || false diff --git a/drivers/place/bookings/asset_name_resolver.cr b/drivers/place/bookings/asset_name_resolver.cr index 35b32d8781..0eb87e1bef 100644 --- a/drivers/place/bookings/asset_name_resolver.cr +++ b/drivers/place/bookings/asset_name_resolver.cr @@ -1,8 +1,11 @@ require "json" require "./locker_models" +require "../desk_source" +# NOTE:: requires the including driver to provide `staff_api` and `building_id` module Place::AssetNameResolver include Place::LockerMetadataParser + include Place::DeskSource @asset_cache : AssetCache = AssetCache.new @asset_record_cache : AssetCache = AssetCache.new @@ -15,6 +18,7 @@ module Place::AssetNameResolver @asset_cache = AssetCache.new @asset_record_cache = AssetCache.new @asset_type_cache.clear + @desk_asset_type_ids = nil end private def lookup_asset(asset_id : String, type : String, zones : Array(String) = [building_id]) : String @@ -50,18 +54,10 @@ module Place::AssetNameResolver return cache[1] end - details = begin - metadata = Metadata.from_json staff_api.metadata(zone_id, metadata_field).get[metadata_field].to_json - metadata.details.as_a? - rescue error - logger.debug { "unable to get #{metadata_field} from zone #{zone_id} metadata" } - nil - end - - assets = if details - details.map { |asset| Asset.from_json asset.to_json } + assets = if type == "desk" + use_desk_assets? ? desk_assets(zone_id) : metadata_desk_assets(zone_id) else - lookup_asset_records(zone_id, type) + metadata_assets(zone_id, metadata_field, type) end @asset_cache[{zone_id, type}] = {Time.utc.to_unix + @asset_cache_timeout, assets} elsif type == "locker" @@ -74,6 +70,32 @@ module Place::AssetNameResolver [] of Asset end + private def desk_assets(zone_id : String) : Array(Asset) + level_desks(zone_id).map { |desk| Asset.new(desk.id, desk.name) } + end + + @[Deprecated("desk metadata: set use_desk_assets and remove")] + private def metadata_desk_assets(zone_id : String) : Array(Asset) + metadata_assets(zone_id, "desks", "desk") + end + + # falls back to the asset records when the metadata is not a list (i.e. it has been migrated) + private def metadata_assets(zone_id : String, metadata_field : String, type : String) : Array(Asset) + details = begin + metadata = Metadata.from_json staff_api.metadata(zone_id, metadata_field).get[metadata_field].to_json + metadata.details.as_a? + rescue error + logger.debug { "unable to get #{metadata_field} from zone #{zone_id} metadata" } + nil + end + + if details + details.map { |asset| Asset.from_json asset.to_json } + else + lookup_asset_records(zone_id, type) + end + end + private def lookup_asset_records(zone_id : String, type : String) : Array(Asset) assets = [] of Asset type_name = case type @@ -81,6 +103,8 @@ module Place::AssetNameResolver when "parking" then "_PARKING_SPACES_" end return assets unless type_name + # desk assets are already looked up via Place::DeskSource + return lookup_assets(zone_id, type) if type == "desk" && use_desk_assets? if (cache = @asset_record_cache[{zone_id, type}]?) && cache[0] > Time.utc.to_unix return cache[1] diff --git a/drivers/place/bookings/grant_area_access.cr b/drivers/place/bookings/grant_area_access.cr index fac30697b2..17bdc16966 100644 --- a/drivers/place/bookings/grant_area_access.cr +++ b/drivers/place/bookings/grant_area_access.cr @@ -4,9 +4,11 @@ require "placeos-driver/interface/mailer" require "placeos-driver/interface/mailer_templates" require "placeos-driver/interface/zone_access_security" require "../booking_model" +require "../desk_source" class Place::Bookings::GrantAreaAccess < PlaceOS::Driver include PlaceOS::Driver::Interface::MailerTemplates + include Place::DeskSource descriptive_name "PlaceOS Booking Area Access" generic_name :BookingAreaAccess @@ -26,6 +28,9 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver # At 10:00 on every day-of-week from Monday through Friday _email_cron: "0 10 * * 1-5", _email_errors_to: "admin@org.com", + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) accessor calendar : Calendar_1 @@ -134,13 +139,6 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver ensure_booking_access end - struct Desk - include JSON::Serializable - - getter id : String - getter security : String? = nil - end - def username_lookup(email : String) : String email = email.strip.downcase if username = cached_username[email]? @@ -212,25 +210,18 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver nil end - # returns desk_id => security zone name / id - def desks(level_id : String, blocked : Hash(String, String | Int64) = {} of String => String | Int64) : Hash(String, String) - desks = with_retry("desk metadata lookup for #{level_id}") do - staff_api.metadata(level_id, "desks").get.dig?("desks", "details") - end - security = {} of String => String - return security unless desks + # returns desk_id => security zone names / ids + def desks(level_id : String, blocked : Hash(String, Array(String)) = {} of String => Array(String)) : Hash(String, Array(String)) + desks = with_retry("desk lookup for #{level_id}") { level_desks(level_id) } + security = {} of String => Array(String) - Array(Desk).from_json(desks.to_json).each do |desk| - sec = desk.security.presence - next unless sec + desks.each do |desk| + groups = desk.security.reject(&.empty?) + next if groups.empty? - if security_zone_whitelist.empty? - security[desk.id] = sec - elsif security_zone_whitelist.includes?(sec) - security[desk.id] = sec - else - blocked[desk.id] = sec - end + allowed, denied = groups.partition { |group| security_zone_whitelist.empty? || security_zone_whitelist.includes?(group) } + security[desk.id] = allowed unless allowed.empty? + blocked[desk.id] = denied unless denied.empty? end security end @@ -248,9 +239,9 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver def ensure_booking_access errors = [] of String - # desk id => security zone + # desk id => security zones # where mapping blocked due to not being whitelisted - blocked = {} of String => String | Int64 + blocked = {} of String => Array(String) @check_mutex.synchronize do if @performing_check @@ -286,7 +277,7 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver desk = booking["asset_id"].as_s if security = desks[desk]? user_access = access_required[booking["user_email"].as_s.downcase] - user_access << security + user_access.concat security user_access.uniq! end end @@ -421,15 +412,15 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver @[Security(Level::Support)] def security_zone_report - # desk id => security id - blocked = {} of String => String | Int64 - found = {} of String => String | Int64 + # desk id => security ids + blocked = {} of String => Array(String) + found = {} of String => Array(String) levels.each do |level_id| found.merge! desks(level_id, blocked) end - found_values = found.values.map(&.to_s).uniq! - security_groups = found.values + blocked.values + found_values = found.values.flatten.uniq! + security_groups = (found.values + blocked.values).flatten in_whitelist_only = security_zone_whitelist - security_groups { @@ -443,7 +434,7 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver def approve_security_zone_list # save all the security zones to the whitelist details = security_zone_report - new_whitelist = details[:in_whitelist_only] + details[:allocated] + details[:blocked].values.uniq! + new_whitelist = details[:in_whitelist_only] + details[:allocated] + details[:blocked].values.flatten.uniq! whitelist_strings = new_whitelist.compact_map { |item| item.as(String) if item.is_a?(String) }.sort! whitelist_ints = new_whitelist.compact_map { |item| item.as(Int64) if item.is_a?(Int64) }.sort! @@ -484,7 +475,7 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver end def notify_issues - sync_blocked = status?(Hash(String, String | Int64), :sync_blocked) || Hash(String, String | Int64).new + sync_blocked = status?(Hash(String, Array(String)), :sync_blocked) || Hash(String, Array(String)).new sync_errors = status?(Array(String), :sync_errors) || [] of String return if sync_blocked.empty? && sync_errors.empty? @@ -493,8 +484,8 @@ class Place::Bookings::GrantAreaAccess < PlaceOS::Driver io << "security groups not in the whitelist:
\n" io << "=====================================
\n" - sync_blocked.each do |desk_id, security_zone| - io << "desk: #{desk_id}, security group: #{security_zone}
\n" + sync_blocked.each do |desk_id, security_zones| + io << "desk: #{desk_id}, security group: #{security_zones.join(", ")}
\n" end io << "

\n\n" end diff --git a/drivers/place/bookings/grant_area_access_spec.cr b/drivers/place/bookings/grant_area_access_spec.cr index 1d1eee8f9d..56f14b4892 100644 --- a/drivers/place/bookings/grant_area_access_spec.cr +++ b/drivers/place/bookings/grant_area_access_spec.cr @@ -1,4 +1,213 @@ require "placeos-driver/spec" +require "placeos-driver/interface/zone_access_security" DriverSpecs.mock_driver "Place::Bookings::GrantAreaAccess" do + system({ + StaffAPI: {StaffAPIMock}, + Calendar: {CalendarMock}, + LocationServices: {LocationServicesMock}, + Security: {SecurityMock}, + }) + + base_settings = { + lookup_using_username: false, + security_zone_whitelist: ["group-a", "group-b"], + request_retries: 0, + } + settings(base_settings) + staff_api = system(:StaffAPI).as(StaffAPIMock) + security = system(:Security).as(SecurityMock) + + # =================================================== + # desk metadata + # =================================================== + # a single security group per desk, blocked groups are not on the whitelist + exec(:desks, "zone-level-1").get.should eq({"desk-1" => ["group-a"]}) + + exec(:ensure_booking_access).get + staff_api.metadata_requests.should eq ["zone-level-1", "zone-level-1"] + security.members.should eq({"group-a" => ["card-alice@org.com"]}) + status[:sync_errors].should eq [] of String + status[:sync_blocked].should eq({"desk-2" => ["group-blocked"]}) + + report = exec(:security_zone_report).get.not_nil! + report["allocated"].should eq ["group-a"] + report["blocked"].should eq({"desk-2" => ["group-blocked"]}) + report["in_whitelist_only"].should eq ["group-b"] + + # =================================================== + # desk assets + # =================================================== + metadata_requests = staff_api.metadata_requests.size + staff_api.use_assets + settings(base_settings.merge({use_desk_assets: true})) + + # desks can have multiple security groups, only whitelisted groups are granted + exec(:desks, "zone-level-1").get.should eq({ + "asset-1" => ["group-a", "group-b"], + "asset-2" => ["group-b"], + }) + staff_api.asset_requests.should eq [{"zone-level-1", nil}, {"zone-level-1", "page-2"}] + + # bookings reference the Asset#id, the stale metadata desk id grants nothing + exec(:ensure_booking_access).get + staff_api.metadata_requests.size.should eq metadata_requests + security.members.should eq({ + "group-a" => ["card-alice@org.com"], + "group-b" => ["card-alice@org.com", "card-bob@org.com"], + }) + status[:sync_errors].should eq [] of String + status[:sync_blocked].should eq({"asset-2" => ["group-blocked"]}) + + report = exec(:security_zone_report).get.not_nil! + report["allocated"].should eq ["group-a", "group-b"] + report["blocked"].should eq({"asset-2" => ["group-blocked"]}) + report["in_whitelist_only"].should eq [] of String +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + getter metadata_requests : Array(String) = [] of String + getter asset_requests : Array(Tuple(String?, String?)) = [] of Tuple(String?, String?) + getter? assets_mode : Bool = false + + def use_assets + @assets_mode = true + @asset_requests.clear + end + + def systems_in_building(zone_id : String, ids_only : Bool = true) + {"zone-level-1" => ["sys-1"]} + end + + def zone(zone_id : String) + {id: zone_id, name: "Building", tags: ["building"], timezone: "Australia/Sydney"} + end + + def metadata(id : String, key : String? = nil) + @metadata_requests << id + {desks: {name: "desks", details: [ + {id: "desk-1", name: "Desk 1", security: "group-a"}, + {id: "desk-2", name: "Desk 2", security: "group-blocked"}, + {id: "desk-3", name: "Desk 3"}, + ]}} + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + @asset_requests << {zone_id, next_page} + + if next_page + [ + {id: "asset-2", identifier: "desk-2", zone_id: "zone-level-1", security_system_groups: ["group-blocked", "group-b"]}, + {id: "asset-3", identifier: "desk-3", zone_id: "zone-level-1", security_system_groups: [] of String}, + ] + else + [{id: "asset-1", identifier: "desk-1", zone_id: "zone-level-1", security_system_groups: ["group-a", "group-b"], next_page: "page-2"}] + end + end + + def query_bookings( + 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, + include_checked_out : Bool? = nil, + extension_data : JSON::Any? = nil, + deleted : Bool? = nil, + asset_id : String? = nil, + limit : Int32? = nil, + ) + prefix = assets_mode? ? "asset" : "desk" + bookings = [ + {id: 1, asset_id: "#{prefix}-1", user_email: "Alice@org.com", booking_type: "desk"}, + {id: 2, asset_id: "#{prefix}-2", user_email: "bob@org.com", booking_type: "desk"}, + {id: 3, asset_id: "#{prefix}-3", user_email: "carl@org.com", booking_type: "desk"}, + ] + # a booking still referencing the metadata desk id + bookings << {id: 4, asset_id: "desk-1", user_email: "dave@org.com", booking_type: "desk"} if assets_mode? + bookings + end +end + +# :nodoc: +class CalendarMock < DriverSpecs::MockDriver + def get_user(user_id : String) + {id: user_id, username: user_id} + end +end + +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + def building_id : String + "zone-building" + end +end + +# :nodoc: +struct MockCardHolder < PlaceOS::Driver::Interface::ZoneAccessSecurity::CardHolderDetails + def initialize(@id, @name, @email) + end +end + +# :nodoc: +struct MockZone < PlaceOS::Driver::Interface::ZoneAccessSecurity::ZoneDetails + def initialize(@id, @name, @description) + end +end + +# :nodoc: +class SecurityMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::ZoneAccessSecurity + + # zone id => card holder ids + @members = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String } + + def members : Hash(String, Array(String)) + @members.transform_values(&.sort) + end + + def card_holder_id_lookup(email : String) : String | Int64? + "card-#{email}" + end + + def card_holder_lookup(id : String | Int64) : CardHolderDetails + MockCardHolder.new(id, id.to_s, nil) + end + + def zone_access_id_lookup(name : String, exact_match : Bool = true) : String | Int64? + name if name.starts_with?("group-") + end + + def zone_access_lookup(id : String | Int64) : ZoneDetails + MockZone.new(id, id.to_s, nil) + end + + def zone_access_member?(zone_id : String | Int64, card_holder_id : String | Int64, from_unix : Int64? = nil, until_unix : Int64? = nil) : String | Int64? + card_holder_id.to_s if @members[zone_id.to_s]?.try(&.includes?(card_holder_id.to_s)) + end + + def zone_access_add_member(zone_id : String | Int64, card_holder_id : String | Int64, from_unix : Int64? = nil, until_unix : Int64? = nil) + @members[zone_id.to_s] << card_holder_id.to_s + true + end + + def zone_access_remove_member(zone_id : String | Int64, card_holder_id : String | Int64, from_unix : Int64? = nil, until_unix : Int64? = nil) + @members[zone_id.to_s].delete(card_holder_id.to_s) + true + end end diff --git a/drivers/place/desk/allocations.cr b/drivers/place/desk/allocations.cr index 62132ecc60..312467c721 100644 --- a/drivers/place/desk/allocations.cr +++ b/drivers/place/desk/allocations.cr @@ -1,8 +1,11 @@ require "placeos-driver" require "http" require "csv" +require "../desk_source" class Place::Desk::Allocations < PlaceOS::Driver + include Place::DeskSource + descriptive_name "PlaceOS Desk Allocations" generic_name :DeskAllocations description %(helper for exporting and importing desk allocations) @@ -14,6 +17,9 @@ class Place::Desk::Allocations < PlaceOS::Driver allocation_list_url: "http://org.com/desk/allocations", polling_cron: "*/15 * * * *", testing: false, + + # read and write desks as `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, }) @allocation_list_url : String = "http://org.com/desk/allocations" @@ -48,10 +54,13 @@ class Place::Desk::Allocations < PlaceOS::Driver Array(Zone).from_json(staff_api.zones(tags: {"level"}).get_json).sort_by(&.name) end - struct Desk + struct DeskAllocation include JSON::Serializable getter id : String + # the id shared with the desk management tool, desk assets keep the + # original metadata desk id as their map_id so integrations are unaffected + getter external_id : String getter name : String getter bookable : Bool getter features : String? @@ -66,39 +75,35 @@ class Place::Desk::Allocations < PlaceOS::Driver # getter org : String # getter campus : String - def initialize(@id, @name, @bookable, @features, @level_code, @building_code, @allocation_email, @building_id, @level_id) + def initialize(@id, @external_id, @name, @bookable, @features, @level_code, @building_code, @allocation_email, @building_id, @level_id) end end - def desks : Hash(String, Desk) + def desks : Hash(String, DeskAllocation) logger.debug { "getting list of all desks" } - response = {} of String => Desk + response = {} of String => DeskAllocation l = all_levels l.each do |level| logger.debug { " - processing level #{level.name}" } - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - if all_desks && (building = buildings[level.parent_id]?) - desks = all_desks.as_a - - building_code = building.code.presence || building.display_name.presence || building.name - level_code = level.code.presence || level.display_name.presence || level.name - - desks.each do |desk| - desk_id = desk["id"].as_s - response[desk_id.downcase] = Desk.new( - desk_id, - desk["name"].as_s?.presence || desk["id"].as_s, - desk["bookable"].as_bool, - desk["features"].as_a?.try(&.map(&.as_s).first?), - level_code, - building_code, - desk["assigned_to"]?.try(&.as_s?.presence.try(&.downcase)), - building.id, - level.id - ) - end + next unless building = buildings[level.parent_id]? + building_code = building.code.presence || building.display_name.presence || building.name + level_code = level.code.presence || level.display_name.presence || level.name + + level_desks(level.id).each do |desk| + response[desk.id.downcase] = DeskAllocation.new( + desk.id, + use_desk_assets? ? desk.map_id : desk.id, + desk.name, + desk.bookable?, + desk.features.first?, + level_code, + building_code, + desk.assigned_to.try(&.downcase), + building.id, + level.id + ) end end @@ -128,7 +133,7 @@ class Place::Desk::Allocations < PlaceOS::Driver "Staff" end - csv.row desk.id, desk.name, desk.building_code, desk.level_code, desk.allocation_email, desk_type + csv.row desk.external_id, desk.name, desk.building_code, desk.level_code, desk.allocation_email, desk_type end end @@ -171,6 +176,9 @@ class Place::Desk::Allocations < PlaceOS::Driver # "desk-6.43.19","6.43.19","Roopali.Misra@cdu.edu.au" csv = CSV.new(response.body, headers: true, strip: true) csv_allocations = {} of String => Allocation + external_ids = all_desks.each_with_object({} of String => String) do |(desk_id, desk), ids| + ids[desk.external_id.downcase] = desk_id + end loop do break unless csv.next @@ -178,7 +186,11 @@ class Place::Desk::Allocations < PlaceOS::Driver desk_id = row[0] allocated_email = row[2].downcase.presence bookable = !!allocated_email || row[3].includes?("Hot") - csv_allocations[desk_id.downcase] = Allocation.new(desk_id, row[1], allocated_email, bookable) + + # allocations reference the external id, record them against the desk id + key = external_ids[desk_id.downcase]? || desk_id.downcase + desk_id = all_desks[key]?.try(&.id) || desk_id + csv_allocations[key] = Allocation.new(desk_id, row[1], allocated_email, bookable) end level_allocations = Hash(String, Array(Allocation)).new do |hash, level_id| @@ -234,8 +246,37 @@ class Place::Desk::Allocations < PlaceOS::Driver end end - # set the users in the metadata + # set the users on the desks protected def update_allocation_metadata(level_zone : String, allocations : Array(Allocation)) + if use_desk_assets? + update_allocation_assets(level_zone, allocations) + else + write_desk_metadata(level_zone, allocations) + end + end + + protected def update_allocation_assets(level_zone : String, allocations : Array(Allocation)) + allocation_hash = allocations.to_h { |alloc| {alloc.desk_id.downcase, alloc} } + + level_desks(level_zone).each do |desk| + next unless allocation = allocation_hash[desk.id.downcase]? + email, user_name = allocation_user(allocation) + + begin + staff_api.update_asset(desk.id, JSON::Any.new({ + "identifier" => JSON::Any.new(allocation.desk_name), + "assigned_to" => JSON::Any.new(email), + "assigned_name" => JSON::Any.new(user_name), + "bookable" => JSON::Any.new(allocation.bookable), + })).get + rescue error + logger.error(exception: error) { "failed to update desk asset #{desk.id} on level: #{level_zone}" } + end + end + end + + @[Deprecated("desk metadata: set use_desk_assets and remove")] + protected def write_desk_metadata(level_zone : String, allocations : Array(Allocation)) # fetch the level metadata all_desks = staff_api.metadata(level_zone, "desks").get.dig?("desks", "details").try(&.as_a?) return unless all_desks @@ -248,18 +289,7 @@ class Place::Desk::Allocations < PlaceOS::Driver allocation = allocation_hash[desk_id.downcase]? desk = desk.as_h next desk unless allocation - - if email = allocation.email.presence - # look up users name - user_name = begin - calendar.get_user(email).get["name"].as_s - rescue error - logger.error(exception: error) { "failed to find allocation name for #{allocation.email}" } - email.split("@")[0].split(' ').map(&.capitalize).join(' ') - end - else - user_name = "" - end + email, user_name = allocation_user(allocation) desk["name"] = JSON::Any.new(allocation.desk_name) desk["assigned_to"] = JSON::Any.new(email) @@ -276,6 +306,21 @@ class Place::Desk::Allocations < PlaceOS::Driver end end + # the allocated email and the users name + protected def allocation_user(allocation : Allocation) : Tuple(String?, String) + email = allocation.email.presence + return {email, ""} unless email + + # look up users name + user_name = begin + calendar.get_user(email).get["name"].as_s + rescue error + logger.error(exception: error) { "failed to find allocation name for #{allocation.email}" } + email.split("@")[0].split(' ').map(&.capitalize).join(' ') + end + {email, user_name} + end + # Syncs all the allocations def sync_allocations(test : Bool = true) buildings.keys.each do |building_id| @@ -318,29 +363,22 @@ class Place::Desk::Allocations < PlaceOS::Driver assignments = {} of String => AssignmentDetails # get the desk assignments - json = staff_api.metadata_children(building_zone, "desks").get - json.as_a.each do |level| - level_zone = level["zone"]["id"].as_s - - if desks = level["metadata"]["desks"]? - desks["details"].as_a.each do |desk| - assigned_email = desk["assigned_to"]?.try(&.as_s?).presence - next unless assigned_email - assigned_email = assigned_email.strip.downcase - assigned_name = desk["assigned_name"]?.try(&.as_s?) - - if assigned_name.nil? - logger.warn { "no assigned name for desk #{assigned_email}" } - assigned_name = assigned_email.split('@')[0].gsub('.', ' ') - end - - desk_id = desk["id"].as_s - desk_name = desk["name"]?.try(&.as_s?) || desk_id - details = AssignmentDetails.new(desk_id, assigned_email, assigned_name, level_zone, desk_name) - - assignments[desk_id.downcase] = details - assigned_to[assigned_email] << details + building_desks(building_zone).each do |level_zone, desks| + desks.each do |desk| + assigned_email = desk.assigned_to.presence + next unless assigned_email + assigned_email = assigned_email.strip.downcase + assigned_name = desk.assigned_name + + if assigned_name.nil? + logger.warn { "no assigned name for desk #{assigned_email}" } + assigned_name = assigned_email.split('@')[0].gsub('.', ' ') end + + details = AssignmentDetails.new(desk.id, assigned_email, assigned_name, level_zone, desk.name) + + assignments[desk.id.downcase] = details + assigned_to[assigned_email] << details end end diff --git a/drivers/place/desk/allocations_spec.cr b/drivers/place/desk/allocations_spec.cr index b9b5e06f3e..09add3d65a 100644 --- a/drivers/place/desk/allocations_spec.cr +++ b/drivers/place/desk/allocations_spec.cr @@ -1,4 +1,258 @@ require "placeos-driver/spec" +require "http/server" + +# :nodoc: +# serves the remote desk allocation CSV. It references the original desk ids +# in both modes, desk assets keep them as their map_id +ALLOCATIONS_SERVER = HTTP::Server.new do |context| + context.response.content_type = "text/csv" + context.response.print <<-CSV + "PlaceOS ID","Name","Assigned To","desk Type" + "desk-1","Desk 1 new","Alice@org.com","Staff" + "desk-2","Desk 2","bob@org.com","Staff" + "unknown-desk","Unknown","carl@org.com","Staff" + CSV +end +ALLOCATIONS_PORT = ALLOCATIONS_SERVER.bind_unused_port("127.0.0.1").port +spawn { ALLOCATIONS_SERVER.listen } DriverSpecs.mock_driver "Place::Desk::Allocations" do + system({ + StaffAPI: {StaffAPIMock}, + Calendar: {CalendarMock}, + }) + + base_settings = { + allocation_list_url: "http://127.0.0.1:#{ALLOCATIONS_PORT}/metadata.csv", + polling_cron: "0 0 1 1 *", + } + settings(base_settings) + staff_api = system(:StaffAPI).as(StaffAPIMock) + + # =================================================== + # desk metadata + # =================================================== + desks = exec(:desks).get.not_nil!.as_h + desks.keys.should eq ["desk-1", "desk-2", "desk-3", "desk-4"] + desks["desk-1"]["features"].should eq "HDR" + desks["desk-2"]["allocation_email"].should eq "bob@org.com" + desks["desk-2"]["bookable"].should eq false + desks["desk-3"]["level_code"].should eq "L1" + desks["desk-3"]["building_code"].should eq "BLD" + + csv = exec(:get_desks, "GET", {} of String => Array(String), "").get.not_nil!.as_a + csv[0].should eq 200 + csv[2].as_s.split("\n").reject(&.empty?).should eq [ + "desk_id,desk_name,building,level,allocation_email,desk_type", + "desk-1,Desk 1,BLD,L1,,Student Hot Desk", + "desk-2,Desk 2,BLD,L1,bob@org.com,Staff", + "desk-3,Desk 3,BLD,L1,student.jane@org.com,Student", + "desk-4,Desk 4,BLD,L1,,Staff", + ] + + # desk-1 is newly allocated, desk-3 is no longer allocated + exec(:pull_allocations, false).get + staff_api.updated_assets.should be_empty + staff_api.metadata_writes.size.should eq 1 + level, written = staff_api.metadata_writes.first + level.should eq "zone-level-1" + written.as_a.map(&.as_h).should eq [ + {"id" => "desk-1", "name" => "Desk 1 new", "bookable" => true, "features" => ["HDR"], "assigned_to" => "alice@org.com", "assigned_name" => "Alice Smith"}, + {"id" => "desk-2", "name" => "Desk 2", "bookable" => false, "features" => [] of String, "assigned_to" => "Bob@org.com", "assigned_name" => "Bob"}, + {"id" => "desk-3", "name" => "Desk 3", "bookable" => false, "features" => [] of String, "assigned_to" => nil, "assigned_name" => ""}, + {"id" => "desk-4", "name" => "Desk 4", "bookable" => false, "features" => [] of String}, + ].map { |desk| JSON.parse(desk.to_json).as_h } + + # desk-2 booking is correct, desk-3 is missing a booking, desk-old and asset-2 are dangling + exec(:sync_allocations, false).get + staff_api.deleted.should eq [2_i64, 3_i64] + staff_api.created.should eq [{"desk-3", "student.jane@org.com", ["zone-org", "zone-building", "zone-level-1"]}] + + # =================================================== + # desk assets + # =================================================== + staff_api.reset + settings(base_settings.merge({use_desk_assets: true})) + + desks = exec(:desks).get.not_nil!.as_h + desks.keys.should eq ["asset-1", "asset-2", "asset-3", "asset-4"] + desks["asset-1"]["name"].should eq "Desk 1" + desks["asset-2"]["allocation_email"].should eq "bob@org.com" + staff_api.asset_requests.should eq [{"zone-level-1", nil}, {"zone-level-1", "page-2"}] + + csv = exec(:get_desks, "GET", {} of String => Array(String), "").get.not_nil!.as_a + # the desk management tool keeps using the original desk ids (Asset#map_id) + csv[2].as_s.split("\n").reject(&.empty?).should eq [ + "desk_id,desk_name,building,level,allocation_email,desk_type", + "desk-1,Desk 1,BLD,L1,,Student Hot Desk", + "desk-2,Desk 2,BLD,L1,bob@org.com,Staff", + "desk-3,Desk 3,BLD,L1,student.jane@org.com,Student", + "desk-4,Desk 4,BLD,L1,,Staff", + ] + + # allocations are matched on the original desk id and written to each desk asset, not the metadata + exec(:pull_allocations, false).get + staff_api.metadata_writes.should be_empty + staff_api.updated_assets.should eq [ + {"asset-1", JSON.parse({identifier: "Desk 1 new", assigned_to: "alice@org.com", assigned_name: "Alice Smith", bookable: true}.to_json)}, + {"asset-3", JSON.parse({identifier: "Desk 3", assigned_to: nil, assigned_name: "", bookable: false}.to_json)}, + ] + + # bookings reference the Asset#id, the legacy desk id bookings are dangling + exec(:sync_allocations, false).get + staff_api.deleted.should eq [1_i64, 2_i64] + staff_api.created.should eq [{"asset-3", "student.jane@org.com", ["zone-org", "zone-building", "zone-level-1"]}] +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + getter metadata_writes = [] of Tuple(String, JSON::Any) + getter updated_assets = [] of Tuple(String, JSON::Any) + getter asset_requests = [] of Tuple(String?, String?) + getter deleted = [] of Int64 + getter created = [] of Tuple(String, String, Array(String)) + + def reset + @metadata_writes.clear + @updated_assets.clear + @asset_requests.clear + @deleted.clear + @created.clear + end + + DESKS = [ + {id: "desk-1", name: "Desk 1", bookable: true, features: ["HDR"]}, + {id: "desk-2", name: "Desk 2", bookable: false, features: [] of String, assigned_to: "Bob@org.com", assigned_name: "Bob"}, + {id: "desk-3", name: "Desk 3", bookable: true, features: [] of String, assigned_to: "student.jane@org.com"}, + {id: "desk-4", name: "Desk 4", bookable: false, features: [] of String}, + ] + + def zones(q : String? = nil, limit : Int32 = 1000, offset : Int32 = 0, parent : String? = nil, tags : Array(String) | String? = nil) + if tags.to_s.includes?("building") + [{id: "zone-building", name: "Building", tags: ["building"], code: "BLD", timezone: "Australia/Sydney", parent_id: "zone-org"}] + else + [{id: "zone-level-1", name: "Level 1", tags: ["level"], code: "L1", parent_id: "zone-building"}] + end + end + + def zone(zone_id : String) + {id: zone_id, name: "Org", tags: ["org"]} + end + + def metadata(id : String, key : String? = nil) + {desks: {name: "desks", details: DESKS}} + end + + def metadata_children(id : String, key : String? = nil) + [{zone: {id: "zone-level-1"}, metadata: {desks: {name: "desks", details: DESKS}}}] + end + + def write_metadata(id : String, key : String, payload : JSON::Any, description : String = "") + @metadata_writes << {id, payload} + payload + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + @asset_requests << {zone_id, next_page} if zone_id + + assets = DESKS.map_with_index do |desk, index| + { + id: "asset-#{index + 1}", + identifier: desk[:name], + map_id: desk[:id], + zone_id: "zone-level-1", + bookable: desk[:bookable], + features: desk[:features], + assigned_to: desk[:assigned_to]?, + assigned_name: desk[:assigned_name]?, + next_page: index.zero? ? "page-2" : nil, + } + end + next_page ? assets[2..] : assets[0..1] + end + + def update_asset(id : String, asset : JSON::Any) + @updated_assets << {id, asset} + asset + end + + def query_bookings( + 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, + include_checked_out : Bool? = nil, + extension_data : JSON::Any? = nil, + deleted : Bool? = nil, + asset_id : String? = nil, + limit : Int32? = nil, + ) + [ + {id: 1, asset_id: "desk-2", user_email: "bob@org.com", booking_type: "desk", recurrence_type: "daily"}, + {id: 2, asset_id: "desk-old", user_email: "old@org.com", booking_type: "desk", recurrence_type: "daily"}, + {id: 3, asset_id: "asset-2", user_email: "bob@org.com", booking_type: "desk", recurrence_type: "daily"}, + # not an allocation booking + {id: 4, asset_id: "desk-other", user_email: "x@org.com", booking_type: "desk", recurrence_type: "none"}, + ] + end + + def booking_delete(booking_id : String | Int64, utm_source : String? = nil, instance : Int64? = nil) + @deleted << booking_id.to_i64 + true + 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(JSON::Any)? = 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, + asset_ids : Array(String)? = nil, + asset_name : String? = nil, + ) + @created << {asset_id, user_email, zones} + {id: 10} + end +end + +# :nodoc: +class CalendarMock < DriverSpecs::MockDriver + def get_user(user_id : String, additional_fields : Array(String)? = nil) + {name: "Alice Smith", email: user_id} + end end diff --git a/drivers/place/desk/control.cr b/drivers/place/desk/control.cr index 60dcf64b3b..356660990d 100644 --- a/drivers/place/desk/control.cr +++ b/drivers/place/desk/control.cr @@ -2,21 +2,25 @@ require "placeos-driver" require "placeos-driver/interface/desk_control" require "placeos" require "json" +require "../desk_source" class Place::Desk::Control < PlaceOS::Driver + include Place::DeskSource + descriptive_name "PlaceOS Desk Control" generic_name :DeskControl description %(helper for handling desk control) default_settings({ desk_id_key: "id", + + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, }) accessor area_manager : AreaManagement_1 accessor staff_api : StaffAPI_1 - METADATA_KEY = "desks" - def on_load # cache desk ids periodically schedule.every(1.hour) { @desk_ids = nil } @@ -25,6 +29,7 @@ class Place::Desk::Control < PlaceOS::Driver def on_update @desk_id_key = setting?(String, :desk_id_key) || "id" + @desk_ids = nil end getter desk_id_key : String = "id" @@ -70,42 +75,16 @@ class Place::Desk::Control < PlaceOS::Driver # Desk zone queries # =================================== - struct DeskId - include JSON::Serializable - include JSON::Serializable::Unmapped - - getter id : String - end - - struct Details - include JSON::Serializable - - property details : Array(DeskId) - end - - alias Zone = PlaceOS::Client::API::Models::Zone - alias Metadata = Hash(String, Details) - alias ChildMetadata = Array(NamedTuple(zone: Zone, metadata: Metadata)) - + # desk id => the id configured under `desk_id_key` getter desk_ids : Hash(String, String) do - metadatas = level_buildings.values.uniq.map do |zone_id| - ChildMetadata.from_json(staff_api.metadata_children( - zone_id, - METADATA_KEY - ).get_json) - end - - desks = {} of String => String key = desk_id_key + desks = {} of String => String - metadatas.each do |metadata| - metadata.each do |level| - zone = level[:zone] - if ids = level[:metadata][METADATA_KEY]?.try(&.details) - ids.each do |desk_details| - if mapped_id = desk_details.json_unmapped[key]?.try(&.as_s?) - desks[desk_details.id] = mapped_id - end + level_buildings.values.uniq!.each do |building_id| + building_desks(building_id).each_value do |level_desks| + level_desks.each do |desk| + if mapped_id = desk.value(key).try(&.as_s?) + desks[desk.id] = mapped_id end end end diff --git a/drivers/place/desk/control_spec.cr b/drivers/place/desk/control_spec.cr index 25c31035af..7f04ddc143 100644 --- a/drivers/place/desk/control_spec.cr +++ b/drivers/place/desk/control_spec.cr @@ -16,10 +16,61 @@ DriverSpecs.mock_driver "Place::Desk::Control" do }) exec(:desk_lookup, "desk_77123").get.should eq("desk-77123") + + # =================================================== + # desk assets: desks are keyed by Asset#id + # =================================================== + staff_api = system(:StaffAPI).as(StaffAPIMock) + settings({ + desk_id_key: "floorsense_id", + use_desk_assets: true, + }) + + # unmapped keys come from the asset other_data, desks without the key are skipped + exec(:desk_ids).get.should eq({ + "asset-1" => "fs-1", + "asset-2" => "fs-2", + }) + exec(:desk_lookup, "asset-2").get.should eq("fs-2") + staff_api.asset_requests.should eq [ + {["zone-DnTcV5ZeEq"], nil}, + {["zone-DnTcV5ZeEq"], "page-2"}, + ] + + # mapped desk fields can also be used as the key + settings({ + desk_id_key: "map_id", + use_desk_assets: true, + }) + exec(:desk_ids).get.should eq({ + "asset-1" => "desk-77123", + "asset-2" => "desk-006-17", + "asset-3" => "desk-3", + }) end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver + getter asset_requests = [] of Tuple(Array(String)?, String?) + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + @asset_requests << {zones, next_page} + + if next_page + [ + {id: "asset-2", identifier: "test3", map_id: "desk-006-17", zone_id: "zone-EYEnrhbaQz", other_data: {floorsense_id: "fs-2"}}, + {id: "asset-3", identifier: "test4", map_id: "desk-3", zone_id: "zone-EYEnrhbaQz", other_data: {} of String => String}, + ] + else + [{id: "asset-1", identifier: "test2", map_id: "desk-77123", zone_id: "zone-EYEnrhbaQz", other_data: {floorsense_id: "fs-1"}, next_page: "page-2"}] + end + end + def metadata_children(id : String, key : String? = nil) logger.info { "requesting zone #{id} and key #{key}" } diff --git a/drivers/place/desk_bookings_locations.cr b/drivers/place/desk_bookings_locations.cr index c52ed06d8a..281be6d24d 100644 --- a/drivers/place/desk_bookings_locations.cr +++ b/drivers/place/desk_bookings_locations.cr @@ -1,11 +1,13 @@ require "placeos-driver" require "placeos-driver/interface/locatable" require "./booking_model" +require "./desk_source" require "json" require "set" class Place::DeskBookingsLocations < PlaceOS::Driver include Interface::Locatable + include Place::DeskSource descriptive_name "PlaceOS Desk Bookings Locations" generic_name :DeskBookings @@ -22,6 +24,9 @@ class Place::DeskBookingsLocations < PlaceOS::Driver poll_rate: 20, booking_type: "desk", + # read desks from `_DESKS_` assets instead of the `desks` zone metadata + use_desk_assets: false, + _expose_for_analytics: {"output_key" => "booking_key->subkey"}, }) @@ -69,19 +74,7 @@ class Place::DeskBookingsLocations < PlaceOS::Driver # asset_id => map_id getter map_ids : Hash(String, String) do - levels = staff_api.metadata_children(building_id, "desks").get.as_a - id_map = {} of String => String - - levels.each do |level| - if desks = level["metadata"]["desks"]? - desks["details"].as_a.each do |desk| - if map_id = desk["map_id"]?.try(&.as_s?).presence - id_map[desk["id"].as_s] = map_id - end - end - end - end - id_map + building_desks(building_id).each_value.flat_map(&.map { |desk| {desk.id, desk.map_id} }).to_h end # =================================== diff --git a/drivers/place/desk_bookings_locations_spec.cr b/drivers/place/desk_bookings_locations_spec.cr index 10c8bc4315..2104c69131 100644 --- a/drivers/place/desk_bookings_locations_spec.cr +++ b/drivers/place/desk_bookings_locations_spec.cr @@ -2,29 +2,85 @@ require "placeos-driver/spec" DriverSpecs.mock_driver "Place::DeskBookingsLocations" do system({ - StaffAPI: {StaffAPIMock}, - AreaManagement: {AreaManagementMock}, + StaffAPI: {StaffAPIMock}, + AreaManagement: {AreaManagementMock}, + LocationServices: {LocationServicesMock}, }) - settings({ + base_settings = { zone_filter: ["placeos-zone-id"], - }) + } + settings(base_settings) + staff_api = system(:StaffAPI).as(StaffAPIMock) now = Time.local start = now.at_beginning_of_day.to_unix ending = now.at_end_of_day.to_unix + # metadata mode: bookings reference the metadata desk ids + exec(:query_desk_bookings).get + resp = exec(:device_locations, "placeos-zone-id").get + puts resp + resp.should eq([ + {"location" => "booking", "type" => "desk", "checked_in" => true, "asset_id" => "desk-123", "booking_id" => 1, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-1234", "staff_email" => "user1234@org.com", "staff_name" => "Bob Jane", "map_id" => "meta-map-123"}, + {"location" => "booking", "type" => "desk", "checked_in" => false, "asset_id" => "desk-456", "booking_id" => 2, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-456", "staff_email" => "zdoo@org.com", "staff_name" => "Zee Doo", "map_id" => "meta-map-456"}, + ]) + staff_api.asset_calls.should eq 0 + + # assets mode: bookings reference the Asset#id, map ids come from the assets (two pages) + staff_api.migrate_bookings + settings(base_settings.merge({use_desk_assets: true})) exec(:query_desk_bookings).get resp = exec(:device_locations, "placeos-zone-id").get puts resp resp.should eq([ - {"location" => "booking", "type" => "desk", "checked_in" => true, "asset_id" => "desk-123", "booking_id" => 1, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-1234", "staff_email" => "user1234@org.com", "staff_name" => "Bob Jane"}, - {"location" => "booking", "type" => "desk", "checked_in" => false, "asset_id" => "desk-456", "booking_id" => 2, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-456", "staff_email" => "zdoo@org.com", "staff_name" => "Zee Doo"}, + {"location" => "booking", "type" => "desk", "checked_in" => true, "asset_id" => "asset-123", "booking_id" => 1, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-1234", "staff_email" => "user1234@org.com", "staff_name" => "Bob Jane", "map_id" => "asset-map-123"}, + {"location" => "booking", "type" => "desk", "checked_in" => false, "asset_id" => "asset-456", "booking_id" => 2, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-456", "staff_email" => "zdoo@org.com", "staff_name" => "Zee Doo", "map_id" => "asset-map-456"}, ]) + staff_api.asset_calls.should eq 2 end # :nodoc: class StaffAPIMock < DriverSpecs::MockDriver + getter use_asset_ids : Bool = false + getter asset_calls : Int32 = 0 + + # bookings reference the Asset#id once desks are migrated + def migrate_bookings : Nil + @use_asset_ids = true + end + + def metadata_children(id : String, key : String? = nil) + raise "unexpected building #{id}" unless id == "zone-building" + [ + { + zone: {id: "placeos-zone-id"}, + metadata: { + desks: { + details: [ + {id: "desk-123", name: "Desk 123", map_id: "meta-map-123"}, + {id: "desk-456", name: "Desk 456", map_id: "meta-map-456"}, + ], + }, + }, + }, + ] + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + [{id: "type-desk", name: "_DESKS_"}, {id: "type-other", name: "Lockers"}] + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + @asset_calls += 1 + raise "unexpected asset query" unless type_id == "type-desk" && zones == ["zone-building"] + if next_page == "page-2" + [{id: "asset-456", identifier: "desk-456", map_id: "asset-map-456", zone_id: "placeos-zone-id", zones: ["zone-building", "placeos-zone-id"]}] + else + [{id: "asset-123", identifier: "desk-123", map_id: "asset-map-123", zone_id: "placeos-zone-id", zones: ["zone-building", "placeos-zone-id"], next_page: "page-2"}] + end + end + def query_bookings(type : String, zones : Array(String) = [] of String, period_start : Int64? = nil, period_end : Int64? = nil) logger.debug { "Querying desk bookings!" } @@ -37,7 +93,7 @@ class StaffAPIMock < DriverSpecs::MockDriver booking_type: type, booking_start: start, booking_end: ending, - asset_id: "desk-123", + asset_id: use_asset_ids ? "asset-123" : "desk-123", user_id: "user-1234", user_email: "user1234@org.com", user_name: "Bob Jane", @@ -52,7 +108,7 @@ class StaffAPIMock < DriverSpecs::MockDriver booking_type: type, booking_start: start, booking_end: ending, - asset_id: "desk-456", + asset_id: use_asset_ids ? "asset-456" : "desk-456", user_id: "user-456", user_email: "zdoo@org.com", user_name: "Zee Doo", @@ -89,3 +145,10 @@ class AreaManagementMock < DriverSpecs::MockDriver nil end end + +# :nodoc: +class LocationServicesMock < DriverSpecs::MockDriver + def building_id : String + "zone-building" + end +end diff --git a/drivers/place/desk_source.cr b/drivers/place/desk_source.cr new file mode 100644 index 0000000000..27f45edfaf --- /dev/null +++ b/drivers/place/desk_source.cr @@ -0,0 +1,204 @@ +require "json" + +# Desks are moving from the `desks` zone metadata to `_DESKS_` assets. +# The `use_desk_assets` setting (typically set once on the org zone and inherited) +# selects assets as the source of truth. Requires the including driver to provide `staff_api`. +# +# NOTE:: with assets, `Desk#id` is the `Asset#id`, which is what desk bookings reference +# once migrated (init `migrate:desk_assets`). With metadata it is the metadata desk id. +module Place::DeskSource + DESK_ASSET_TYPE = "_DESKS_" + DESK_METADATA = "desks" + + # metadata keys with a dedicated field, the rest are kept in `other_data`. + # matches init `migrate:desk_assets` so both sources present the same desk + DESK_FIELDS = {"id", "map_id", "name", "bookable", "assigned_to", "assigned_name", "groups", "features", "images", "notes", "security"} + + struct Desk + include JSON::Serializable + + getter id : String + getter name : String + getter map_id : String + getter zone_id : String + getter? bookable : Bool + getter assigned_to : String? + getter assigned_name : String? + getter groups : Array(String) + getter features : Array(String) + getter images : Array(String) + getter notes : String? + getter security : Array(String) + + # custom fields, such as third party ids (`floorsense_id`) + getter other_data : Hash(String, JSON::Any) + + def initialize( + @id : String, + @name : String, + @map_id : String, + @zone_id : String, + @bookable : Bool = true, + @assigned_to : String? = nil, + @assigned_name : String? = nil, + @groups : Array(String) = [] of String, + @features : Array(String) = [] of String, + @images : Array(String) = [] of String, + @notes : String? = nil, + @security : Array(String) = [] of String, + @other_data : Hash(String, JSON::Any) = {} of String => JSON::Any, + ) + end + + def []?(key : String) : JSON::Any? + other_data[key]? + end + + # looks up a desk metadata key, whether it maps to a desk field or is custom. + # empty fields are treated as missing + def value(key : String) : JSON::Any? + return other_data[key]? unless DESK_FIELDS.includes?(key) + value = JSON.parse(to_json)[key]? + value unless value.nil? || value.raw.nil? || value.as_a?.try(&.empty?) + end + + def self.from_asset(json : JSON::Any) : Desk? + return unless asset = json.as_h? + return unless id = string(asset["id"]?) + other_data = asset["other_data"]?.try(&.as_h?) || {} of String => JSON::Any + + new( + id: id, + name: string(asset["identifier"]?) || string(asset["name"]?) || id, + map_id: string(asset["map_id"]?) || string(other_data["map_id"]?) || id, + zone_id: string(asset["zone_id"]?) || "", + bookable: bool(asset["bookable"]?), + assigned_to: string(asset["assigned_to"]?), + assigned_name: string(asset["assigned_name"]?), + groups: strings(asset["place_groups"]?), + features: strings(asset["features"]?), + images: strings(asset["images"]?), + notes: string(asset["notes"]?), + security: strings(asset["security_system_groups"]?), + other_data: other_data, + ) + end + + def self.from_metadata(json : JSON::Any, zone_id : String) : Desk? + return unless desk = json.as_h? + id = string(desk["id"]?) + return unless map_id = string(desk["map_id"]?) || id + id ||= map_id + + new( + id: id, + name: string(desk["name"]?) || id, + map_id: map_id, + zone_id: zone_id, + bookable: bool(desk["bookable"]?), + assigned_to: string(desk["assigned_to"]?), + assigned_name: string(desk["assigned_name"]?), + groups: strings(desk["groups"]?), + features: strings(desk["features"]?), + images: strings(desk["images"]?), + notes: string(desk["notes"]?), + security: string(desk["security"]?).try { |group| [group] } || [] of String, + other_data: desk.reject { |key, _| DESK_FIELDS.includes?(key) }, + ) + end + + protected def self.string(value : JSON::Any?) : String? + value.try(&.as_s?).presence + end + + protected def self.strings(value : JSON::Any?) : Array(String) + value.try(&.as_a?).try(&.compact_map(&.as_s?)) || [] of String + end + + # desks are bookable unless explicitly disabled, some metadata uses strings ("FALSE") + protected def self.bool(value : JSON::Any?) : Bool + return true unless value + if string = value.as_s? + string.downcase != "false" + else + value.as_bool? != false + end + end + end + + def use_desk_assets? : Bool + !!setting?(Bool, :use_desk_assets) + end + + # the desks on a level + def level_desks(level_id : String) : Array(Desk) + use_desk_assets? ? asset_desks(zone_id: level_id) : metadata_level_desks(level_id) + end + + # level zone id => the desks on that level + def building_desks(building_id : String) : Hash(String, Array(Desk)) + desks = use_desk_assets? ? asset_desks(zones: [building_id]) : metadata_building_desks(building_id) + desks.group_by(&.zone_id) + end + + # Assets + ############################################################################################### + + @desk_asset_type_ids : Array(String)? = nil + + protected def asset_desks(zone_id : String? = nil, zones : Array(String)? = nil) : Array(Desk) + desk_asset_type_ids.flat_map do |type_id| + desks = [] of Desk + next_page = nil + + loop do + page = staff_api.assets(type_id: type_id, zone_id: zone_id, zones: zones, next_page: next_page).get.as_a + page.each { |asset| Desk.from_asset(asset).try { |desk| desks << desk } } + next_page = page.first?.try(&.["next_page"]?).try(&.as_s?) + break unless next_page + end + + desks + end + end + + protected def desk_asset_type_ids : Array(String) + if ids = @desk_asset_type_ids + return ids + end + + ids = staff_api.asset_types.get.as_a + .select { |asset_type| asset_type["name"]?.try(&.as_s?) == DESK_ASSET_TYPE } + .map(&.["id"].as_s).uniq! + + # don't cache a miss, the desk type may not have been created yet + @desk_asset_type_ids = ids unless ids.empty? + ids + end + + # Metadata + ############################################################################################### + + @[Deprecated("desk metadata: set use_desk_assets and remove")] + protected def metadata_level_desks(level_id : String) : Array(Desk) + details = staff_api.metadata(level_id, DESK_METADATA).get[DESK_METADATA]?.try(&.["details"]?) + parse_metadata_desks(details, level_id) + end + + @[Deprecated("desk metadata: set use_desk_assets and remove")] + protected def metadata_building_desks(building_id : String) : Array(Desk) + staff_api.metadata_children(building_id, DESK_METADATA).get.as_a.flat_map do |child| + zone_id = child["zone"]["id"].as_s + parse_metadata_desks(child.dig?("metadata", DESK_METADATA, "details"), zone_id) + end + end + + private def parse_metadata_desks(details : JSON::Any?, zone_id : String) : Array(Desk) + return [] of Desk unless details + unless desks = details.as_a? + logger.warn { "desk metadata on #{zone_id} is not a list, desks may have been migrated to assets (use_desk_assets)" } + return [] of Desk + end + desks.compact_map { |desk| Desk.from_metadata(desk, zone_id) } + end +end diff --git a/drivers/place/llm/campus.cr b/drivers/place/llm/campus.cr index 1bffa78dfb..6bc740ab8d 100644 --- a/drivers/place/llm/campus.cr +++ b/drivers/place/llm/campus.cr @@ -5,9 +5,11 @@ require "set" # metadata class require "placeos" require "./nearby" +require "../desk_source" class Place::Campus < PlaceOS::Driver include Interface::ChatFunctions + include Place::DeskSource descriptive_name "LLM Campus interface" generic_name :Campus @@ -20,6 +22,9 @@ class Place::Campus < PlaceOS::Driver # how many days into the future a booking may be made (inclusive) max_booking_days: 14, + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) @fallback_timezone : Time::Location = Time::Location::UTC @@ -99,20 +104,11 @@ class Place::Campus < PlaceOS::Driver logger.debug { "getting list of levels for building #{building_id}" } l = all_levels(building_id) l.each do |level| - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - if all_desks - desks = all_desks.as_a - level.bookable_desk_count = desks.size - features = Set(String).new - desks.each do |desk| - if feat = desk["features"]? - feat.as_a.each { |f| features << f.as_s.downcase } - end - end - level.desk_features = features.to_a unless features.empty? - else - level.bookable_desk_count = 0 - end + desks = level_desks(level.id) + level.bookable_desk_count = desks.size + features = Set(String).new + desks.each { |desk| desk.features.each { |f| features << f.downcase } } + level.desk_features = features.to_a unless features.empty? end l end @@ -139,7 +135,7 @@ class Place::Campus < PlaceOS::Driver alias ChildMetadata = Array(NamedTuple(zone: PlaceZone, metadata: Metadata)) @[Description("returns the list of desks available for booking in the specified building on the level and day specified. If the level has desk features then you can also filter by features.")] - def desks(building_id : String, level_id : String, day_offset : Int32 = 0, date : Time? = nil, feature : String? = nil) : Array(Desk) + def desks(building_id : String, level_id : String, day_offset : Int32 = 0, date : Time? = nil, feature : String? = nil) : Array(DeskDetails) logger.debug { "listing desks in building #{building_id} on level #{level_id}, day offset #{day_offset}" } # ensure the level id exists @@ -147,9 +143,9 @@ class Place::Campus < PlaceOS::Driver raise "could not find level_id #{level_id} in building #{building_id}. Make sure you've obtained the list of levels." unless level # get the list of desks for the level - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no bookable desks on this level, please try another." unless all_desks - desks = Array(Desk).from_json(all_desks.to_json).select!(&.bookable) + desks = level_desks(level.id) + raise "no bookable desks on this level, please try another." if desks.empty? + desks.select!(&.bookable?) # calculate the offset time tz = building_timezone(building_id) @@ -173,14 +169,14 @@ class Place::Campus < PlaceOS::Driver feature = feature.try(&.downcase) desks.reject! do |desk| next true if desk.id.in?(bookings) - next true if feature && !desk.features.map!(&.downcase).includes?(feature) + next true if feature && desk.features.none? { |f| f.downcase == feature } if !desk.groups.empty? (desk.groups & me.groups).empty? end end logger.debug { "found #{desks.size} available desks" } - desks + desks.map { |desk| DeskDetails.new(desk) } end struct Colleagues @@ -270,9 +266,9 @@ class Place::Campus < PlaceOS::Driver # Desks are booked by id, but they are drawn on the level map under their # map_id, so we have to translate between the two. Returns desk_id => map_id protected def desk_map_ids(level_id : String) : Hash(String, String) - all_desks = staff_api.metadata(level_id, "desks").get.dig?("desks", "details") - raise "no desks configured on level #{level_id}" unless all_desks - Array(Desk).from_json(all_desks.to_json).to_h { |desk| {desk.id, desk.map_id} } + desks = level_desks(level_id) + raise "no desks configured on level #{level_id}" if desks.empty? + desks.to_h { |desk| {desk.id, desk.map_id} } end @[Description("given a desk_id this returns nearby desks in order of how close they are. You can provide a colleagues desk_id for instance and then pick the first id that matches one of the desks available for booking. The building is resolved from the level_id, so no building_id is required")] @@ -434,9 +430,8 @@ class Place::Campus < PlaceOS::Driver desk = nil case booking_type when "desk" - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks - desks = Array(Desk).from_json(all_desks.to_json) + desks = level_desks(level.id) + raise "no desks found on level #{level_id}, ensure this id is correct" if desks.empty? desk = desks.find { |d| d.id == asset_id } raise "could not find a desk with id '#{asset_id}', maybe you passed the desk name?" unless desk @@ -509,9 +504,8 @@ class Place::Campus < PlaceOS::Driver desk = nil case booking_type when "desk" - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks - desks = Array(Desk).from_json(all_desks.to_json) + desks = level_desks(level.id) + raise "no desks found on level #{level_id}, ensure this id is correct" if desks.empty? desk = desks.find { |d| d.id == asset_id } raise "could not find a desk with id '#{asset_id}', maybe you passed the desk name?" unless desk @@ -646,15 +640,25 @@ class Place::Campus < PlaceOS::Driver end end - struct Desk + # the desk fields returned to the LLM + struct DeskDetails include JSON::Serializable getter id : String - getter name : String { id } - getter bookable : Bool { true } - getter groups : Array(String) = [] of String - getter features : Array(String) = [] of String - getter map_id : String { id } + getter name : String + getter bookable : Bool + getter groups : Array(String) + getter features : Array(String) + getter map_id : String + + def initialize(desk : Desk) + @id = desk.id + @name = desk.name + @bookable = desk.bookable? + @groups = desk.groups + @features = desk.features + @map_id = desk.map_id + end end protected def to_friendly_system(system : JSON::Any) : System? diff --git a/drivers/place/llm/campus_spec.cr b/drivers/place/llm/campus_spec.cr index 0c2de007c5..949c52d9e3 100644 --- a/drivers/place/llm/campus_spec.cr +++ b/drivers/place/llm/campus_spec.cr @@ -124,6 +124,57 @@ class StaffAPI < DriverSpecs::MockDriver {name: "Colleague Five", email: "c5@example.com", groups: ["staff"]}, ] + # the same desks as `_DESKS_` assets, with asset ids that differ from the + # metadata desk ids so specs can tell which source the driver used + ASSETS = { + "zone-level-a1" => [ + [ + {id: "asset-a1-01", identifier: "Window Desk", map_id: "desk.a", zone_id: "zone-level-a1", bookable: true, place_groups: [] of String, features: ["Window"], other_data: {floorsense_id: "fs-asset-a1-01"}}, + {id: "asset-a1-02", identifier: "Desk A1 02", map_id: "desk.b", zone_id: "zone-level-a1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-a1-02"}}, + ], + [ + {id: "asset-a1-03", identifier: "Desk A1 03", map_id: "desk.x", zone_id: "zone-level-a1", bookable: true, place_groups: ["execs"], features: [] of String, other_data: {floorsense_id: "fs-asset-a1-03"}}, + {id: "asset-a1-c1", identifier: "Desk A1 C1", map_id: "desk.c1", zone_id: "zone-level-a1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-a1-c1"}}, + {id: "asset-a1-c2", identifier: "Desk A1 C2", map_id: "desk.c2", zone_id: "zone-level-a1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-a1-c2"}}, + {id: "asset-a1-c3", identifier: "Desk A1 C3", map_id: "desk.c3", zone_id: "zone-level-a1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-a1-c3"}}, + ], + ], + "zone-level-a2" => [ + [ + {id: "asset-a2-01", identifier: "Desk A2 01", map_id: "asset-a2-01", zone_id: "zone-level-a2", bookable: false, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-a2-01"}}, + ], + ], + "zone-level-b1" => [ + [ + {id: "asset-b1-01", identifier: "Desk B1 01", map_id: "desk.b1", zone_id: "zone-level-b1", bookable: true, place_groups: [] of String, features: ["Standing"], other_data: {floorsense_id: "fs-asset-b1-01"}}, + {id: "asset-b1-c4", identifier: "Desk B1 C4", map_id: "desk.c4", zone_id: "zone-level-b1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-asset-b1-c4"}}, + ], + ], + } + + # once migrated, desk bookings reference the Asset#id + @desk_assets : Bool = false + + def desk_assets(enabled : Bool) + @desk_assets = enabled + end + + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + JSON.parse([{id: "type-desk", name: "_DESKS_"}, {id: "type-locker", name: "Locker"}].to_json) + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + return JSON.parse("[]") unless pages = ASSETS[zone_id]? + page_index = next_page ? next_page.lchop("page-").to_i - 1 : 0 + page = JSON.parse(pages[page_index].to_json).as_a + # the next page link is on the first element of a page + if page_index + 1 < pages.size && (first = page.first?) + first.as_h["next_page"] = JSON::Any.new("page-#{page_index + 2}") + end + JSON::Any.new(page) + end + def on_load self[:bookings_created] = 0 self[:last_booking_zones] = [] of String @@ -332,19 +383,23 @@ class StaffAPI < DriverSpecs::MockDriver # a colleague's desk for the day if desk = COLLEAGUE_DESKS[email]? asset_id, level_id, building_id = desk - bookings << colleague_booking(asset_id, level_id, building_id, email) + bookings << colleague_booking(desk_asset_id(asset_id), level_id, building_id, email) end elsif !zones.empty? # everything booked on the level, used to work out what is still free COLLEAGUE_DESKS.each do |colleague_email, (asset_id, level_id, building_id)| next unless zones.includes?(level_id) - bookings << colleague_booking(asset_id, level_id, building_id, colleague_email) + bookings << colleague_booking(desk_asset_id(asset_id), level_id, building_id, colleague_email) end end bookings end + protected def desk_asset_id(desk_id : String) : String + @desk_assets ? desk_id.sub("desk-", "asset-") : desk_id + end + protected def colleague_booking(asset_id : String, level_id : String, building_id : String, email : String) : JSON::Any JSON.parse({ id: 1_i64, @@ -777,7 +832,7 @@ DriverSpecs.mock_driver "Place::Campus" do result.map(&.["level_name"].as_s).should eq ["Ground Floor", "Lobby"] # who is sitting there, not just how many - result.first["colleagues"].as_a.map(&.["email"].as_s).sort.should eq [ + result.first["colleagues"].as_a.map(&.["email"].as_s).sort!.should eq [ "c1@example.com", "c2@example.com", "c3@example.com", ] result.last["colleagues"].as_a.map(&.["name"].as_s).should eq ["Colleague Four"] @@ -785,7 +840,7 @@ DriverSpecs.mock_driver "Place::Campus" do level_a1 = result.first level_a1["building_id"].as_s.should eq "zone-building-a" level_a1["building_name"].as_s.should eq "Sydney Tower" - level_a1["groups"].as_a.map(&.as_s).sort.should eq ["design", "eng", "staff"] + level_a1["groups"].as_a.map(&.as_s).sort!.should eq ["design", "eng", "staff"] # desk.a is closest to one colleague but furthest from the other two, so the # more central desk.b outranks it. Colleagues' own desks are booked, and @@ -806,4 +861,100 @@ DriverSpecs.mock_driver "Place::Campus" do capabilities.downcase.should contain "buildings" capabilities.downcase.should contain "ask" end + + it "lists the available desks from the level metadata" do + desks = exec(:desks, building_id: "zone-building-a", level_id: "zone-level-a1").get.as_a + # colleagues' desks are booked and desk-a1-03 is limited to execs + desks.map(&.["id"].as_s).should eq ["desk-a1-01", "desk-a1-02"] + desks.first.as_h.keys.sort!.should eq ["bookable", "features", "groups", "id", "map_id", "name"] + end + + # =================================================== + # desk assets + # =================================================== + + it "lists desks from assets, excluding booked, unbookable and group restricted desks" do + system(:StaffAPI).as(StaffAPI).desk_assets(true) + settings({ + time_zone: "Australia/Sydney", + prompt_tweaks: "", + max_booking_days: 14, + use_desk_assets: true, + }) + sleep 200.milliseconds + + levels = exec(:levels, building_id: "zone-building-a").get.as_a + level_a1 = levels.find! { |l| l["id"].as_s == "zone-level-a1" } + level_a1["bookable_desk_count"].as_i.should eq 6 + level_a1["desk_features"].as_a.map(&.as_s).should eq ["window"] + + # both pages of assets are read, the colleagues' bookings of their asset ids hide them + desks = exec(:desks, building_id: "zone-building-a", level_id: "zone-level-a1").get.as_a + desks.map(&.["id"].as_s).should eq ["asset-a1-01", "asset-a1-02"] + + # the LLM gets the same slim desk shape as before, no asset other_data + desk = desks.first.as_h + desk.keys.sort!.should eq ["bookable", "features", "groups", "id", "map_id", "name"] + desk["name"].as_s.should eq "Window Desk" + desk["map_id"].as_s.should eq "desk.a" + + exec(:desks, building_id: "zone-building-a", level_id: "zone-level-a1", feature: "window").get.as_a + .map(&.["id"].as_s).should eq ["asset-a1-01"] + + # the only desk on level a2 isn't bookable + exec(:desks, building_id: "zone-building-a", level_id: "zone-level-a2").get.as_a.should be_empty + end + + it "maps asset ids to their map_id for proximity" do + nearby = exec(:nearby_desks, level_id: "zone-level-a1", desk_id: "asset-a1-c1").get.as_a.map(&.as_s) + nearby.first.should eq "asset-a1-01" + nearby.should contain "asset-a1-02" + nearby.should_not contain "asset-a1-c1" + + # the metadata desk id is no longer known + expect_raises(Exception, /could not find a desk with id 'desk-a1-c1'/) do + exec(:nearby_desks, level_id: "zone-level-a1", desk_id: "desk-a1-c1").get + end + + result = exec(:desks_near_colleagues).get.as_a + result.map(&.["level_id"].as_s).should eq ["zone-level-a1", "zone-level-b1"] + result.first["nearby_desks"].as_a.map(&.as_s).should eq ["asset-a1-02", "asset-a1-01"] + result.last["nearby_desks"].as_a.map(&.as_s).should eq ["asset-b1-01"] + end + + it "books desks against the Asset#id" do + exec( + :book_relative, + building_id: "zone-building-a", + booking_type: "desk", + asset_id: "asset-a1-01", + level_id: "zone-level-a1", + day_offset: 1, + ).get + + staff_api = system(:StaffAPI) + staff_api[:last_booking_asset].as_s.should eq "asset-a1-01" + staff_api[:last_booking_asset_name].as_s.should eq "Window Desk" + ext = staff_api[:last_booking_extension].as_h + ext["assigned_asset_id"].as_s.should eq "asset-a1-01" + ext["name"].as_s.should eq "Window Desk" + ext["map_id"].as_s.should eq "desk.a" + + date = Time.local(Time::Location.load("America/New_York")).at_beginning_of_day + 2.days + exec( + :book_on, + building_id: "zone-building-b", + booking_type: "desk", + asset_id: "asset-b1-01", + level_id: "zone-level-b1", + date: date, + ).get + staff_api[:last_booking_asset].as_s.should eq "asset-b1-01" + staff_api[:last_booking_extension]["map_id"].as_s.should eq "desk.b1" + + # metadata desk ids can't be booked once desks are assets + expect_raises(Exception, /could not find a desk with id 'desk-b1-01'/) do + exec(:book_on, building_id: "zone-building-b", booking_type: "desk", asset_id: "desk-b1-01", level_id: "zone-level-b1", date: date).get + end + end end diff --git a/drivers/place/llm/workplace.cr b/drivers/place/llm/workplace.cr index 4d4b66ce0d..c3ddbbb86e 100644 --- a/drivers/place/llm/workplace.cr +++ b/drivers/place/llm/workplace.cr @@ -5,9 +5,11 @@ require "set" # metadata class require "placeos" require "./nearby" +require "../desk_source" class Place::Workplace < PlaceOS::Driver include Interface::ChatFunctions + include Place::DeskSource descriptive_name "LLM Workplace interface" generic_name :Workplace @@ -23,6 +25,9 @@ class Place::Workplace < PlaceOS::Driver # default desk booking window, local time (24 hour clock) booking_start_hour: 8, booking_end_hour: 20, + + # read desks from `_DESKS_` assets instead of the level `desks` metadata + use_desk_assets: false, }) @fallback_timezone : Time::Location = Time::Location::UTC @@ -90,20 +95,11 @@ class Place::Workplace < PlaceOS::Driver logger.debug { "getting list of levels" } l = all_levels l.each do |level| - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - if all_desks - desks = all_desks.as_a - level.bookable_desk_count = desks.size - features = Set(String).new - desks.each do |desk| - if feat = desk["features"]? - feat.as_a.each { |f| features << f.as_s.downcase } - end - end - level.desk_features = features.to_a unless features.empty? - else - level.bookable_desk_count = 0 - end + desks = level_desks(level.id) + level.bookable_desk_count = desks.size + features = Set(String).new + desks.each { |desk| desk.features.each { |f| features << f.downcase } } + level.desk_features = features.to_a unless features.empty? end l end @@ -131,7 +127,7 @@ class Place::Workplace < PlaceOS::Driver alias ChildMetadata = Array(NamedTuple(zone: PlaceZone, metadata: Metadata)) @[Description("returns the list of desks available for booking on the level and day specified. If the level has desk features then you can also filter by features.")] - def desks(level_id : String, day_offset : Int32 = 0, date : Time? = nil, feature : String? = nil) + def desks(level_id : String, day_offset : Int32 = 0, date : Time? = nil, feature : String? = nil) : Array(DeskDetails) logger.debug { "listing desks on level #{level_id}, day offset #{day_offset}" } # ensure the level id exists @@ -139,9 +135,9 @@ class Place::Workplace < PlaceOS::Driver raise "could not find level_id #{level_id} in the building. Make sure you've obtained the list of levels." unless level # get the list of desks for the level - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no bookable desks on this level, please try another." unless all_desks - desks = Array(Desk).from_json(all_desks.to_json).select!(&.bookable) + desks = level_desks(level.id) + raise "no bookable desks on this level, please try another." if desks.empty? + desks.select!(&.bookable?) # calculate the offset time if date @@ -164,14 +160,14 @@ class Place::Workplace < PlaceOS::Driver feature = feature.try(&.downcase) desks.reject! do |desk| next true if desk.id.in?(bookings) - next true if feature && !desk.features.map!(&.downcase).includes?(feature) + next true if feature && desk.features.none? { |f| f.downcase == feature } if !desk.groups.empty? (desk.groups & me.groups).empty? end end logger.debug { "found #{desks.size} available desks" } - desks + desks.map { |desk| DeskDetails.new(desk) } end struct Colleagues @@ -269,9 +265,9 @@ class Place::Workplace < PlaceOS::Driver # Desks are booked by id, but they are drawn on the level map under their # map_id, so we have to translate between the two. Returns desk_id => map_id protected def desk_map_ids(level_id : String) : Hash(String, String) - all_desks = staff_api.metadata(level_id, "desks").get.dig?("desks", "details") - raise "no desks configured on level #{level_id}" unless all_desks - Array(Desk).from_json(all_desks.to_json).to_h { |desk| {desk.id, desk.map_id} } + desks = level_desks(level_id) + raise "no desks configured on level #{level_id}" if desks.empty? + desks.to_h { |desk| {desk.id, desk.map_id} } end @[Description("given a desk_id this returns nearby desks in order of how close they are. You can provide a colleagues desk_id for instance and then pick the first id that matches one of the desks available for booking")] @@ -417,9 +413,8 @@ class Place::Workplace < PlaceOS::Driver desk = nil case booking_type when "desk" - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks - desks = Array(Desk).from_json(all_desks.to_json) + desks = level_desks(level.id) + raise "no desks found on level #{level_id}, ensure this id is correct" if desks.empty? desk = desks.find { |d| d.id == asset_id } raise "could not find a desk with id '#{asset_id}', maybe you passed the desk name?" unless desk @@ -481,9 +476,8 @@ class Place::Workplace < PlaceOS::Driver desk = nil case booking_type when "desk" - all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details") - raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks - desks = Array(Desk).from_json(all_desks.to_json) + desks = level_desks(level.id) + raise "no desks found on level #{level_id}, ensure this id is correct" if desks.empty? desk = desks.find { |d| d.id == asset_id } raise "could not find a desk with id '#{asset_id}', maybe you passed the desk name?" unless desk @@ -594,15 +588,25 @@ class Place::Workplace < PlaceOS::Driver # Support functions # ========================= - struct Desk + # the desk fields returned to the LLM + struct DeskDetails include JSON::Serializable getter id : String - getter name : String { id } - getter bookable : Bool { true } - getter groups : Array(String) = [] of String - getter features : Array(String) = [] of String - getter map_id : String { id } + getter name : String + getter bookable : Bool + getter groups : Array(String) + getter features : Array(String) + getter map_id : String + + def initialize(desk : Desk) + @id = desk.id + @name = desk.name + @bookable = desk.bookable? + @groups = desk.groups + @features = desk.features + @map_id = desk.map_id + end end protected def to_friendly_system(system : JSON::Any) : System? diff --git a/drivers/place/llm/workplace_spec.cr b/drivers/place/llm/workplace_spec.cr index ecbd024465..7cb810c72d 100644 --- a/drivers/place/llm/workplace_spec.cr +++ b/drivers/place/llm/workplace_spec.cr @@ -117,10 +117,59 @@ class StaffAPI < DriverSpecs::MockDriver {name: "Colleague Five", email: "c5@example.com", groups: ["staff"]}, ] + # the same desks as `_DESKS_` assets, with asset ids that differ from the + # metadata desk ids so specs can tell which source the driver used + ASSETS = { + "zone-level-1" => [ + [ + {id: "asset-desk-1", identifier: "Desk 1", map_id: "map-desk-1", zone_id: "zone-level-1", bookable: true, place_groups: [] of String, features: ["Window"], other_data: {floorsense_id: "fs-1"}}, + {id: "asset-L1-A", identifier: "Level 1 A", map_id: "desk.1", zone_id: "zone-level-1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L1-A"}}, + ], + [ + {id: "asset-L1-B", identifier: "Level 1 B", map_id: "desk.5", zone_id: "zone-level-1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L1-B"}}, + {id: "asset-L1-C", identifier: "Level 1 C", map_id: "desk.2", zone_id: "zone-level-1", bookable: false, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L1-C"}}, + {id: "asset-L1-D", identifier: "Level 1 D", map_id: "desk.3", zone_id: "zone-level-1", bookable: true, place_groups: ["exec"], features: [] of String, other_data: {floorsense_id: "fs-L1-D"}}, + {id: "asset-L1-E", identifier: "Level 1 E", map_id: "desk.4", zone_id: "zone-level-1", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L1-E"}}, + ], + ], + "zone-level-2" => [ + [ + {id: "asset-L2-C1", identifier: "Level 2 C1", map_id: "desk.c1", zone_id: "zone-level-2", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L2-C1"}}, + {id: "asset-L2-C2", identifier: "Level 2 C2", map_id: "desk.c2", zone_id: "zone-level-2", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L2-C2"}}, + {id: "asset-L2-C3", identifier: "Level 2 C3", map_id: "desk.c3", zone_id: "zone-level-2", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L2-C3"}}, + {id: "asset-L2-A", identifier: "Level 2 A", map_id: "desk.a", zone_id: "zone-level-2", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L2-A"}}, + {id: "asset-L2-B", identifier: "Level 2 B", map_id: "desk.b", zone_id: "zone-level-2", bookable: true, place_groups: [] of String, features: [] of String, other_data: {floorsense_id: "fs-L2-B"}}, + ], + ], + } + + # once migrated, desk bookings reference the Asset#id + @desk_assets : Bool = false + + def desk_assets(enabled : Bool) + @desk_assets = enabled + end + def on_load self[:bookings_created] = 0 end + def asset_types(category_id : String? = nil, zone_id : String? = nil, brand : String? = nil, model_number : String? = nil) + JSON.parse([{id: "type-desk", name: "_DESKS_"}, {id: "type-locker", name: "Locker"}].to_json) + end + + def assets(type_id : String? = nil, zone_id : String? = nil, order_id : String? = nil, barcode : String? = nil, serial_number : String? = nil, bookable : Bool? = nil, accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, limit : Int32 = 1000, next_page : String? = nil) + raise "unexpected asset type #{type_id}" unless type_id == "type-desk" + return JSON.parse("[]") unless pages = ASSETS[zone_id]? + page_index = next_page ? next_page.lchop("page-").to_i - 1 : 0 + page = JSON.parse(pages[page_index].to_json).as_a + # the next page link is on the first element of a page + if page_index + 1 < pages.size && (first = page.first?) + first.as_h["next_page"] = JSON::Any.new("page-#{page_index + 2}") + end + JSON::Any.new(page) + end + def zone(zone_id : String) case zone_id when "zone-org" then ORG_ZONE @@ -209,19 +258,23 @@ class StaffAPI < DriverSpecs::MockDriver # a colleague's desk for the day if desk = COLLEAGUE_DESKS[email]? asset_id, level_id = desk - bookings << colleague_booking(asset_id, level_id, email) + bookings << colleague_booking(desk_asset_id(asset_id), level_id, email) end elsif !zones.empty? # everything booked on the level, used to work out what is still free COLLEAGUE_DESKS.each do |colleague_email, (asset_id, level_id)| next unless zones.includes?(level_id) - bookings << colleague_booking(asset_id, level_id, colleague_email) + bookings << colleague_booking(desk_asset_id(asset_id), level_id, colleague_email) end end bookings end + protected def desk_asset_id(desk_id : String) : String + @desk_assets ? "asset-#{desk_id}" : desk_id + end + protected def colleague_booking(asset_id : String, level_id : String, email : String) : JSON::Any JSON.parse({ id: 1_i64, @@ -268,12 +321,13 @@ DriverSpecs.mock_driver "Place::Workplace" do StaffAPI: {StaffAPI}, }) - settings({ + base_settings = { time_zone: "Australia/Sydney", max_booking_days: 14, booking_start_hour: 9, booking_end_hour: 17, - }) + } + settings(base_settings) # let on_update settle sleep 200.milliseconds @@ -448,13 +502,13 @@ DriverSpecs.mock_driver "Place::Workplace" do result.map(&.["level_name"].as_s).should eq ["Level 3", "Level 2"] # who is sitting there, not just how many - result.first["colleagues"].as_a.map(&.["email"].as_s).sort.should eq [ + result.first["colleagues"].as_a.map(&.["email"].as_s).sort!.should eq [ "c1@example.com", "c2@example.com", "c3@example.com", ] result.last["colleagues"].as_a.map(&.["name"].as_s).should eq ["Colleague Four"] level_2 = result.first - level_2["groups"].as_a.map(&.as_s).sort.should eq ["design", "eng", "staff"] + level_2["groups"].as_a.map(&.as_s).sort!.should eq ["design", "eng", "staff"] # desk.a is the closest desk to one colleague but the furthest from the # other two, so the more central desk.b outranks it. Both are returned as @@ -480,4 +534,96 @@ DriverSpecs.mock_driver "Place::Workplace" do ).get end end + + it "lists the available desks from the level metadata" do + desks = exec(:desks, level_id: "zone-level-1", day_offset: 0).get.as_a + # L1-A is booked by a colleague + desks.map(&.["id"].as_s).should eq ["desk-1", "desk-2", "L1-B", "L1-C", "L1-D", "L1-E"] + desks.first.as_h.keys.sort!.should eq ["bookable", "features", "groups", "id", "map_id", "name"] + + levels = exec(:levels).get.as_a + level_1 = levels.find!(&.["id"].as_s.==("zone-level-1")) + level_1["bookable_desk_count"].as_i.should eq 7 + level_1["desk_features"].as_a.map(&.as_s).should eq ["window"] + end + + # =================================================== + # desk assets + # =================================================== + + it "lists desks from assets, excluding booked, unbookable and group restricted desks" do + system(:StaffAPI).as(StaffAPI).desk_assets(true) + settings(base_settings.merge({use_desk_assets: true})) + sleep 200.milliseconds + + # both pages of assets are read, the colleague's booking of asset-L1-A hides it + desks = exec(:desks, level_id: "zone-level-1", day_offset: 0).get.as_a + desks.map(&.["id"].as_s).should eq ["asset-desk-1", "asset-L1-B", "asset-L1-E"] + + # the LLM gets the same slim desk shape as before, no asset other_data + desk = desks.first.as_h + desk.keys.sort!.should eq ["bookable", "features", "groups", "id", "map_id", "name"] + desk["name"].as_s.should eq "Desk 1" + desk["map_id"].as_s.should eq "map-desk-1" + + exec(:desks, level_id: "zone-level-1", day_offset: 0, feature: "window").get.as_a + .map(&.["id"].as_s).should eq ["asset-desk-1"] + + levels = exec(:levels).get.as_a + level_1 = levels.find!(&.["id"].as_s.==("zone-level-1")) + level_1["bookable_desk_count"].as_i.should eq 6 + level_1["desk_features"].as_a.map(&.as_s).should eq ["window"] + end + + it "maps asset ids to their map_id for proximity" do + nearby = exec(:nearby_desks, desk_level_id: "zone-level-1", desk_id: "asset-L1-A").get + Array(String).from_json(nearby.to_json).should eq ["asset-L1-B", "asset-L1-C", "asset-L1-D", "asset-L1-E"] + + # the metadata desk id is no longer known + expect_raises(Exception, /could not find a desk with id 'L1-A'/) do + exec(:nearby_desks, desk_level_id: "zone-level-1", desk_id: "L1-A").get + end + + result = exec(:desks_near_colleagues, day_offset: 0).get.as_a + result.map(&.["level_id"].as_s).should eq ["zone-level-2", "zone-level-1"] + result.first["nearby_desks"].as_a.map(&.as_s).should eq ["asset-L2-B", "asset-L2-A"] + # asset-L1-C isn't bookable and asset-L1-D is limited to another group + result.last["nearby_desks"].as_a.map(&.as_s).should eq ["asset-L1-B", "asset-L1-E"] + end + + it "books desks against the Asset#id" do + exec( + :book_relative, + booking_type: "desk", + asset_id: "asset-desk-1", + level_id: "zone-level-1", + day_offset: 1, + number_of_days: 1, + ).get + + staff_api = system(:StaffAPI) + staff_api[:last_booking_asset].as_s.should eq "asset-desk-1" + staff_api[:last_booking_asset_name].as_s.should eq "Desk 1" + ext = staff_api[:last_booking_extension].as_h + ext["assigned_asset_id"].as_s.should eq "asset-desk-1" + ext["name"].as_s.should eq "Desk 1" + ext["map_id"].as_s.should eq "map-desk-1" + + date = Time.local(perth).at_beginning_of_day + 2.days + exec( + :book_on, + booking_type: "desk", + asset_id: "asset-L1-B", + level_id: "zone-level-1", + date: date, + number_of_days: 1, + ).get + staff_api[:last_booking_asset].as_s.should eq "asset-L1-B" + staff_api[:last_booking_extension]["map_id"].as_s.should eq "desk.5" + + # metadata desk ids can't be booked once desks are assets + expect_raises(Exception, /could not find a desk with id 'desk-1'/) do + exec(:book_on, booking_type: "desk", asset_id: "desk-1", level_id: "zone-level-1", date: date, number_of_days: 1).get + end + end end diff --git a/drivers/place/staff_api.cr b/drivers/place/staff_api.cr index 5a0d191a34..cd1888515d 100644 --- a/drivers/place/staff_api.cr +++ b/drivers/place/staff_api.cr @@ -1323,21 +1323,47 @@ class Place::StaffAPI < PlaceOS::Driver accessible : Bool? = nil, features : Array(String)? = nil, zones : Array(String)? = nil, + limit : Int32 = 1000, + next_page : String? = nil, ) - params = URI::Params.new - params["type_id"] = type_id.to_s if type_id.presence - params["zone_id"] = zone_id.to_s if zone_id.presence - params["order_id"] = order_id.to_s if order_id.presence - params["barcode"] = barcode.to_s if barcode.presence - params["serial_number"] = serial_number.to_s if serial_number.presence - params["bookable"] = bookable.to_s unless bookable.nil? - params["accessible"] = accessible.to_s unless accessible.nil? - params["features"] = features.join(',') if features && !features.empty? - params["zones"] = zones.join(',') if zones && !zones.empty? - params["limit"] = 1000.to_s - logger.debug { "getting assets (#{params})" } - response = get("/api/engine/v2/assets", params, headers: authentication) + response = if next_page + logger.debug { "getting assets page (#{next_page})" } + get(next_page, headers: authentication) + else + params = URI::Params.new + params["type_id"] = type_id.to_s if type_id.presence + params["zone_id"] = zone_id.to_s if zone_id.presence + params["order_id"] = order_id.to_s if order_id.presence + params["barcode"] = barcode.to_s if barcode.presence + params["serial_number"] = serial_number.to_s if serial_number.presence + params["bookable"] = bookable.to_s unless bookable.nil? + params["accessible"] = accessible.to_s unless accessible.nil? + params["features"] = features.join(',') if features && !features.empty? + params["zones"] = zones.join(',') if zones && !zones.empty? + params["limit"] = limit.to_s + logger.debug { "getting assets (#{params})" } + get("/api/engine/v2/assets", params, headers: authentication) + end raise "issue getting assets: #{response.status_code}" unless response.success? + + # like `get_members`, the first result holds a link to the next page of results + next_link = LinkHeader.new(response)["next"]? + return ExecResponse.new(response.body) unless next_link + + assets = JSON.parse(response.body).as_a + if first = assets.first?.try(&.as_h?) + first["next_page"] = JSON::Any.new(next_link) + end + ExecResponse.new(assets.to_json) + end + + @[Security(Level::Support)] + def update_asset(id : String, asset : JSON::Any) + logger.debug { "updating asset #{id}" } + response = patch("/api/engine/v2/assets/#{id}", body: asset.to_json, headers: authentication(HTTP::Headers{ + "Content-Type" => "application/json", + })) + raise "failed to update asset #{id}: #{response.status_code}" unless response.success? ExecResponse.new(response.body) end diff --git a/drivers/place/staff_api_spec.cr b/drivers/place/staff_api_spec.cr index 42958ac1f9..7c702424d1 100644 --- a/drivers/place/staff_api_spec.cr +++ b/drivers/place/staff_api_spec.cr @@ -43,7 +43,7 @@ DriverSpecs.mock_driver "Place::StaffAPI" do "approved": false }]))) - sleep 1 + sleep 1.second invites_resp = exec(:get_survey_invites, sent: false) expect_http_request do |request, response| @@ -90,7 +90,7 @@ DriverSpecs.mock_driver "Place::StaffAPI" do "sent": false }]))) - sleep 1 + sleep 1.second sync_resp = exec(:add_remove_ad_groups, "user-12345", ["ad-group-1", "ad-group-2"]) expect_http_request do |request, response| @@ -119,7 +119,7 @@ DriverSpecs.mock_driver "Place::StaffAPI" do "auto_assigned": "ad-group-1" }]))) - sleep 1 + sleep 1.second failed_sync = exec(:add_remove_ad_groups, "user-missing", [] of String) expect_http_request do |_request, response| @@ -127,4 +127,49 @@ DriverSpecs.mock_driver "Place::StaffAPI" do end expect_raises(PlaceOS::Driver::RemoteException) { failed_sync.get } + + # assets are paginated, the first result links to the next page + sleep 1.second + assets = exec(:assets, type_id: "type-desk", zone_id: "zone-level", limit: 1) + + expect_http_request do |request, response| + request.path.should eq "/api/engine/v2/assets" + request.query_params["type_id"].should eq "type-desk" + request.query_params["zone_id"].should eq "zone-level" + request.query_params["limit"].should eq "1" + response.status_code = 200 + response.headers["Link"] = %(; rel="next") + response << %([{"id": "asset-1", "identifier": "Desk 1"}]) + end + + page = assets.get.as_a + page.size.should eq 1 + next_page = page.first["next_page"].as_s + next_page.should eq "/api/engine/v2/assets?type_id=type-desk&zone_id=zone-level&limit=1&offset=1" + + sleep 1.second + assets = exec(:assets, next_page: next_page) + + expect_http_request do |request, response| + request.query_params["offset"].should eq "1" + response.status_code = 200 + response << %([{"id": "asset-2", "identifier": "Desk 2"}]) + end + + # last page has no next link + assets.get.should eq JSON.parse(%([{"id": "asset-2", "identifier": "Desk 2"}])) + + sleep 1.second + updated = exec(:update_asset, "asset-1", {assigned_to: "steve@place.tech"}) + + expect_http_request do |request, response| + request.method.should eq "PATCH" + request.path.should eq "/api/engine/v2/assets/asset-1" + request.headers["Content-Type"]?.should eq "application/json" + JSON.parse(request.body.not_nil!)["assigned_to"].should eq "steve@place.tech" + response.status_code = 200 + response << %({"id": "asset-1", "assigned_to": "steve@place.tech"}) + end + + updated.get["assigned_to"].should eq "steve@place.tech" end