Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 99 additions & 12 deletions drivers/place/booking_approver.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@ require "./booking_model"
class Place::BookingApprover < PlaceOS::Driver
descriptive_name "Booking Auto Approver"
generic_name :BookingApprover
description %(Automatically approves all PlaceOS bookings)
description %(Automatically approves all PlaceOS bookings, optionally rejecting bookings for assets not defined in zone metadata)

accessor staff_api : StaffAPI_1

default_settings({
approve_booking_types: ["desk"],
approve_zones: ["zone-12345"],

# reject bookings for assets that are not defined in the building level zone metadata
reject_undefined_assets: false,
bookable_assets_cache_cron: "0 0 * * *",
# time_zone: "Australia/Sydney",

# booking_type => zone metadata key
asset_metadata_keys: {
"desk" => "desks",
"parking" => "parking-spaces",
},
})

def on_load
Expand All @@ -21,16 +32,40 @@ class Place::BookingApprover < PlaceOS::Driver
on_update
end

@bookings_approved : Int32 = 0u32
@bookings_approved : Int32 = 0
@bookings_rejected : Int32 = 0
@approve_zones : Array(String) = [] of String
@approve_booking_types : Array(String) = [] of String

@reject_undefined_assets : Bool = false
@asset_metadata_keys : Hash(String, String) = {} of String => String

# booking_type => bookable asset ids
@bookable_assets : Hash(String, Set(String)) = {} of String => Set(String)

def on_update
@approve_zones = setting?(Array(String), :approve_zones) || [] of String
@approve_booking_types = setting?(Array(String), :approve_booking_types) || [] of String

@reject_undefined_assets = setting?(Bool, :reject_undefined_assets) || false
@asset_metadata_keys = setting?(Hash(String, String), :asset_metadata_keys) || {
"desk" => "desks",
"parking" => "parking-spaces",
}

schedule.clear
schedule.every(10.minutes) { approve_missed }

if @reject_undefined_assets
cron = setting?(String, :bookable_assets_cache_cron).presence || "0 0 * * *"
time_zone = setting?(String, :time_zone).presence
location = time_zone ? Time::Location.load(time_zone) : Time::Location.local
schedule.cron(cron, location) { cache_bookable_assets }
schedule.in(1.second) { cache_bookable_assets }
else
@bookable_assets = {} of String => Set(String)
self[:bookable_assets] = nil
end
end

# Finds the building ID for the current location services object
Expand All @@ -42,6 +77,37 @@ class Place::BookingApprover < PlaceOS::Driver
nil
end

# Caches the asset ids defined in the metadata of the levels of every building
def cache_bookable_assets
building_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
assets = {} of String => Set(String)

@approve_booking_types.each do |booking_type|
metadata_key = @asset_metadata_keys[booking_type]? || "#{booking_type}s"
ids = Set(String).new

building_ids.each do |building_id|
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|
if asset_id = asset["id"]?.try(&.as_s?).presence
ids << asset_id
end
end
end
end

assets[booking_type] = ids
end

@bookable_assets = assets
self[:bookable_assets] = assets.transform_values(&.size)
"cached #{assets.sum(&.[1].size)} bookable assets"
rescue error
logger.warn(exception: error) { "failed to cache bookable assets, keeping existing cache" }
raise error
end

private def approve_booking(booking : Booking)
return if booking.action != "create"

Expand All @@ -59,17 +125,33 @@ class Place::BookingApprover < PlaceOS::Driver
end
end

if !booking.approved
# approving booking instances isn't a thing
staff_api.approve(booking.id).get
logger.debug { "Approved Booking #{booking.id}" }
@bookings_approved += 1
true
else
false
return false if booking.approved || booking.rejected

if @reject_undefined_assets
bookable = @bookable_assets[booking.booking_type]?
if bookable.nil?
# don't reject bookings because the cache failed to load, approve_missed will retry
logger.warn { "bookable assets for #{booking.booking_type} not cached, ignoring booking #{booking.id}" }
return false
end

