Skip to content
Open
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
44 changes: 43 additions & 1 deletion OPENAPI_DOC.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23394,7 +23394,7 @@ paths:
- name: parent_id
in: query
description: only return zones who have this zone as a parent (supports comma-separated
list)
list). Use 'root' to get zones with no parent
example: zone-1234,zone-5678
schema:
type: array
Expand All @@ -23410,6 +23410,12 @@ paths:
items:
type: string
nullable: true
- name: include_children_count
in: query
description: include children_count for each zone (useful for tree views)
example: "true"
schema:
type: boolean
- name: q
in: query
description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html)
Expand Down Expand Up @@ -26295,6 +26301,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -26778,6 +26788,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -27210,6 +27224,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -27792,6 +27810,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -28547,6 +28569,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -29679,6 +29705,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -30111,6 +30141,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -32492,6 +32526,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down Expand Up @@ -32924,6 +32962,10 @@ components:
type: string
nullable: true
nullable: true
children_count:
type: integer
format: Int32
nullable: true
id:
type: string
nullable: true
Expand Down
2 changes: 1 addition & 1 deletion shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ shards:

neuroplastic:
git: https://github.com/spider-gazelle/neuroplastic.git
version: 1.14.1
version: 1.14.2

office365:
git: https://github.com/placeos/office365.git
Expand Down
2 changes: 1 addition & 1 deletion spec/controllers/signage_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ module PlaceOS::Api
json["playlist_config"][playlist_id][1].should eq [] of String

# skip forward a moment to avoid a 304
sleep 1
sleep 1.seconds

