diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index f075145d..2eb45ecb 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -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 @@ -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) @@ -26295,6 +26301,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -26778,6 +26788,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -27210,6 +27224,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -27792,6 +27810,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -28547,6 +28569,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -29679,6 +29705,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -30111,6 +30141,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -32492,6 +32526,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true @@ -32924,6 +32962,10 @@ components: type: string nullable: true nullable: true + children_count: + type: integer + format: Int32 + nullable: true id: type: string nullable: true diff --git a/shard.lock b/shard.lock index 16e25eb0..01211dc6 100644 --- a/shard.lock +++ b/shard.lock @@ -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 diff --git a/spec/controllers/signage_spec.cr b/spec/controllers/signage_spec.cr index 6297d16d..59b6afbf 100644 --- a/spec/controllers/signage_spec.cr +++ b/spec/controllers/signage_spec.cr @@ -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( diff --git a/spec/controllers/zones_spec.cr b/spec/controllers/zones_spec.cr index 1e53f8d4..4de0108e 100644 --- a/spec/controllers/zones_spec.cr +++ b/spec/controllers/zones_spec.cr @@ -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 diff --git a/src/placeos-rest-api/controllers/zones.cr b/src/placeos-rest-api/controllers/zones.cr index db5e53cc..647a6890 100644 --- a/src/placeos-rest-api/controllers/zones.cr +++ b/src/placeos-rest-api/controllers/zones.cr @@ -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 ############################################################################################### @@ -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 @@ -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