From 4ed8ecfd0f7b831ed431bf51fcc57d435509ec9f Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 23 Jul 2026 21:16:30 +1000 Subject: [PATCH] feat(signage): add widget support --- ...0260723100500000_add_signage_templates.sql | 77 ++++++ spec/booking_clash_perf_spec.cr | 2 +- spec/generator.cr | 58 +++++ spec/helper.cr | 2 + spec/signage_plugin_spec.cr | 14 + spec/signage_template_spec.cr | 245 ++++++++++++++++++ spec/system_template_spec.cr | 121 +++++++++ src/placeos-models/signage_plugin.cr | 8 + src/placeos-models/signage_template.cr | 147 +++++++++++ src/placeos-models/signage_template/layout.cr | 73 ++++++ .../signage_template/system_template.cr | 65 +++++ 11 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 migration/db/migrations/20260723100500000_add_signage_templates.sql create mode 100644 spec/signage_template_spec.cr create mode 100644 spec/system_template_spec.cr create mode 100644 src/placeos-models/signage_template.cr create mode 100644 src/placeos-models/signage_template/layout.cr create mode 100644 src/placeos-models/signage_template/system_template.cr diff --git a/migration/db/migrations/20260723100500000_add_signage_templates.sql b/migration/db/migrations/20260723100500000_add_signage_templates.sql new file mode 100644 index 00000000..4f57ee35 --- /dev/null +++ b/migration/db/migrations/20260723100500000_add_signage_templates.sql @@ -0,0 +1,77 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- +micrate StatementBegin +DO +$$ +BEGIN + IF NOT EXISTS (SELECT * + FROM pg_type typ + INNER JOIN pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'signage_plugin_plugin_type') THEN + CREATE TYPE signage_plugin_plugin_type AS ENUM ( + 'PLUGIN', + 'WIDGET' + ); + END IF; +END; +$$ +LANGUAGE plpgsql; +-- +micrate StatementEnd + +ALTER TABLE "signage_plugin" + ADD COLUMN IF NOT EXISTS plugin_type public.signage_plugin_plugin_type NOT NULL DEFAULT 'PLUGIN'::public.signage_plugin_plugin_type; + +-- templates position widget plugins over signage displays +CREATE TABLE IF NOT EXISTS "signage_template"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + authority_id TEXT NOT NULL, + background_item_id TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + + name TEXT NOT NULL, + description TEXT, + tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + layouts JSONB NOT NULL DEFAULT '[]'::jsonb, + full_screen_takeover BOOLEAN NOT NULL DEFAULT FALSE, + CHECK (jsonb_typeof(layouts) = 'array'), + FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE, + FOREIGN KEY (background_item_id) REFERENCES playlist_items(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS signage_template_authority_id_index ON "signage_template" USING BTREE (authority_id); +CREATE INDEX IF NOT EXISTS signage_template_background_item_id_index ON "signage_template" USING BTREE (background_item_id); +CREATE INDEX IF NOT EXISTS signage_template_tags_index ON "signage_template" USING GIN (tags); + +-- junction attaching templates to control systems, each row optionally +-- scoped by a schedule. A schedule-less row is the default template for +-- the pairing (at most one, enforced by the partial unique index). +CREATE TABLE IF NOT EXISTS "system_templates"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + control_system_id TEXT NOT NULL, + template_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + + schedule JSONB, + CHECK (schedule IS NULL OR jsonb_typeof(schedule) = 'object'), + FOREIGN KEY (control_system_id) REFERENCES sys(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES signage_template(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS system_templates_control_system_id_index ON "system_templates" USING BTREE (control_system_id); +CREATE INDEX IF NOT EXISTS system_templates_template_id_index ON "system_templates" USING BTREE (template_id); +CREATE UNIQUE INDEX IF NOT EXISTS system_templates_default_unique ON "system_templates" (control_system_id, template_id) WHERE schedule IS NULL; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP TABLE IF EXISTS "system_templates"; +DROP TABLE IF EXISTS "signage_template"; +ALTER TABLE "signage_plugin" DROP COLUMN IF EXISTS plugin_type; + +-- Drop the enum type +DROP TYPE IF EXISTS public.signage_plugin_plugin_type; diff --git a/spec/booking_clash_perf_spec.cr b/spec/booking_clash_perf_spec.cr index 55892c96..9e286ab5 100644 --- a/spec/booking_clash_perf_spec.cr +++ b/spec/booking_clash_perf_spec.cr @@ -96,7 +96,7 @@ module PlaceOS::Model b1 = recurring.call("desk-1", start_time, end_time, 0b1111111, rec_end) b1.save! b2 = recurring.call("desk-1", start_time + 30.minutes, end_time + 30.minutes, 0b1111111, rec_end) - b2.clashing_bookings.map(&.id).uniq.should contain b1.id + b2.clashing_bookings.map(&.id).uniq!.should contain b1.id b2.clashing?.should be_true end diff --git a/spec/generator.cr b/spec/generator.cr index 50652e86..7e4d7ab2 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -144,6 +144,64 @@ module PlaceOS::Model ) end + def self.widget_plugin( + name : String = Faker::Hacker.noun, + authority : Authority? = nil, + **args, + ) + plugin = signage_plugin(**args, name: name, authority: authority) + plugin.plugin_type = SignagePlugin::PluginType::Widget + plugin + end + + def self.layout( + plugin : SignagePlugin? = nil, + position : SignageTemplate::Layout::Position = SignageTemplate::Layout::Position::Top, + x_pos : Float32? = nil, + y_pos : Float32? = 0.25_f32, + plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any, + ) + SignageTemplate::Layout.new( + position: position, + plugin_id: plugin.try(&.id), + x_pos: x_pos, + y_pos: y_pos, + plugin_params: plugin_params, + ) + end + + def self.signage_template( + name : String = Faker::Hacker.noun, + authority : Authority? = nil, + layouts : Array(SignageTemplate::Layout) = [] of SignageTemplate::Layout, + tags : Array(String) = [] of String, + ) + unless authority + existing = Authority.find_by_domain("localhost") + authority = existing || self.authority.save! + end + + template = SignageTemplate.new + template.name = name + template.authority_id = authority.id.as(String) + template.layouts = layouts + template.tags = tags + template + end + + def self.system_template( + template : SignageTemplate? = nil, + control_system : ControlSystem = control_system.save!, + schedule : Playlist::Schedule? = nil, + ) + template ||= signage_template.save! + sys_template = SignageTemplate::SystemTemplate.new + sys_template.control_system_id = control_system.id.as(String) + sys_template.template_id = template.id.as(UUID) + sys_template.schedule = schedule + sys_template + end + def self.revision(playlist : Playlist = playlist.save!, user : User = user.save!) rev = Playlist::Revision.new rev.playlist_id = playlist.id diff --git a/spec/helper.cr b/spec/helper.cr index 1a5a57c6..84b353fc 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -26,6 +26,8 @@ Spec.after_suite do # Models that inherit directly from ::PgORM::Base (not ModelBase) — # cleared in dependency order (children first so FKs don't fire). [ + PlaceOS::Model::SignageTemplate::SystemTemplate, + PlaceOS::Model::SignageTemplate, PlaceOS::Model::PendingMail, PlaceOS::Model::GroupHistory, PlaceOS::Model::GroupInvitation, diff --git a/spec/signage_plugin_spec.cr b/spec/signage_plugin_spec.cr index 6a1abe0e..6a756791 100644 --- a/spec/signage_plugin_spec.cr +++ b/spec/signage_plugin_spec.cr @@ -82,5 +82,19 @@ module PlaceOS::Model plugin.save.should eq false plugin.errors.any? { |e| e.field == :uri }.should eq true end + + it "defaults plugin_type to Plugin" do + plugin = Generator.signage_plugin.save! + + found = SignagePlugin.find(plugin.id.as(String)) + found.plugin_type.should eq SignagePlugin::PluginType::Plugin + end + + it "persists a Widget plugin_type" do + plugin = Generator.widget_plugin.save! + + found = SignagePlugin.find(plugin.id.as(String)) + found.plugin_type.should eq SignagePlugin::PluginType::Widget + end end end diff --git a/spec/signage_template_spec.cr b/spec/signage_template_spec.cr new file mode 100644 index 00000000..d02131b7 --- /dev/null +++ b/spec/signage_template_spec.cr @@ -0,0 +1,245 @@ +require "./helper" + +module PlaceOS::Model + describe SignageTemplate do + Spec.before_each do + SignageTemplate::SystemTemplate.clear + SignageTemplate.clear + SignagePlugin.clear + Playlist::Item.clear + History.clear + end + + test_round_trip(SignageTemplate) + + it "creates a template and reloads its layouts" do + widget = Generator.widget_plugin.save! + layouts = [ + Generator.layout(plugin: widget, position: SignageTemplate::Layout::Position::Left, x_pos: 0.3_f32, y_pos: nil), + Generator.layout(position: SignageTemplate::Layout::Position::Floating, x_pos: 0.5_f32, y_pos: 0.75_f32), + ] + template = Generator.signage_template(layouts: layouts, tags: ["lobby", "level1"]).save! + + found = SignageTemplate.find!(template.id.as(UUID)) + found.name.should eq template.name + found.tags.should eq ["lobby", "level1"] + found.full_screen_takeover.should eq false + found.layouts.size.should eq 2 + + slice = found.layouts[0] + slice.plugin_id.should eq widget.id + slice.position.should eq SignageTemplate::Layout::Position::Left + slice.x_pos.should eq 0.3_f32 + slice.spacer?.should eq false + + floating = found.layouts[1] + floating.plugin_id.should be_nil + floating.spacer?.should eq true + floating.x_pos.should eq 0.5_f32 + floating.y_pos.should eq 0.75_f32 + end + + it "requires a name" do + template = Generator.signage_template(name: "") + template.save.should eq false + template.errors.map(&.field).should contain :name + end + + it "requires an authority" do + template = SignageTemplate.new + template.name = "no authority" + template.save.should eq false + template.errors.map(&.field).should contain :authority_id + end + + describe "layout structural validation" do + it "requires x_pos and y_pos for floating layouts" do + layout = Generator.layout(position: SignageTemplate::Layout::Position::Floating, x_pos: 0.5_f32, y_pos: nil) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "floating" + end + + it "requires x_pos for left and right layouts" do + {SignageTemplate::Layout::Position::Left, SignageTemplate::Layout::Position::Right}.each do |position| + layout = Generator.layout(position: position, x_pos: nil, y_pos: nil) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "requires x_pos" + end + end + + it "requires y_pos for top and bottom layouts" do + {SignageTemplate::Layout::Position::Top, SignageTemplate::Layout::Position::Bottom}.each do |position| + layout = Generator.layout(position: position, y_pos: nil) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "requires y_pos" + end + end + + it "requires positions to be percentages between 0 and 1 exclusive" do + {0.0_f32, 1.0_f32, -0.5_f32, 1.5_f32}.each do |value| + layout = Generator.layout(position: SignageTemplate::Layout::Position::Top, y_pos: value) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "y_pos" + end + end + + it "accepts a spacer layout without a plugin" do + layout = Generator.layout(position: SignageTemplate::Layout::Position::Bottom, y_pos: 0.2_f32) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq true + end + end + + describe "layout plugin validation" do + it "rejects layouts referencing a plugin that does not exist" do + layout = SignageTemplate::Layout.new(plugin_id: "signage_plugin-nonexistent", y_pos: 0.25_f32) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "does not exist" + end + + it "rejects layouts referencing a non-widget plugin" do + plugin = Generator.signage_plugin.save! + layout = Generator.layout(plugin: plugin) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "not a widget" + end + + it "rejects widgets belonging to a different authority" do + other_authority = Generator.authority(domain: "http://other.example.com").save! + widget = Generator.widget_plugin(authority: other_authority).save! + layout = Generator.layout(plugin: widget) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "same authority" + end + + it "rejects params that do not exist in the plugin properties" do + widget = Generator.widget_plugin.save! + layout = Generator.layout(plugin: widget, plugin_params: {"bogus" => JSON::Any.new("value")}) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "'bogus'" + end + + it "enforces required params" do + widget = Generator.widget_plugin( + params: { + "type" => JSON::Any.new("object"), + "properties" => JSON::Any.new({ + "api_key" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any), + } of String => JSON::Any), + "required" => JSON::Any.new([JSON::Any.new("api_key")]), + }, + defaults: {} of String => JSON::Any, + ).save! + + layout = Generator.layout(plugin: widget) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq false + template.errors.first.field.should eq :layouts + template.errors.first.message.should contain "required param 'api_key'" + + layout = Generator.layout(plugin: widget, plugin_params: {"api_key" => JSON::Any.new("secret")}) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq true + end + + it "satisfies required params via plugin defaults" do + widget = Generator.widget_plugin( + params: { + "type" => JSON::Any.new("object"), + "properties" => JSON::Any.new({ + "api_key" => JSON::Any.new({"type" => JSON::Any.new("string")} of String => JSON::Any), + } of String => JSON::Any), + "required" => JSON::Any.new([JSON::Any.new("api_key")]), + }, + defaults: {"api_key" => JSON::Any.new("default-secret")}, + ).save! + + layout = Generator.layout(plugin: widget) + template = Generator.signage_template(layouts: [layout]) + template.save.should eq true + end + end + + describe "history recording" do + it "does not record history on create" do + Generator.signage_template.save! + History.count.should eq 0 + end + + it "records changed fields on update" do + template = Generator.signage_template.save! + + template.name = "updated name" + template.tags = ["new-tag"] + template.save! + + History.count.should eq 1 + history = History.all.to_a.first + history.type.should eq "template" + history.resource_id.should eq template.id.to_s + history.action.should eq "updated" + history.changed_fields.should eq ["name", "tags"] + end + + it "records layout changes" do + template = Generator.signage_template.save! + + template.layouts = [Generator.layout] + template.save! + + History.count.should eq 1 + History.all.to_a.first.changed_fields.should eq ["layouts"] + end + + it "does not record history when nothing changed" do + template = Generator.signage_template.save! + template.save! + History.count.should eq 0 + end + end + + it "clears background_item_id when the media item is deleted" do + item = Generator.item.save! + template = Generator.signage_template + template.background_item_id = item.id + template.save! + + item.destroy + + found = SignageTemplate.find!(template.id.as(UUID)) + found.background_item_id.should be_nil + end + + it "is removed when the authority is deleted" do + authority = Generator.authority(domain: "http://cascade.example.com").save! + template = Generator.signage_template(authority: authority).save! + template_id = template.id.as(UUID) + + authority.destroy + SignageTemplate.find?(template_id).should be_nil + end + + it "supports filtering by tag" do + Generator.signage_template(tags: ["lobby"]).save! + Generator.signage_template(tags: ["carpark"]).save! + + SignageTemplate.where("? = ANY(tags)", "lobby").count.should eq 1 + end + end +end diff --git a/spec/system_template_spec.cr b/spec/system_template_spec.cr new file mode 100644 index 00000000..8bd13fe7 --- /dev/null +++ b/spec/system_template_spec.cr @@ -0,0 +1,121 @@ +require "./helper" + +module PlaceOS::Model + describe SignageTemplate::SystemTemplate do + Spec.before_each do + SignageTemplate::SystemTemplate.clear + SignageTemplate.clear + ControlSystem.clear + end + + test_round_trip(SignageTemplate::SystemTemplate) + + it "requires a control system and a template" do + sys_template = SignageTemplate::SystemTemplate.new + sys_template.save.should eq false + sys_template.errors.map(&.field).should contain :control_system_id + sys_template.errors.map(&.field).should contain :template_id + end + + it "attaches a template to a system as the default when no schedule is set" do + sys_template = Generator.system_template.save! + sys_template.default?.should eq true + + found = SignageTemplate::SystemTemplate.find!(sys_template.id.as(UUID)) + found.schedule.should be_nil + found.default?.should eq true + end + + it "round-trips a schedule" do + schedule = Playlist::Schedule.new(play_cron: "*/5 * * * *", play_period: 60) + sys_template = Generator.system_template(schedule: schedule).save! + sys_template.default?.should eq false + + found = SignageTemplate::SystemTemplate.find!(sys_template.id.as(UUID)) + reloaded = found.schedule.as(Playlist::Schedule) + reloaded.play_cron.should eq "*/5 * * * *" + reloaded.play_period.should eq 60 + end + + it "validates the schedule when present" do + sys_template = Generator.system_template(schedule: Playlist::Schedule.new(play_cron: "not a cron")) + sys_template.save.should eq false + sys_template.errors.first.field.should eq :schedule + end + + it "allows a default and multiple scheduled rows for the same pairing" do + template = Generator.signage_template.save! + sys = Generator.control_system.save! + + Generator.system_template(template: template, control_system: sys).save! + Generator.system_template(template: template, control_system: sys, schedule: Playlist::Schedule.new(play_cron: "0 9 * * *")).save! + Generator.system_template(template: template, control_system: sys, schedule: Playlist::Schedule.new(play_cron: "0 17 * * *")).save! + + SignageTemplate::SystemTemplate.where(control_system_id: sys.id.as(String)).count.should eq 3 + end + + it "rejects a second default for the same pairing" do + template = Generator.signage_template.save! + sys = Generator.control_system.save! + + Generator.system_template(template: template, control_system: sys).save! + + duplicate = Generator.system_template(template: template, control_system: sys) + duplicate.save.should eq false + duplicate.errors.first.field.should eq :schedule + + # the same template can still be the default on another system + other_sys = Generator.control_system.save! + Generator.system_template(template: template, control_system: other_sys).save.should eq true + end + + it "allows re-saving the existing default" do + sys_template = Generator.system_template.save! + + found = SignageTemplate::SystemTemplate.find!(sys_template.id.as(UUID)) + found.save.should eq true + end + + it "enforces the single default constraint at the database level" do + template = Generator.signage_template.save! + sys = Generator.control_system.save! + + Generator.system_template(template: template, control_system: sys).save! + scheduled = Generator.system_template(template: template, control_system: sys, schedule: Playlist::Schedule.new).save! + + # bypass model validation with a raw update; the partial unique index fires + expect_raises(::Exception) do + SignageTemplate::SystemTemplate + .where("id = ?", scheduled.id.as(UUID)) + .update_all({:schedule => nil}) + end + end + + it "is removed when the control system is deleted" do + sys = Generator.control_system.save! + sys_template = Generator.system_template(control_system: sys).save! + id = sys_template.id.as(UUID) + + sys.destroy + SignageTemplate::SystemTemplate.find?(id).should be_nil + end + + it "is removed when the template is deleted" do + template = Generator.signage_template.save! + sys_template = Generator.system_template(template: template).save! + id = sys_template.id.as(UUID) + + template.destroy + SignageTemplate::SystemTemplate.find?(id).should be_nil + end + + it "exposes attached systems via the template" do + template = Generator.signage_template.save! + sys = Generator.control_system.save! + Generator.system_template(template: template, control_system: sys).save! + + template.system_templates.count.should eq 1 + template.systems.to_a.map(&.id).should eq [sys.id] + end + end +end diff --git a/src/placeos-models/signage_plugin.cr b/src/placeos-models/signage_plugin.cr index 1dadf9fb..c2f57c2e 100644 --- a/src/placeos-models/signage_plugin.cr +++ b/src/placeos-models/signage_plugin.cr @@ -13,11 +13,19 @@ module PlaceOS::Model PLAYSTHROUGH end + enum PluginType + Plugin + Widget + end + attribute name : String, sanitize: :text, es_subfield: "keyword" attribute description : String = "", sanitize: :common attribute uri : String attribute playback_type : PlaybackType = PlaybackType::STATIC, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::SignagePlugin::PlaybackType) + # widgets are embeddable in signage templates; plugins play in playlists + attribute plugin_type : PluginType = PluginType::Plugin, converter: PlaceOS::Model::PGEnumConverter(PlaceOS::Model::SignagePlugin::PluginType) + belongs_to Authority, foreign_key: "authority_id" attribute enabled : Bool = true diff --git a/src/placeos-models/signage_template.cr b/src/placeos-models/signage_template.cr new file mode 100644 index 00000000..60e824f4 --- /dev/null +++ b/src/placeos-models/signage_template.cr @@ -0,0 +1,147 @@ +require "uuid" +require "uuid/json" + +require "./base/model" +require "./authority" +require "./history" +require "./signage_plugin" +require "./playlist" +require "./signage_template/layout" + +module PlaceOS::Model + # positions widget plugins over a signage display, changing its layout. + # Attached to displays via `SignageTemplate::SystemTemplate`. + class SignageTemplate < ::PgORM::Base + include PgORM::Timestamps + include Neuroplastic + + Log = ::Log.for(self) + + table :signage_template + + default_primary_key id : UUID, autogenerated: true + + attribute name : String, sanitize: :text, es_subfield: "keyword" + attribute description : String? = nil, sanitize: :common + attribute tags : Array(String) = [] of String, sanitize: :text, es_type: "keyword" + + attribute authority_id : String, es_type: "keyword" + belongs_to :authority, class_name: PlaceOS::Model::Authority + + # optional media item rendered behind the widgets + # (cleared at the DB level if the item is deleted: ON DELETE SET NULL) + attribute background_item_id : String? = nil, es_type: "keyword" + belongs_to :background_item, class_name: PlaceOS::Model::Playlist::Item, foreign_key: background_item_id + + # widget placements, stored as a JSONB array. Not indexed in elastic. + attribute layouts : Array(Layout) = [] of Layout, converter: PlaceOS::Model::DBArrConverter(PlaceOS::Model::SignageTemplate::Layout), es_ignore: true + + attribute full_screen_takeover : Bool = false + + # Helpers + ############################################################################################### + + def system_templates + SystemTemplate.where(template_id: self.id) + end + + def systems + sys_ids = system_templates.to_a.map(&.control_system_id).uniq! + ControlSystem.where(id: sys_ids) + end + + # Validation + ############################################################################################### + + validates :name, presence: true + validates :authority_id, presence: true + + # each layout must be structurally valid + validate ->(this : SignageTemplate) { + this.layouts.each_with_index do |layout, index| + if message = layout.validation_message + this.validation_error(:layouts, "layout #{index + 1}: #{message}") + end + end + } + + # layout plugins must be widgets belonging to the same authority and the + # provided params must satisfy the plugin's params schema + validate ->(this : SignageTemplate) { + plugin_ids = this.layouts.compact_map(&.plugin_id).uniq! + return if plugin_ids.empty? + + plugins = SignagePlugin.where(id: plugin_ids).to_a.index_by(&.id.as(String)) + + this.layouts.each_with_index do |layout, index| + next unless plugin_id = layout.plugin_id + + unless plugin = plugins[plugin_id]? + this.validation_error(:layouts, "layout #{index + 1}: plugin '#{plugin_id}' does not exist") + next + end + + unless plugin.plugin_type.widget? + this.validation_error(:layouts, "layout #{index + 1}: plugin '#{plugin_id}' is not a widget") + end + + unless plugin.authority_id == this.authority_id + this.validation_error(:layouts, "layout #{index + 1}: plugin '#{plugin_id}' must belong to the same authority") + end + + this.validate_layout_params(index, layout, plugin) + end + } + + protected def validate_layout_params(index : Int32, layout : Layout, plugin : SignagePlugin) + properties = plugin.params["properties"]?.try(&.as_h?) + + layout.plugin_params.each_key do |key| + unless properties.try(&.has_key?(key)) + validation_error(:layouts, "layout #{index + 1}: param '#{key}' does not exist in plugin params properties") + end + end + + if required = plugin.params["required"]?.try(&.as_a?) + merged = plugin.defaults.merge(layout.plugin_params) + required.each do |required_key| + next unless key = required_key.as_s? + unless merged.has_key?(key) + validation_error(:layouts, "layout #{index + 1}: missing required param '#{key}'") + end + end + end + end + + # History + ############################################################################################### + + before_save :record_template_history + + # tracks which fields changed on updates (creates are not recorded) + protected def record_template_history + return unless persisted? + + changed_fields = [] of String + changed_fields << "name" if name_changed? + changed_fields << "description" if description_changed? + changed_fields << "tags" if tags_changed? + changed_fields << "background_item_id" if background_item_id_changed? + changed_fields << "layouts" if layouts_changed? + changed_fields << "full_screen_takeover" if full_screen_takeover_changed? + + return if changed_fields.empty? + + History.create!( + type: "template", + resource_id: self.id.to_s, + action: "updated", + changed_fields: changed_fields, + ) + rescue ex + Log.error(exception: ex) { "failed to record signage template history for #{self.id}" } + end + end +end + +require "./signage_template/*" diff --git a/src/placeos-models/signage_template/layout.cr b/src/placeos-models/signage_template/layout.cr new file mode 100644 index 00000000..977213bf --- /dev/null +++ b/src/placeos-models/signage_template/layout.cr @@ -0,0 +1,73 @@ +require "json" + +require "../base/model" + +# forward declare so this file can be required before signage_template.cr +# finishes defining the parent class (the `attribute layouts : Array(Layout)` +# line needs Layout resolved at macro-expansion time). +class PlaceOS::Model::SignageTemplate < ::PgORM::Base; end + +module PlaceOS::Model + struct SignageTemplate::Layout + include JSON::Serializable + + enum Position + Top + Bottom + Left + Right + Floating + end + + # nil plugin_id means this layout entry is a spacer + getter plugin_id : String? = nil + getter position : Position = Position::Top + + # percentages of the screen, exclusive bounds (0, 1). + # Left/Right slices size via x_pos, Top/Bottom via y_pos, + # Floating widgets require both. + getter x_pos : Float32? = nil + getter y_pos : Float32? = nil + + getter plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any + + def initialize( + @position : Position = Position::Top, + @plugin_id : String? = nil, + @x_pos : Float32? = nil, + @y_pos : Float32? = nil, + @plugin_params : Hash(String, JSON::Any) = {} of String => JSON::Any, + ) + end + + def spacer? : Bool + plugin_id.nil? + end + + # `nil` when the layout is valid, otherwise a human-readable reason. + def validation_message : String? + if x = x_pos + return "x_pos must be greater than 0 and less than 1" unless 0.0_f32 < x < 1.0_f32 + end + + if y = y_pos + return "y_pos must be greater than 0 and less than 1" unless 0.0_f32 < y < 1.0_f32 + end + + case position + in .floating? + return "floating position requires x_pos and y_pos" if x_pos.nil? || y_pos.nil? + in .left?, .right? + return "#{position.to_s.downcase} position requires x_pos" if x_pos.nil? + in .top?, .bottom? + return "#{position.to_s.downcase} position requires y_pos" if y_pos.nil? + end + + nil + end + + def valid? : Bool + validation_message.nil? + end + end +end diff --git a/src/placeos-models/signage_template/system_template.cr b/src/placeos-models/signage_template/system_template.cr new file mode 100644 index 00000000..6dcec9b7 --- /dev/null +++ b/src/placeos-models/signage_template/system_template.cr @@ -0,0 +1,65 @@ +require "uuid" +require "uuid/json" + +require "../base/model" +require "../control_system" +require "../converter/json_string" +require "../playlist/schedule" +require "../signage_template" + +module PlaceOS::Model + # junction attaching a SignageTemplate to a ControlSystem. A row with a + # `schedule` shows the template during that schedule; a row without one is + # the default template for the pairing (at most one, enforced by a partial + # unique index). Surrogate UUID pk as the same template may be attached to + # the same system multiple times with different schedules. + class SignageTemplate::SystemTemplate < ::PgORM::Base + include PgORM::Timestamps + + table :system_templates + + default_primary_key id : UUID, autogenerated: true + + attribute control_system_id : String + belongs_to :control_system, class_name: PlaceOS::Model::ControlSystem, foreign_key: control_system_id + + attribute template_id : UUID + belongs_to :template, class_name: PlaceOS::Model::SignageTemplate, foreign_key: template_id + + # when the template applies; nil means this is the default template + attribute schedule : Playlist::Schedule? = nil, converter: OptionalRecordConverter(PlaceOS::Model::Playlist::Schedule) + + def default? : Bool + schedule.nil? + end + + # Validation + ############################################################################################### + + validates :control_system_id, presence: true + validates :template_id, presence: true + + # a schedule, when present, must be valid + validate ->(this : SystemTemplate) { + if schedule = this.schedule + if message = schedule.validation_message + this.validation_error(:schedule, message) + end + end + } + + # friendly pre-check for the partial unique index (the DB constraint + # remains the source of truth) + validate ->(this : SystemTemplate) { + return unless this.schedule.nil? + + existing = SystemTemplate + .where("control_system_id = ? AND template_id = ? AND schedule IS NULL", this.control_system_id, this.template_id) + .first? + return if existing.nil? + return if this.persisted? && existing.id == this.id + + this.validation_error(:schedule, "system already has a default entry for this template") + } + end +end