Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

ALTER TABLE "playlists"
ADD COLUMN IF NOT EXISTS play_at_period INTEGER;

ALTER TABLE "playlists"
ADD COLUMN IF NOT EXISTS play_at_takeover BOOLEAN NOT NULL DEFAULT FALSE;

CREATE TABLE IF NOT EXISTS "signage_plugin"(
id TEXT NOT NULL PRIMARY KEY,
authority_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,

name TEXT NOT NULL,
description TEXT,
uri TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
params JSONB NOT NULL DEFAULT '{}'::jsonb,
defaults JSONB NOT NULL DEFAULT '{}'::jsonb,
CHECK (jsonb_typeof(params) = 'object'),
CHECK (jsonb_typeof(defaults) = 'object'),
FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS signage_plugin_authority_id_index ON "signage_plugin" USING BTREE (authority_id);

ALTER TABLE "playlist_items"
ADD COLUMN IF NOT EXISTS plugin_id TEXT REFERENCES "signage_plugin"(id) ON DELETE CASCADE;

ALTER TABLE "playlist_items"
ADD COLUMN IF NOT EXISTS plugin_params JSONB NOT NULL DEFAULT '{}'::jsonb;

CREATE INDEX IF NOT EXISTS playlist_items_plugin_id_index ON "playlist_items" USING BTREE (plugin_id);

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

ALTER TABLE "playlist_items" DROP COLUMN IF EXISTS plugin_params;
ALTER TABLE "playlist_items" DROP COLUMN IF EXISTS plugin_id;
DROP TABLE IF EXISTS "signage_plugin";
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_at_period;
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_at_takeover;
43 changes: 43 additions & 0 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,29 @@ module PlaceOS::Model
play
end

def self.signage_plugin(
name : String = Faker::Hacker.noun,
description : String = "",
uri : String = "/plugins/default",
authority : Authority? = nil,
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)},
defaults : Hash(String, JSON::Any) = {"play_at_period" => JSON::Any.new(10_i64)},
)
unless authority
existing = Authority.find_by_domain("localhost")
authority = existing || self.authority.save!
end

SignagePlugin.new(
name: name,
description: description,
uri: uri,
authority_id: authority.id,
params: params,
defaults: defaults,
)
end

def self.revision(playlist : Playlist = playlist.save!, user : User = user.save!)
rev = Playlist::Revision.new
rev.playlist_id = playlist.id
Expand Down Expand Up @@ -155,6 +178,26 @@ module PlaceOS::Model
item
end

def self.plugin_item(
name : String = Faker::Hacker.noun,
plugin : SignagePlugin = signage_plugin.save!,
plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any,
authority : Authority? = nil,
)
unless authority
existing = Authority.find_by_domain("localhost")
authority = existing || self.authority.save!
end

item = Playlist::Item.new
item.authority_id = authority.id
item.name = name
item.media_type = Playlist::Item::MediaType::Plugin
item.plugin_id = plugin.id
item.plugin_params = plugin_params
item
end

def self.booking(tenant_id, asset_ids : Array(String), start : Time, ending : Time, booking_type = "booking", parent_id = nil, event_id = nil)
user_name = Faker::Hacker.noun
user_email = Faker::Internet.email
Expand Down
80 changes: 80 additions & 0 deletions spec/playlist_item_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,86 @@ module PlaceOS::Model
updated.should_not eq playlist.updated_at
end

it "creates a plugin item" do
plugin = Generator.signage_plugin.save!
item = Generator.plugin_item(plugin: plugin)
item.save!

found = Playlist::Item.find(item.id.as(String))
found.media_type.should eq Playlist::Item::MediaType::Plugin
found.plugin_id.should eq plugin.id
end

it "requires a plugin_id for plugin items" do
item = Generator.item
item.media_type = Playlist::Item::MediaType::Plugin
item.media_uri = nil
item.plugin_id = nil
item.save.should eq false
item.errors.any? { |e| e.field == :plugin_id }.should eq true
end

it "validates plugin_params keys exist in plugin params properties" do
plugin = Generator.signage_plugin(
params: {
"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),
},
).save!

item = Generator.plugin_item(
plugin: plugin,
plugin_params: {"bad_key" => JSON::Any.new(5_i64)},
)
item.save.should eq false
item.errors.any? { |e| e.field == :plugin_params }.should eq true
end

it "validates required params are satisfied by defaults merged with plugin_params" do
plugin = Generator.signage_plugin(
params: {
"type" => JSON::Any.new("object"),
"properties" => JSON::Any.new({
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
"color" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any),
} of String => JSON::Any),
"required" => JSON::Any.new([JSON::Any.new("play_at_period"), JSON::Any.new("color")]),
},
defaults: {"play_at_period" => JSON::Any.new(10_i64)},
).save!

# missing "color" which is required and has no default
item = Generator.plugin_item(
plugin: plugin,
plugin_params: {} of String => JSON::Any,
)
item.save.should eq false
item.errors.any? { |e| e.field == :plugin_params && e.message.to_s.includes?("color") }.should eq true
end

it "allows plugin_params when defaults cover required params" do
plugin = Generator.signage_plugin(
params: {
"type" => JSON::Any.new("object"),
"properties" => JSON::Any.new({
"play_at_period" => JSON::Any.new({"type" => JSON::Any.new("integer")} of String => JSON::Any),
"color" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any),
} of String => JSON::Any),
"required" => JSON::Any.new([JSON::Any.new("play_at_period"), JSON::Any.new("color")]),
},
defaults: {"play_at_period" => JSON::Any.new(10_i64)},
).save!

# "color" provided in plugin_params, "play_at_period" covered by defaults
item = Generator.plugin_item(
plugin: plugin,
plugin_params: {"color" => JSON::Any.new("red")},
)
item.save.should eq true
end

