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