asset_ids = booking.asset_ids.empty? ? [booking.asset_id] : booking.asset_ids
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" }
@bookings_rejected += 1
return false
end
end

# approving booking instances isn't a thing
staff_api.approve(booking.id).get
logger.debug { "Approved Booking #{booking.id}" }
@bookings_approved += 1
true
rescue error
logger.warn(exception: error) { "failed to approve booking #{booking.id}" }
logger.warn(exception: error) { "failed to process booking #{booking.id}" }
end

def approve_missed
Expand All @@ -79,6 +161,7 @@ class Place::BookingApprover < PlaceOS::Driver
type: booking_type,
zones: [get_building_id],
approved: false,
rejected: false,
period_end: 8.weeks.from_now.to_unix
).get_json

Expand All @@ -91,6 +174,10 @@ class Place::BookingApprover < PlaceOS::Driver
end

def status
{bookings_approved: @bookings_approved}
{
bookings_approved: @bookings_approved,
bookings_rejected: @bookings_rejected,
bookable_assets: @bookable_assets.transform_values(&.size),
}
end
end
229 changes: 229 additions & 0 deletions drivers/place/booking_approver_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
require "placeos-driver/spec"

# publishes a booking created event, assigning the next booking id to `id_var`
macro create_booking(id_var, asset_ids, booking_type, approved)
begin
{{id_var}} += 1
now = Time.utc.to_unix
publish("staff/booking/changed", {
action: "create",
id: {{id_var}},
booking_type: {{booking_type}},
booking_start: now + 3600,
booking_end: now + 7200,
asset_id: {{asset_ids}}.first,
asset_ids: {{asset_ids}},
user_id: "user-1",
user_email: "user@org.com",
user_name: "User One",
zones: ["zone-building", "zone-level-1"],
booked_by_name: "User One",
booked_by_email: "user@org.com",
approved: {{approved}},
rejected: false,
}.to_json)
sleep 200.milliseconds
{{id_var}}.to_i64
end
end

DriverSpecs.mock_driver "Place::BookingApprover" do
system({
StaffAPI: {StaffAPIMock},
})

settings({
approve_booking_types: ["desk"],
approve_zones: ["zone-building"],
})

staff_api = system(:StaffAPI).as(StaffAPIMock)
booking_id = 0

# ==============
# mode disabled
# ==============
id = create_booking(booking_id, ["desk-undefined"], "desk", false)
staff_api.approved.should eq [id]
staff_api.rejected.should be_empty
exec(:status).get.not_nil!["bookable_assets"].should eq({} of String => Int32)

# ==============
# mode enabled
# ==============
staff_api.reset
settings({
approve_booking_types: ["desk", "parking"],
approve_zones: ["zone-building"],
reject_undefined_assets: true,
})
sleep 2.seconds

# assets from every level of every building are cached, keyed by booking type
exec(:status).get.not_nil!["bookable_assets"].should eq({"desk" => 3, "parking" => 1})
staff_api.metadata_requests.sort.should eq [
{"zone-building", "desks"},
{"zone-building", "parking-spaces"},
{"zone-building-2", "desks"},
{"zone-building-2", "parking-spaces"},
]

defined_desk = create_booking(booking_id, ["desk-1"], "desk", false)
other_building_desk = create_booking(booking_id, ["desk-3"], "desk", false)
undefined_desk = create_booking(booking_id, ["desk-undefined"], "desk", false)
partially_defined = create_booking(booking_id, ["desk-1", "desk-undefined"], "desk", false)
defined_parking = create_booking(booking_id, ["park-1"], "parking", false)
desk_as_parking = create_booking(booking_id, ["desk-1"], "parking", false)
already_approved = create_booking(booking_id, ["desk-undefined"], "desk", true)
not_monitored = create_booking(booking_id, ["locker-undefined"], "locker", false)

staff_api.approved.should eq [defined_desk, other_building_desk, defined_parking]
staff_api.rejected.should eq [undefined_desk, partially_defined, desk_as_parking]
staff_api.reject_sources.uniq.should eq ["booking_approval_driver"]
staff_api.approved.should_not contain(already_approved)
staff_api.rejected.should_not contain(not_monitored)

