Skip to content

Commit e049b84

Browse files
authored
feat(zones): [PPT-2429] add tree view API with parent filtering and children counts (#429)
1 parent bbd1114 commit e049b84

5 files changed

Lines changed: 227 additions & 10 deletions

File tree

OPENAPI_DOC.yml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23394,7 +23394,7 @@ paths:
2339423394
- name: parent_id
2339523395
in: query
2339623396
description: only return zones who have this zone as a parent (supports comma-separated
23397-
list)
23397+
list). Use 'root' to get zones with no parent
2339823398
example: zone-1234,zone-5678
2339923399
schema:
2340023400
type: array
@@ -23410,6 +23410,12 @@ paths:
2341023410
items:
2341123411
type: string
2341223412
nullable: true
23413+
- name: include_children_count
23414+
in: query
23415+
description: include children_count for each zone (useful for tree views)
23416+
example: "true"
23417+
schema:
23418+
type: boolean
2341323419
- name: q
2341423420
in: query
2341523421
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:
2629526301
type: string
2629626302
nullable: true
2629726303
nullable: true
26304+
children_count:
26305+
type: integer
26306+
format: Int32
26307+
nullable: true
2629826308
id:
2629926309
type: string
2630026310
nullable: true
@@ -26778,6 +26788,10 @@ components:
2677826788
type: string
2677926789
nullable: true
2678026790
nullable: true
26791+
children_count:
26792+
type: integer
26793+
format: Int32
26794+
nullable: true
2678126795
id:
2678226796
type: string
2678326797
nullable: true
@@ -27210,6 +27224,10 @@ components:
2721027224
type: string
2721127225
nullable: true
2721227226
nullable: true
27227+
children_count:
27228+
type: integer
27229+
format: Int32
27230+
nullable: true
2721327231
id:
2721427232
type: string
2721527233
nullable: true
@@ -27792,6 +27810,10 @@ components:
2779227810
type: string
2779327811
nullable: true
2779427812
nullable: true
27813+
children_count:
27814+
type: integer
27815+
format: Int32
27816+
nullable: true
2779527817
id:
2779627818
type: string
2779727819
nullable: true
@@ -28547,6 +28569,10 @@ components:
2854728569
type: string
2854828570
nullable: true
2854928571
nullable: true
28572+
children_count:
28573+
type: integer
28574+
format: Int32
28575+
nullable: true
2855028576
id:
2855128577
type: string
2855228578
nullable: true
@@ -29679,6 +29705,10 @@ components:
2967929705
type: string
2968029706
nullable: true
2968129707
nullable: true
29708+
children_count:
29709+
type: integer
29710+
format: Int32
29711+
nullable: true
2968229712
id:
2968329713
type: string
2968429714
nullable: true
@@ -30111,6 +30141,10 @@ components:
3011130141
type: string
3011230142
nullable: true
3011330143
nullable: true
30144+
children_count:
30145+
type: integer
30146+
format: Int32
30147+
nullable: true
3011430148
id:
3011530149
type: string
3011630150
nullable: true
@@ -32492,6 +32526,10 @@ components:
3249232526
type: string
3249332527
nullable: true
3249432528
nullable: true
32529+
children_count:
32530+
type: integer
32531+
format: Int32
32532+
nullable: true
3249532533
id:
3249632534
type: string
3249732535
nullable: true
@@ -32924,6 +32962,10 @@ components:
3292432962
type: string
3292532963
nullable: true
3292632964
nullable: true
32965+
children_count:
32966+
type: integer
32967+
format: Int32
32968+
nullable: true
3292732969
id:
3292832970
type: string
3292932971
nullable: true

shard.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ shards:
155155

156156
neuroplastic:
157157
git: https://github.com/spider-gazelle/neuroplastic.git
158-
version: 1.14.1
158+
version: 1.14.2
159159

160160
office365:
161161
git: https://github.com/placeos/office365.git

spec/controllers/signage_spec.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ module PlaceOS::Api
144144
json["playlist_config"][playlist_id][1].should eq [] of String
145145

146146
# skip forward a moment to avoid a 304
147-
sleep 1
147+
sleep 1.seconds
148148