it "updates playlists when an item is modified" do
revision = Generator.revision

Expand Down
86 changes: 86 additions & 0 deletions spec/signage_plugin_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
require "./helper"

module PlaceOS::Model
describe SignagePlugin do
Spec.before_each do
SignagePlugin.clear
end

test_round_trip(SignagePlugin)

it "creates a signage plugin" do
plugin = Generator.signage_plugin
plugin.save!

found = SignagePlugin.find(plugin.id.as(String))
found.name.should eq plugin.name
found.enabled.should eq true
end

it "requires a name" do
plugin = Generator.signage_plugin(name: "")
plugin.save.should eq false
plugin.errors.first.field.should eq :name
end

it "validates defaults keys exist in params properties" do
plugin = Generator.signage_plugin(
params: {
"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),
},
defaults: {
"play_at_period" => JSON::Any.new(10_i64),
},
)
plugin.save.should eq true
end

it "rejects defaults with keys not in params properties" do
plugin = Generator.signage_plugin(
params: {
"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),
},
defaults: {
"nonexistent_key" => JSON::Any.new("value"),
},
)
plugin.save.should eq false
plugin.errors.first.field.should eq :defaults
end

it "allows empty defaults" do
plugin = Generator.signage_plugin(
defaults: {} of String => JSON::Any,
)
plugin.save.should eq true
end

it "allows a local resource URI" do
plugin = Generator.signage_plugin(uri: "/plugins/weather")
plugin.save.should eq true
end

it "allows an https URL" do
plugin = Generator.signage_plugin(uri: "https://example.com/plugins/weather")
plugin.save.should eq true
end

it "rejects http URLs" do
plugin = Generator.signage_plugin(uri: "http://example.com/plugins/weather")
plugin.save.should eq false
plugin.errors.any? { |e| e.field == :uri }.should eq true
end

it "requires a uri" do
plugin = Generator.signage_plugin(uri: "")
plugin.save.should eq false
plugin.errors.any? { |e| e.field == :uri }.should eq true
end
end
end
4 changes: 4 additions & 0 deletions src/placeos-models/playlist.cr
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ module PlaceOS::Model
attribute play_at : Int64? = nil
attribute play_cron : String? = nil

# how many minutes should a scheduled playlist play for / should it takeover the displays
attribute play_at_period : Int32? = nil
attribute play_at_takeover : Bool = false

def should_present?(now : Time = Time.utc, timezone : Bool = false) : Bool
return false unless enabled

Expand Down
36 changes: 36 additions & 0 deletions src/placeos-models/playlist/item.cr
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ module PlaceOS::Model
belongs_to Upload, foreign_key: "media_id", association_name: "media"
belongs_to Upload, foreign_key: "thumbnail_id", association_name: "thumbnail"

# plugin data for playback
belongs_to SignagePlugin, foreign_key: "plugin_id", association_name: "plugin"
attribute plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any

# other metadata
attribute play_count : Int64 = 0
attribute valid_from : Int64? = nil
Expand Down Expand Up @@ -87,6 +91,8 @@ module PlaceOS::Model
case this.media_type
when .image?, .video?
this.validation_error(:media_id, "must specify a media upload id") unless this.media
when .plugin?
this.validate_plugin
else
if media_uri = this.media_uri.presence
begin
Expand All @@ -103,6 +109,36 @@ module PlaceOS::Model
end
}

protected def validate_plugin
plugin = self.plugin
unless plugin
self.validation_error(:plugin_id, "must specify a plugin id")
return
end

params = self.plugin_params
properties = plugin.params["properties"]?.try(&.as_h?)

# ensure plugin_params keys exist in plugin params properties
params.each_key do |key|
unless properties.try(&.has_key?(key))
self.validation_error(:plugin_params, "key '#{key}' does not exist in plugin params properties")
end
end

# ensure all required params are satisfied by defaults merged with plugin_params
if required = plugin.params["required"]?.try(&.as_a?)
merged = plugin.defaults.merge(params)
required.each do |req_key|
key = req_key.as_s?
next unless key
unless merged.has_key?(key)
self.validation_error(:plugin_params, "missing required param '#{key}'")
end
end
end
end

before_destroy :cleanup_playlists

# Reject any bookings that are current
Expand Down
52 changes: 52 additions & 0 deletions src/placeos-models/signage_plugin.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require "uri"
require "./base/model"

module PlaceOS::Model
class SignagePlugin < ModelBase
include PlaceOS::Model::Timestamps

table :signage_plugin

attribute name : String, es_subfield: "keyword"
attribute description : String = ""
attribute uri : String

belongs_to Authority, foreign_key: "authority_id"

attribute enabled : Bool = true
attribute params : Hash(String, JSON::Any) = {} of String => JSON::Any
attribute defaults : Hash(String, JSON::Any) = {} of String => JSON::Any

# Validation
###############################################################################################

validates :name, presence: true
validates :uri, presence: true

validate ->(this : SignagePlugin) {
return unless uri = this.uri.presence
begin
parsed = URI.parse(uri)
raise "requires a request target" unless parsed.request_target.presence
if scheme = parsed.scheme
raise "scheme must be https" unless scheme.downcase == "https"
end
rescue error
this.validation_error(:uri, "not valid: #{error.message}")
end
}

# ensure keys in defaults exist in params properties
validate ->(this : SignagePlugin) {
return if this.defaults.empty?

properties = this.params["properties"]?.try(&.as_h?)

this.defaults.each_key do |key|
unless properties.try(&.has_key?(key))
this.validation_error(:defaults, "key '#{key}' does not exist in params properties")
end
end
}
end
end
Loading