Skip to content

Commit dfd8e85

Browse files
authored
feat: add signage plugins (#307)
1 parent c3b52d0 commit dfd8e85

8 files changed

Lines changed: 379 additions & 2 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
-- +micrate Up
2+
-- SQL in section 'Up' is executed when this migration is applied
3+
4+
ALTER TABLE "playlists"
5+
ADD COLUMN IF NOT EXISTS play_at_period INTEGER;
6+
7+
ALTER TABLE "playlists"
8+
ADD COLUMN IF NOT EXISTS play_at_takeover BOOLEAN NOT NULL DEFAULT FALSE;
9+
10+
-- +micrate StatementBegin
11+
DO
12+
$$
13+
BEGIN
14+
IF NOT EXISTS (SELECT *
15+
FROM pg_type typ
16+
INNER JOIN pg_namespace nsp
17+
ON nsp.oid = typ.typnamespace
18+
WHERE nsp.nspname = current_schema()
19+
AND typ.typname = 'signage_plugin_playback_type') THEN
20+
CREATE TYPE signage_plugin_playback_type AS ENUM (
21+
'STATIC',
22+
'INTERACTIVE',
23+
'PLAYSTHROUGH'
24+
);
25+
END IF;
26+
END;
27+
$$
28+
LANGUAGE plpgsql;
29+
-- +micrate StatementEnd
30+
31+
CREATE TABLE IF NOT EXISTS "signage_plugin"(
32+
id TEXT NOT NULL PRIMARY KEY,
33+
authority_id TEXT,
34+
created_at TIMESTAMPTZ NOT NULL,
35+
updated_at TIMESTAMPTZ NOT NULL,
36+
37+
name TEXT NOT NULL,
38+
description TEXT,
39+
uri TEXT NOT NULL,
40+
playback_type public.signage_plugin_playback_type NOT NULL DEFAULT 'STATIC'::public.signage_plugin_playback_type,
41+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
42+
params JSONB NOT NULL DEFAULT '{}'::jsonb,
43+
defaults JSONB NOT NULL DEFAULT '{}'::jsonb,
44+
CHECK (jsonb_typeof(params) = 'object'),
45+
CHECK (jsonb_typeof(defaults) = 'object'),
46+
FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE
47+
);
48+
49+
CREATE INDEX IF NOT EXISTS signage_plugin_authority_id_index ON "signage_plugin" USING BTREE (authority_id);
50+
51+
ALTER TABLE "playlist_items"
52+
ADD COLUMN IF NOT EXISTS plugin_id TEXT REFERENCES "signage_plugin"(id) ON DELETE CASCADE;
53+
54+
ALTER TABLE "playlist_items"
55+
ADD COLUMN IF NOT EXISTS plugin_params JSONB NOT NULL DEFAULT '{}'::jsonb;
56+
57+
CREATE INDEX IF NOT EXISTS playlist_items_plugin_id_index ON "playlist_items" USING BTREE (plugin_id);
58+
59+
-- +micrate Down
60+
-- SQL section 'Down' is executed when this migration is rolled back
61+
62+
ALTER TABLE "playlist_items" DROP COLUMN IF EXISTS plugin_params;
63+
ALTER TABLE "playlist_items" DROP COLUMN IF EXISTS plugin_id;
64+
DROP TABLE IF EXISTS "signage_plugin";
65+
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_at_period;
66+
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_at_takeover;
67+
68+
-- Drop the enum type
69+
DROP TYPE IF EXISTS public.signage_plugin_playback_type;

spec/generator.cr

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,29 @@ module PlaceOS::Model
103103
play
104104
end
105105

106+
def self.signage_plugin(
107+
name : String = Faker::Hacker.noun,
108+
description : String = "",
109+
uri : String = "/plugins/default",
110+
authority : Authority? = nil,
111+
params : Hash(String, JSON::Any) = {"type" => JSON::Any.new("object"), "properties" => JSON::Any.new({"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any)} of String => JSON::Any)},
112+
defaults : Hash(String, JSON::Any) = {"play_at_period" => JSON::Any.new(10_i64)},
113+
)
114+
unless authority
115+
existing = Authority.find_by_domain("localhost")
116+
authority = existing || self.authority.save!
117+
end
118+
119+
SignagePlugin.new(
120+
name: name,
121+
description: description,
122+
uri: uri,
123+
authority_id: authority.id,
124+
params: params,
125+
defaults: defaults,
126+
)
127+
end
128+
106129
def self.revision(playlist : Playlist = playlist.save!, user : User = user.save!)
107130
rev = Playlist::Revision.new
108131
rev.playlist_id = playlist.id
@@ -155,6 +178,26 @@ module PlaceOS::Model
155178
item
156179
end
157180

181+
def self.plugin_item(
182+
name : String = Faker::Hacker.noun,
183+
plugin : SignagePlugin = signage_plugin.save!,
184+
plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any,
185+
authority : Authority? = nil,
186+
)
187+
unless authority
188+
existing = Authority.find_by_domain("localhost")
189+
authority = existing || self.authority.save!
190+
end
191+
192+
item = Playlist::Item.new
193+
item.authority_id = authority.id
194+
item.name = name
195+
item.media_type = Playlist::Item::MediaType::Plugin
196+
item.plugin_id = plugin.id
197+
item.plugin_params = plugin_params
198+
item
199+
end
200+
158201
def self.booking(tenant_id, asset_ids : Array(String), start : Time, ending : Time, booking_type = "booking", parent_id = nil, event_id = nil)
159202
user_name = Faker::Hacker.noun
160203
user_email = Faker::Internet.email

spec/playlist_item_spec.cr

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,86 @@ module PlaceOS::Model
6464
updated.should_not eq playlist.updated_at
6565
end
6666

67+
it "creates a plugin item" do
68+
plugin = Generator.signage_plugin.save!
69+
item = Generator.plugin_item(plugin: plugin)
70+
item.save!
71+
72+
found = Playlist::Item.find(item.id.as(String))
73+
found.media_type.should eq Playlist::Item::MediaType::Plugin
74+
found.plugin_id.should eq plugin.id
75+
end
76+
77+
it "requires a plugin_id for plugin items" do
78+
item = Generator.item
79+
item.media_type = Playlist::Item::MediaType::Plugin
80+
item.media_uri = nil
81+
item.plugin_id = nil
82+
item.save.should eq false
83+
item.errors.any? { |e| e.field == :plugin_id }.should eq true
84+
end
85+
86+
it "validates plugin_params keys exist in plugin params properties" do
87+
plugin = Generator.signage_plugin(
88+
params: {
89+
"type" => JSON::Any.new("object"),
90+
"properties" => JSON::Any.new({
91+
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
92+
} of String => JSON::Any),
93+
},
94+
).save!
95+
96+
item = Generator.plugin_item(
97+
plugin: plugin,
98+
plugin_params: {"bad_key" => JSON::Any.new(5_i64)},
99+
)
100+
item.save.should eq false
101+
item.errors.any? { |e| e.field == :plugin_params }.should eq true
102+
end
103+
104+
it "validates required params are satisfied by defaults merged with plugin_params" do
105+
plugin = Generator.signage_plugin(
106+
params: {
107+
"type" => JSON::Any.new("object"),
108+
"properties" => JSON::Any.new({
109+
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
110+
"color" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any),
111+
} of String => JSON::Any),
112+
"required" => JSON::Any.new([JSON::Any.new("play_at_period"), JSON::Any.new("color")]),
113+
},
114+
defaults: {"play_at_period" => JSON::Any.new(10_i64)},
115+
).save!
116+
117+
# missing "color" which is required and has no default
118+
item = Generator.plugin_item(
119+
plugin: plugin,
120+
plugin_params: {} of String => JSON::Any,
121+
)
122+
item.save.should eq false
123+
item.errors.any? { |e| e.field == :plugin_params && e.message.to_s.includes?("color") }.should eq true
124+
end
125+
126+
it "allows plugin_params when defaults cover required params" do
127+
plugin = Generator.signage_plugin(
128+
params: {
129+
"type" => JSON::Any.new("object"),
130+
"properties" => JSON::Any.new({
131+
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
132+
"color" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any),
133+
} of String => JSON::Any),
134+
"required" => JSON::Any.new([JSON::Any.new("play_at_period"), JSON::Any.new("color")]),
135+
},
136+
defaults: {"play_at_period" => JSON::Any.new(10_i64)},
137+
).save!
138+
139+
# "color" provided in plugin_params, "play_at_period" covered by defaults
140+
item = Generator.plugin_item(
141+
plugin: plugin,
142+
plugin_params: {"color" => JSON::Any.new("red")},
143+
)
144+
item.save.should eq true
145+
end
146+
67147
it "updates playlists when an item is modified" do
68148
revision = Generator.revision
69149

spec/signage_plugin_spec.cr

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
require "./helper"
2+
3+
module PlaceOS::Model
4+
describe SignagePlugin do
5+
Spec.before_each do
6+
SignagePlugin.clear
7+
end
8+
9+
test_round_trip(SignagePlugin)
10+
11+
it "creates a signage plugin" do
12+
plugin = Generator.signage_plugin
13+
plugin.save!
14+
15+
found = SignagePlugin.find(plugin.id.as(String))
16+
found.name.should eq plugin.name
17+
found.enabled.should eq true
18+
end
19+
20+
it "requires a name" do
21+
plugin = Generator.signage_plugin(name: "")
22+
plugin.save.should eq false
23+
plugin.errors.first.field.should eq :name
24+
end
25+
26+
it "validates defaults keys exist in params properties" do
27+
plugin = Generator.signage_plugin(
28+
params: {
29+
"type" => JSON::Any.new("object"),
30+
"properties" => JSON::Any.new({
31+
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
32+
} of String => JSON::Any),
33+
},
34+
defaults: {
35+
"play_at_period" => JSON::Any.new(10_i64),
36+
},
37+
)
38+
plugin.save.should eq true
39+
end
40+
41+
it "rejects defaults with keys not in params properties" do
42+
plugin = Generator.signage_plugin(
43+
params: {
44+
"type" => JSON::Any.new("object"),
45+
"properties" => JSON::Any.new({
46+
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
47+
} of String => JSON::Any),
48+
},
49+
defaults: {
50+
"nonexistent_key" => JSON::Any.new("value"),
51+
},
52+
)
53+
plugin.save.should eq false
54+
plugin.errors.first.field.should eq :defaults
55+
end
56+
57+
it "allows empty defaults" do
58+
plugin = Generator.signage_plugin(
59+
defaults: {} of String => JSON::Any,
60+
)
61+
plugin.save.should eq true
62+
end
63+
64+
it "allows a local resource URI" do
65+
plugin = Generator.signage_plugin(uri: "/plugins/weather")
66+
plugin.save.should eq true
67+
end
68+
69+
it "allows an https URL" do
70+
plugin = Generator.signage_plugin(uri: "https://example.com/plugins/weather")
71+
plugin.save.should eq true
72+
end
73+
74+
it "rejects http URLs" do
75+
plugin = Generator.signage_plugin(uri: "http://example.com/plugins/weather")
76+
plugin.save.should eq false
77+
plugin.errors.any? { |e| e.field == :uri }.should eq true
78+
end
79+
80+
it "requires a uri" do
81+
plugin = Generator.signage_plugin(uri: "")
82+
plugin.save.should eq false
83+
plugin.errors.any? { |e| e.field == :uri }.should eq true
84+
end
85+
end
86+
end

src/placeos-models/control_system.cr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ module PlaceOS::Model
115115
this.validation_error(:camera_snapshot_url, "is an invalid URI") unless Validation.valid_uri?(url)
116116
end
117117

118-
this.camera_snapshot_urls.each do |url|
119-
if !Validation.valid_uri?(url)
118+
this.camera_snapshot_urls.each do |snap_url|
119+
if !Validation.valid_uri?(snap_url)
120120
this.validation_error(:camera_snapshot_urls, "contains an invalid URI")
121121
break
122122
end

src/placeos-models/playlist.cr

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ module PlaceOS::Model
5555
attribute play_at : Int64? = nil
5656
attribute play_cron : String? = nil
5757

58+
# how many minutes should a scheduled playlist play for / should it takeover the displays
59+
attribute play_at_period : Int32? = nil
60+
attribute play_at_takeover : Bool = false
61+
5862
def should_present?(now : Time = Time.utc, timezone : Bool = false) : Bool
5963
return false unless enabled
6064

src/placeos-models/playlist/item.cr

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ module PlaceOS::Model
3737
belongs_to Upload, foreign_key: "media_id", association_name: "media"
3838
belongs_to Upload, foreign_key: "thumbnail_id", association_name: "thumbnail"
3939

40+
# plugin data for playback
41+
belongs_to SignagePlugin, foreign_key: "plugin_id", association_name: "plugin"
42+
attribute plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any
43+
4044
# other metadata
4145
attribute play_count : Int64 = 0
4246
attribute valid_from : Int64? = nil
@@ -87,6 +91,8 @@ module PlaceOS::Model
8791
case this.media_type
8892
when .image?, .video?
8993
this.validation_error(:media_id, "must specify a media upload id") unless this.media
94+
when .plugin?
95+
this.validate_plugin
9096
else
9197
if media_uri = this.media_uri.presence
9298
begin
@@ -103,6 +109,36 @@ module PlaceOS::Model
103109
end
104110
}
105111

112+
protected def validate_plugin
113+
plugin = self.plugin
114+
unless plugin
115+
self.validation_error(:plugin_id, "must specify a plugin id")
116+
return
117+
end
118+
119+
params = self.plugin_params
120+
properties = plugin.params["properties"]?.try(&.as_h?)
121+
122+
# ensure plugin_params keys exist in plugin params properties
123+
params.each_key do |key|
124+
unless properties.try(&.has_key?(key))
125+
self.validation_error(:plugin_params, "key '#{key}' does not exist in plugin params properties")
126+
end
127+
end
128+
129+
# ensure all required params are satisfied by defaults merged with plugin_params
130+
if required = plugin.params["required"]?.try(&.as_a?)
131+
merged = plugin.defaults.merge(params)
132+
required.each do |req_key|
133+
key = req_key.as_s?
134+
next unless key
135+
unless merged.has_key?(key)
136+
self.validation_error(:plugin_params, "missing required param '#{key}'")
137+
end
138+
end
139+
end
140+
end
141+
106142
before_destroy :cleanup_playlists
107143

108144
# Reject any bookings that are current

0 commit comments

Comments
 (0)