# we should now approve the playlist
approved = client.post(
Expand Down
106 changes: 106 additions & 0 deletions spec/controllers/zones_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,112 @@ module PlaceOS::Api
child2.destroy
child3.destroy
end

it "gets root zones with parent_id=root" do
root1 = Model::Generator.zone.save!
root2 = Model::Generator.zone.save!

child = Model::Generator.zone
child.parent_id = root1.id
child.save!

sleep 1.second
refresh_elastic(Model::Zone.table_name)

params = HTTP::Params.encode({"parent_id" => "root"})
path = "#{Zones.base_route}?#{params}"
result = client.get(path, headers: Spec::Authentication.headers)

result.success?.should be_true
zones = Array(Hash(String, JSON::Any)).from_json(result.body)
zone_ids = zones.map(&.["id"].as_s)

zone_ids.should contain(root1.id)
zone_ids.should contain(root2.id)
zone_ids.should_not contain(child.id)

root1.destroy
root2.destroy
child.destroy
end

it "includes children_count when requested" do
parent = Model::Generator.zone.save!

child1 = Model::Generator.zone
child1.parent_id = parent.id
child1.save!

child2 = Model::Generator.zone
child2.parent_id = parent.id
child2.save!

grandchild = Model::Generator.zone
grandchild.parent_id = child1.id
grandchild.save!

sleep 1.second
refresh_elastic(Model::Zone.table_name)

params = HTTP::Params.encode({"parent_id" => parent.id.as(String), "include_children_count" => "true"})
path = "#{Zones.base_route}?#{params}"
result = client.get(path, headers: Spec::Authentication.headers)

result.success?.should be_true
zones = Array(Hash(String, JSON::Any)).from_json(result.body)

child1_data = zones.find { |z| z["id"].as_s == child1.id }
child2_data = zones.find { |z| z["id"].as_s == child2.id }

child1_data.should_not be_nil
child2_data.should_not be_nil

child1_data.not_nil!["children_count"].as_i.should eq 1
child2_data.not_nil!["children_count"].as_i.should eq 0

parent.destroy
child1.destroy
child2.destroy
grandchild.destroy
end

it "preserves include_children_count across paginated requests" do
parent = Model::Generator.zone.save!

children = Array(Model::Zone).new
5.times do
child = Model::Generator.zone
child.parent_id = parent.id
child.save!
children << child
end

sleep 1.second
refresh_elastic(Model::Zone.table_name)

params = HTTP::Params.encode({
"parent_id" => parent.id.as(String),
"include_children_count" => "true",
"limit" => "2",
})
path = "#{Zones.base_route}?#{params}"
result = client.get(path, headers: Spec::Authentication.headers)

result.success?.should be_true

link_header = result.headers["Link"]?
link_header.should_not be_nil
link_header.not_nil!.should contain("include_children_count=true")

zones = Array(Hash(String, JSON::Any)).from_json(result.body)
zones.size.should eq 2
zones.each do |zone|
zone["children_count"]?.should_not be_nil
end

parent.destroy
children.each(&.destroy)
end
end

describe "tags", tags: "search" do
Expand Down
83 changes: 76 additions & 7 deletions src/placeos-rest-api/controllers/zones.cr
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module PlaceOS::Api
class ::PlaceOS::Model::Zone
@[JSON::Field(key: "trigger_data")]
property trigger_data_details : Array(::PlaceOS::Model::Trigger)? = nil
property children_count : Int32? = nil
end

###############################################################################################
Expand Down Expand Up @@ -96,21 +97,48 @@ module PlaceOS::Api
# list the configured zones
@[AC::Route::GET("/", converters: {tags: ConvertStringArray, parent_id: ConvertStringArray})]
def index(
@[AC::Param::Info(description: "only return zones who have this zone as a parent (supports comma-separated list)", example: "zone-1234,zone-5678")]
@[AC::Param::Info(description: "only return zones who have this zone as a parent (supports comma-separated list). Use 'root' to get zones with no parent", example: "zone-1234,zone-5678")]
parent_id : Array(String)? = nil,
@[AC::Param::Info(description: "return zones with particular tags", example: "building,level")]
tags : Array(String)? = nil,
@[AC::Param::Info(description: "include children_count for each zone (useful for tree views)", example: "true")]
include_children_count : Bool = false,
) : Array(::PlaceOS::Model::Zone)
elastic = ::PlaceOS::Model::Zone.elastic
query = elastic.query(search_params)
query.sort(NAME_SORT_ASC)

# Limit results to the children of these parents (OR logic)
# Handle tree view queries
if parent_id
query.should({
"parent_id" => parent_id,
})
query.minimum_should_match(1)
# Special case: "root" means zones with no parent
if parent_id.includes?("root")
# Remove "root" and add any other parent_ids if present
other_parents = parent_id.reject("root")
if !other_parents.empty?
# Mix of root and specific parents: use OR logic
# Build array with nil and other parent IDs
parent_values = Array(String?).new
parent_values << nil
other_parents.each { |p| parent_values << p }
query.should({
"parent_id" => parent_values,
})
query.minimum_should_match(1)
else
# Only root zones: filter for missing parent_id
parent_values = Array(String?).new
parent_values << nil
query.filter({
"parent_id" => parent_values,
})
end
else
# Limit results to the children of these parents (OR logic)
query.should({
"parent_id" => parent_id,
})
query.minimum_should_match(1)
end
end

# Limit results to zones containing the passed list of tags
Expand All @@ -123,7 +151,48 @@ module PlaceOS::Api
query.search_field "name"
end

paginate_results(elastic, query)
results = paginate_results(elastic, query)

# Add children count if requested
if include_children_count
results = add_children_counts_from_es(results, elastic, query)
end

results
end

# Helper to add children counts to zones using Elasticsearch aggregations
private def add_children_counts_from_es(zones : Array(::PlaceOS::Model::Zone), elastic, base_query)
return zones if zones.empty?

zone_ids = zones.map(&.id.as(String))

# Query all zones whose parent_id matches any of our zone_ids, then aggregate by parent_id
agg_query = ::PlaceOS::Model::Zone.elastic.query({} of String => String)
agg_query.should({"parent_id" => zone_ids})
agg_query.minimum_should_match(1)
agg_query.terms("children_by_parent", "parent_id", size: zone_ids.size)

# Execute query with aggregation
data = ::PlaceOS::Model::Zone.elastic.search(agg_query)

# Extract aggregation results
count_map = Hash(String, Int32).new
if aggs = data[:aggregations]?
if buckets = aggs.dig?("children_by_parent", "buckets").try(&.as_a?)
buckets.each do |bucket|
parent_id = bucket["key"].as_s
count_map[parent_id] = bucket["doc_count"].as_i
end
end
end

# Assign counts to zones
zones.each do |zone|
zone.children_count = count_map[zone.id.as(String)]? || 0
end

zones
end

# returns unique zone tags
Expand Down
Loading