149149
# we should now approve the playlist
150150
approved = client.post(

spec/controllers/zones_spec.cr

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,112 @@ module PlaceOS::Api
7474
child2.destroy
7575
child3.destroy
7676
end
77+
78+
it "gets root zones with parent_id=root" do
79+
root1 = Model::Generator.zone.save!
80+
root2 = Model::Generator.zone.save!
81+
82+
child = Model::Generator.zone
83+
child.parent_id = root1.id
84+
child.save!
85+
86+
sleep 1.second
87+
refresh_elastic(Model::Zone.table_name)
88+
89+
params = HTTP::Params.encode({"parent_id" => "root"})
90+
path = "#{Zones.base_route}?#{params}"
91+
result = client.get(path, headers: Spec::Authentication.headers)
92+
93+
result.success?.should be_true
94+
zones = Array(Hash(String, JSON::Any)).from_json(result.body)
95+
zone_ids = zones.map(&.["id"].as_s)
96+
97+
zone_ids.should contain(root1.id)
98+
zone_ids.should contain(root2.id)
99+
zone_ids.should_not contain(child.id)
100+
101+
root1.destroy
102+
root2.destroy
103+
child.destroy
104+
end
105+
106+
it "includes children_count when requested" do
107+
parent = Model::Generator.zone.save!
108+
109+
child1 = Model::Generator.zone
110+
child1.parent_id = parent.id
111+
child1.save!
112+
113+
child2 = Model::Generator.zone
114+
child2.parent_id = parent.id
115+
child2.save!
116+
117+
grandchild = Model::Generator.zone
118+
grandchild.parent_id = child1.id
119+
grandchild.save!
120+
121+
sleep 1.second
122+
refresh_elastic(Model::Zone.table_name)
123+
124+
params = HTTP::Params.encode({"parent_id" => parent.id.as(String), "include_children_count" => "true"})
125+
path = "#{Zones.base_route}?#{params}"
126+
result = client.get(path, headers: Spec::Authentication.headers)
127+
128+
result.success?.should be_true
129+
zones = Array(Hash(String, JSON::Any)).from_json(result.body)
130+
131+
child1_data = zones.find { |z| z["id"].as_s == child1.id }
132+
child2_data = zones.find { |z| z["id"].as_s == child2.id }
133+
134+
child1_data.should_not be_nil
135+
child2_data.should_not be_nil
136+
137+
child1_data.not_nil!["children_count"].as_i.should eq 1
138+
child2_data.not_nil!["children_count"].as_i.should eq 0
139+
140+
parent.destroy
141+
child1.destroy
142+
child2.destroy
143+
grandchild.destroy
144+
end
145+
146+
it "preserves include_children_count across paginated requests" do
147+
parent = Model::Generator.zone.save!
148+
149+
children = Array(Model::Zone).new
150+
5.times do
151+
child = Model::Generator.zone
152+
child.parent_id = parent.id
153+
child.save!
154+
children << child
155+
end
156+
157+
sleep 1.second
158+
refresh_elastic(Model::Zone.table_name)
159+
160+
params = HTTP::Params.encode({
161+
"parent_id" => parent.id.as(String),
162+
"include_children_count" => "true",
163+
"limit" => "2",
164+
})
165+
path = "#{Zones.base_route}?#{params}"
166+
result = client.get(path, headers: Spec::Authentication.headers)
167+
168+
result.success?.should be_true
169+
170+
link_header = result.headers["Link"]?
171+
link_header.should_not be_nil
172+
link_header.not_nil!.should contain("include_children_count=true")
173+
174+
zones = Array(Hash(String, JSON::Any)).from_json(result.body)
175+
zones.size.should eq 2
176+
zones.each do |zone|
177+
zone["children_count"]?.should_not be_nil
178+
end
179+
180+
parent.destroy
181+
children.each(&.destroy)
182+
end
77183
end
78184

79185
describe "tags", tags: "search" do

src/placeos-rest-api/controllers/zones.cr

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module PlaceOS::Api
2424
class ::PlaceOS::Model::Zone
2525
@[JSON::Field(key: "trigger_data")]
2626
property trigger_data_details : Array(::PlaceOS::Model::Trigger)? = nil
27+
property children_count : Int32? = nil
2728
end
2829

2930
###############################################################################################
@@ -96,21 +97,48 @@ module PlaceOS::Api
9697
# list the configured zones
9798
@[AC::Route::GET("/", converters: {tags: ConvertStringArray, parent_id: ConvertStringArray})]
9899
def index(
99-
@[AC::Param::Info(description: "only return zones who have this zone as a parent (supports comma-separated list)", example: "zone-1234,zone-5678")]
100+
@[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")]
100101
parent_id : Array(String)? = nil,
101102
@[AC::Param::Info(description: "return zones with particular tags", example: "building,level")]
102103
tags : Array(String)? = nil,
104+
@[AC::Param::Info(description: "include children_count for each zone (useful for tree views)", example: "true")]
105+
include_children_count : Bool = false,
103106
) : Array(::PlaceOS::Model::Zone)
104107
elastic = ::PlaceOS::Model::Zone.elastic
105108
query = elastic.query(search_params)
106109
query.sort(NAME_SORT_ASC)
107110

108-
# Limit results to the children of these parents (OR logic)
111+
# Handle tree view queries
109112
if parent_id
110-
query.should({
111-
"parent_id" => parent_id,
112-
})
113-
query.minimum_should_match(1)
113+
# Special case: "root" means zones with no parent
114+
if parent_id.includes?("root")
115+
# Remove "root" and add any other parent_ids if present
116+
other_parents = parent_id.reject("root")
117+
if !other_parents.empty?
118+
# Mix of root and specific parents: use OR logic
119+
# Build array with nil and other parent IDs
120+
parent_values = Array(String?).new
121+
parent_values << nil
122+
other_parents.each { |p| parent_values << p }
123+
query.should({
124+
"parent_id" => parent_values,
125+
})
126+
query.minimum_should_match(1)
127+
else
128+
# Only root zones: filter for missing parent_id
129+
parent_values = Array(String?).new
130+
parent_values << nil
131+
query.filter({
132+
"parent_id" => parent_values,
133+
})
134+
end
135+
else
136+
# Limit results to the children of these parents (OR logic)
137+
query.should({
138+
"parent_id" => parent_id,
139+
})
140+
query.minimum_should_match(1)
141+
end
114142
end
115143

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

126-
paginate_results(elastic, query)
154+
results = paginate_results(elastic, query)
155+
156+
# Add children count if requested
157+
if include_children_count
158+
results = add_children_counts_from_es(results, elastic, query)
159+
end
160+
161+
results
162+
end
163+
164+
# Helper to add children counts to zones using Elasticsearch aggregations
165+
private def add_children_counts_from_es(zones : Array(::PlaceOS::Model::Zone), elastic, base_query)
166+
return zones if zones.empty?
167+
168+
zone_ids = zones.map(&.id.as(String))
169+
170+
# Query all zones whose parent_id matches any of our zone_ids, then aggregate by parent_id
171+
agg_query = ::PlaceOS::Model::Zone.elastic.query({} of String => String)
172+
agg_query.should({"parent_id" => zone_ids})
173+
agg_query.minimum_should_match(1)
174+
agg_query.terms("children_by_parent", "parent_id", size: zone_ids.size)
175+
176+
# Execute query with aggregation
177+
data = ::PlaceOS::Model::Zone.elastic.search(agg_query)
178+
179+
# Extract aggregation results
180+
count_map = Hash(String, Int32).new
181+
if aggs = data[:aggregations]?
182+
if buckets = aggs.dig?("children_by_parent", "buckets").try(&.as_a?)
183+
buckets.each do |bucket|
184+
parent_id = bucket["key"].as_s
185+
count_map[parent_id] = bucket["doc_count"].as_i
186+
end
187+
end
188+
end
189+
190+
# Assign counts to zones
191+
zones.each do |zone|
192+
zone.children_count = count_map[zone.id.as(String)]? || 0
193+
end
194+
195+
zones
127196
end
128197

129198
# returns unique zone tags

0 commit comments

Comments
 (0)