exec(:status).get.not_nil!["bookings_rejected"].should eq 3

# ===========================================
# a failed refresh keeps the existing cache
# ===========================================
staff_api.reset
staff_api.fail_metadata(true)
expect_raises(Exception) { exec(:cache_bookable_assets).get }
exec(:status).get.not_nil!["bookable_assets"].should eq({"desk" => 3, "parking" => 1})

create_booking(booking_id, ["desk-2"], "desk", false)
create_booking(booking_id, ["desk-undefined"], "desk", false)
staff_api.approved.size.should eq 1
staff_api.rejected.size.should eq 1

# ==================================================================
# bookings are never rejected when the cache has not loaded yet
# ==================================================================
staff_api.reset
settings({
approve_booking_types: ["desk"],
approve_zones: ["zone-building"],
reject_undefined_assets: false,
})
sleep 200.milliseconds
staff_api.fail_metadata(true)
settings({
approve_booking_types: ["desk"],
approve_zones: ["zone-building"],
reject_undefined_assets: true,
})
sleep 2.seconds
exec(:status).get.not_nil!["bookable_assets"].should eq({} of String => Int32)

create_booking(booking_id, ["desk-undefined"], "desk", false)
create_booking(booking_id, ["desk-1"], "desk", false)
staff_api.approved.should be_empty
staff_api.rejected.should be_empty

# the next refresh succeeds and bookings are processed again
staff_api.fail_metadata(false)
exec(:cache_bookable_assets).get
exec(:status).get.not_nil!["bookable_assets"].should eq({"desk" => 3})

# ==========================================
# empty metadata rejects all bookings of type
# ==========================================
staff_api.reset
settings({
approve_booking_types: ["desk"],
approve_zones: ["zone-building"],
reject_undefined_assets: true,
asset_metadata_keys: {"desk" => "missing-key"},
})
sleep 2.seconds
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]
end

# :nodoc:
class StaffAPIMock < DriverSpecs::MockDriver
LEVEL_METADATA = JSON.parse(%({
"zone-building": [
{
"zone": {"id": "zone-level-1"},
"metadata": {
"desks": {
"name": "desks",
"details": [{"id": "desk-1", "name": "Desk 1"}, {"id": "desk-2", "name": "Desk 2"}]
},
"parking-spaces": {
"name": "parking-spaces",
"details": [{"id": "park-1", "name": "Park 1"}]
}
}
},
{"zone": {"id": "zone-level-2"}, "metadata": {}}
],
"zone-building-2": [
{
"zone": {"id": "zone-level-3"},
"metadata": {
"desks": {
"name": "desks",
"details": [{"id": "desk-3", "name": "Desk 3"}, {"name": "Desk missing ID"}]
}
}
}
]
}))

getter approved = [] of Int64
getter rejected = [] of Int64
getter reject_sources = [] of String?
getter metadata_requests = [] of Tuple(String, String)
@fail_metadata : Bool = false

def fail_metadata(state : Bool)
@fail_metadata = state
end

def reset
@approved.clear
@rejected.clear
@reject_sources.clear
@metadata_requests.clear
@fail_metadata = false
end

def zones(tags : String)
[{id: "zone-building"}, {id: "zone-building-2"}]
end

def metadata_children(id : String, key : String? = nil)
raise "metadata unavailable" if @fail_metadata
@metadata_requests << {id, key.to_s}
LEVEL_METADATA[id].as_a.map do |level|
metadata = level["metadata"].as_h.select { |name, _| name == key }
{zone: level["zone"], metadata: metadata}
end
end

def approve(booking_id : Int64, instance : Int64? = nil)
@approved << booking_id
true
end

def reject(booking_id : Int64, utm_source : String? = nil, instance : Int64? = nil)
@rejected << booking_id
@reject_sources << utm_source
true
end

def query_bookings(
type : String? = nil,
zones : Array(String?) = [] of String?,
approved : Bool? = nil,
rejected : Bool? = nil,
period_end : Int64? = nil,
)
[] of Nil
end
end
Loading