Project

General

Profile

« Previous | Next » 

Revision a36689ab

Added by Ohad Levy about 7 years ago

fixes #18681 - moves polymorphic subject into notification object

also adds host build, destroyed and missing owner UI notifications

View differences:

app/models/concerns/hostext/ui_notifications.rb
module Hostext
module UINotifications
extend ActiveSupport::Concern
included do
before_provision :provision_notification
before_destroy :remove_ui_notifications
end
def provision_notification
::UINotifications::Hosts::BuildCompleted.deliver!(self) if just_provisioned?
end
def remove_ui_notifications
::UINotifications::Hosts::Destroy.deliver!(self)
end
def just_provisioned?
!!previous_changes['installed_at']
end
end
end
app/models/host/managed.rb
# Define custom hook that can be called in model by magic methods (before, after, around)
define_model_callbacks :build, :only => :after
define_model_callbacks :provision, :only => :before
include Hostext::UINotifications
before_validation :refresh_build_status, :if => :build_changed?
......
end
def build_hooks
return unless respond_to?(:old) && old && (build? != old.build?)
return if previous_changes['build'].nil?
if build?
run_callbacks :build do
logger.debug "custom hook after_build on #{name} will be executed if defined."
app/models/notification.rb
belongs_to :notification_blueprint
belongs_to :initiator, :class_name => User, :foreign_key => 'user_id'
belongs_to :subject, :polymorphic => true
has_many :notification_recipients, :dependent => :delete_all
has_many :recipients, :class_name => User, :through => :notification_recipients, :source => :user
store :actions, :accessors => [:links], :coder => JSON
validates :notification_blueprint, :presence => true
validates :initiator, :presence => true
......
:in => [AUDIENCE_USER, AUDIENCE_GROUP, AUDIENCE_TAXONOMY,
AUDIENCE_GLOBAL, AUDIENCE_ADMIN]
}, :presence => true
validates :message, :presence => true
before_validation :set_custom_attributes
before_create :set_expiry, :set_notification_recipients,
:set_default_initiator
......
when AUDIENCE_GLOBAL
User.reorder('').pluck(:id)
when AUDIENCE_TAXONOMY
notification_blueprint.subject.user_ids.uniq
subject.user_ids.uniq
when AUDIENCE_USER
[initiator.id]
when AUDIENCE_ADMIN
User.only_admin.reorder('').uniq.pluck(:id)
when AUDIENCE_GROUP
notification_blueprint.subject.all_users.uniq.map(&:id) # This needs to be rewritten in usergroups.
subject.all_users.uniq.map(&:id) # This needs to be rewritten in usergroups.
end
end
......
subscribers = subscriber_ids
notification_recipients.build subscribers.map{|id| { :user_id => id}}
end
def set_custom_attributes
return unless notification_blueprint # let validation catch this.
self.actions = UINotifications::URLResolver.new(
subject,
notification_blueprint.actions
).actions if notification_blueprint.actions.any?
# copy notification message in case we didn't create a custom one.
self.message ||= UINotifications::StringParser.new(
notification_blueprint.message,
{subject: subject, initator: initiator}
).to_s
end
end
app/models/notification_blueprint.rb
# NotificationBlueprint of storing it.
class NotificationBlueprint < ActiveRecord::Base
has_many :notifications, :dependent => :destroy
belongs_to :subject, :polymorphic => true
store :actions, :accessors => [:links], :coder => JSON
validates :message, :presence => true
app/models/notification_recipient.rb
:id => id,
:seen => seen,
:level => notification_blueprint.level,
:text => notification_blueprint.message,
:subject => notification_blueprint.subject,
:text => notification.message,
:created_at => notification.created_at,
:group => notification_blueprint.group,
:actions => notification_blueprint.actions
:actions => notification.actions
}
end
app/services/ui_notifications.rb
# Namespace for Foreman UI notifications event handling
module UINotifications
class Base
attr_reader :subject
def self.deliver!(subject)
new(subject).deliver!
rescue => e
# Do not break actions using notifications even if there is a failure.
logger.warn("Failed to handle notifications - this is most likely a bug: #{e}")
end
def self.logger
@logger ||= Foreman::Logging.logger('notifications')
end
def initialize(subject)
raise(Foreman::Exception, 'must provide notification subject') if subject.nil?
@subject = subject
end
def deliver!
logger.debug("Notification event: #{event} - checking for notifications")
create
end
protected
def event
self.class.name.sub(/^\w+::/,'')
end
# Defaults to anonymous api admin, override in subclasses as needed.
def initiator
User.anonymous_api_admin
end
def logger
self.class.logger
end
end
end
app/services/ui_notifications/hosts.rb
module UINotifications
module Hosts
class Base < UINotifications::Base
def deliver!
if audience.nil? || initiator.nil?
logger.warn("Invalid owner for #{subject}, unable to send notifications")
# add notification for missing owner
UINotifications::Hosts::MissingOwner.deliver!(subject)
return false
end
super
end
protected
def audience
case subject.owner_type
when 'User'
::Notification::AUDIENCE_USER
when 'Usergroup'
::Notification::AUDIENCE_GROUP
end
end
def initiator
case subject.owner
when User
subject.owner
when Usergroup
# Usergroup, picking the first user, in theory can look in the audit
# log to see who set the host on built, but since its a group
# all of the users are going to get a notification.
subject.owner.all_users.first
end
end
end
end
end
app/services/ui_notifications/hosts/build_completed.rb
module UINotifications
module Hosts
class BuildCompleted < Base
private
def create
add_notification if update_notifications.zero?
end
def add_notification
::Notification.create!(
initiator: initiator,
subject: subject,
audience: audience,
notification_blueprint: blueprint
)
end
def update_notifications
blueprint.notifications.
where(subject: subject).
update_all(expired_at: blueprint.expired_at)
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_build_completed')
end
end
end
end
app/services/ui_notifications/hosts/destroy.rb
module UINotifications
module Hosts
class Destroy < Base
private
def create
# I'm defaulting to deleting older notifications as it may
# contain links to non existing actions.
delete_others
Notification.create!(
initiator: initiator,
audience: audience,
# note we do not store the subject, as the object is being deleted.
message: StringParser.new(blueprint.message, {subject: subject}),
notification_blueprint: blueprint
)
end
def delete_others
logger.debug("Removing all notifications for host: #{subject}")
Notification.where(subject: subject).destroy_all
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_destroyed')
end
end
end
end
app/services/ui_notifications/hosts/missing_owner.rb
module UINotifications
module Hosts
class MissingOwner < UINotifications::Base
private
def create
add_notification if update_notifications.zero?
end
def update_notifications
blueprint.notifications.
where(subject: subject).
update_all(expired_at: blueprint.expired_at)
end
def add_notification
Notification.create!(
initiator: initiator,
audience: ::Notification::AUDIENCE_ADMIN,
subject: subject,
notification_blueprint: blueprint
)
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_missing_owner')
end
end
end
end
app/services/ui_notifications/seed.rb
module UINotifications
# seeds UI notification blueprints that are supported by Foreman.
class Seed
attr_reader :attributes
def initialize(blueprint_attributes)
@attributes = blueprint_attributes
end
def configure
blueprint = NotificationBlueprint.find_by(name: attributes[:name])
if blueprint
blueprint.update_attributes!(attributes)
else
NotificationBlueprint.create!(attributes)
end
end
end
end
app/services/ui_notifications/string_parser.rb
module UINotifications
# class to convert blueprint and url title templates into strings
class StringParser
def initialize(template, options = {})
@template = template
@options = options
end
def to_s
template % options
end
private
attr_reader :template, :options
end
end
app/services/ui_notifications/url_resolver.rb
module UINotifications
# interpolates blueprint links for notifications
class URLResolver
# needed in order to resolve rails urls for notifications
include Rails.application.routes.url_helpers
def initialize(subject, actions = nil)
@subject = subject
@raw_actions = actions
end
def actions
return if raw_actions.try(:[], :links).nil?
links = raw_actions[:links].map do |link|
validate_title link
if link.has_key? :href
link
elsif link.has_key? :path_method
validate_link(link)
parse_link(link[:path_method], link[:title])
end
end
{links: links}
end
private
attr_reader :subject, :raw_actions
def parse_link path_method, title
{
href: path_for(path_method),
title: StringParser.new(title, {subject: subject}).to_s
}
end
def validate_title link
if link[:title].blank?
raise(Foreman::Exception, "Invalid link, must contain :title")
end
end
def validate_link link
path_method = link[:path_method]
unless path_method.to_s.match(/_path$/)
raise(Foreman::Exception, "Invalid path_method #{path_method}, must end with _path")
end
end
def path_for path_method
if collection_path?(path_method)
public_send(path_method)
else
public_send(path_method, subject)
end
end
def collection_path? path
path.to_s.sub(/_path$/,'').ends_with?('s')
end
end
end
config/application.rb
:ldap => {:enabled => false},
:permissions => {:enabled => false},
:sql => {:enabled => false},
:templates => {:enabled => true}
:templates => {:enabled => true},
:notifications => {:enabled => true}
))
config.logger = Foreman::Logging.logger('app')
config/settings.yaml.example
# :enabled: false
# :templates:
# :enabled: true
# :notifications:
# :enabled: true
db/migrate/20170226193446_move_subject_to_notifications.rb
class MoveSubjectToNotifications < ActiveRecord::Migration
def change
add_reference :notifications, :subject, polymorphic: true, index: true
remove_reference :notification_blueprints, :subject, polymorphic: true, index: true
end
end
db/migrate/20170306100129_add_message_to_notification.rb
class AddMessageToNotification < ActiveRecord::Migration
def change
add_column :notifications, :message, :string
add_column :notifications, :actions, :text
end
end
db/seeds.d/17-notification_blueprints.rb
blueprints = [
{
group: _('Hosts'),
name: 'host_build_completed',
message: _('%{subject} has been provisioned successfully'),
level: 'success',
actions:
{
links:
[
path_method: :host_path,
title: _('Details')
]
}
},
{
group: _('Hosts'),
name: 'host_destroyed',
message: _('%{subject} has been deleted successfully'),
level: 'info'
},
{
group: _('Hosts'),
name: 'host_missing_owner',
message: _('%{subject} has no owner set'),
level: 'warning',
actions:
{
links:
[
path_method: :edit_host_path,
title: _('Update host')
]
}
}
]
blueprints.each { |blueprint| UINotifications::Seed.new(blueprint).configure }
test/controllers/notification_recipients_controller_test.rb
assert_equal 0, response['total']
end
test "notification when host is destroyed" do
host = FactoryGirl.create(:host)
assert host.destroy
get :index, { :format => 'json' }, set_session_user
assert_response :success
response = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, response['total']
assert_equal "#{host} has been deleted successfully", response['notifications'][0]["text"]
end
test "notification when host is built" do
host = FactoryGirl.create(:host, owner: User.current)
assert host.update_attribute(:build, true)
assert host.built
get :index, { :format => 'json' }, set_session_user
assert_response :success
response = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, response['total']
assert_equal "#{host} has been provisioned successfully", response['notifications'][0]["text"]
end
test "notification when host has no owner" do
host = FactoryGirl.create(:host, :managed)
User.current = nil
assert host.update_attributes(owner_id: nil, owner_type: nil, build: true)
assert_nil host.owner
assert host.built
get :index, { :format => 'json' }, set_session_user
assert_response :success
response = ActiveSupport::JSON.decode(@response.body)
assert_equal 1, response['total']
assert_equal "#{host} has no owner set", response['notifications'][0]["text"]
end
private
def add_notification
test/factories/notification.rb
notification_blueprint
association :initiator, :factory => :user
audience 'user'
subject User.first
end
end
test/factories/notification_blueprint.rb
sequence(:group) { |n| "notification_blueprint_#{n}" }
sequence(:message) { |n| "message_#{n}" }
sequence(:name) { |n| "name_#{n}" }
subject User.first
level 'info'
expires_in 24.hours
end
test/integration_test_helper.rb
require 'database_cleaner'
require 'active_support_test_case_helper'
require 'minitest-optional_retry'
# load notification blueprint seeds
require File.join(Rails.root,'db','seeds.d','17-notification_blueprints.rb')
DatabaseCleaner.strategy = :transaction
test/models/notification_test.rb
test 'should be able to create notification' do
blueprint = FactoryGirl.create(
:notification_blueprint,
:message => 'this test just executed successfully',
:subject => nil
:message => 'this test just executed successfully'
)
notice = FactoryGirl.create(:notification,
:audience => 'global',
:notification_blueprint => blueprint)
assert notice.valid?
assert_equal blueprint.message, notice.notification_blueprint.message
assert_equal blueprint.message, notice.message
assert_equal User.all, notice.recipients
end
......
group.users = FactoryGirl.create_list(:user,25)
notification = FactoryGirl.build(:notification,
:audience => Notification::AUDIENCE_GROUP)
notification.notification_blueprint.subject = group
notification.subject = group
assert group.all_users.any?
assert_equal group.all_users.map(&:id),
notification.subscriber_ids
......
org.users = FactoryGirl.create_list(:user,25)
notification = FactoryGirl.build(:notification,
:audience => Notification::AUDIENCE_TAXONOMY)
notification.notification_blueprint.subject = org
notification.subject = org
assert org.user_ids.any?
assert_equal org.user_ids, notification.subscriber_ids
end
......
loc.users = FactoryGirl.create_list(:user,25)
notification = FactoryGirl.build(:notification,
:audience => Notification::AUDIENCE_TAXONOMY)
notification.notification_blueprint.subject = loc
notification.subject = loc
assert loc.user_ids.any?
assert_equal loc.user_ids, notification.subscriber_ids
end
......
assert_equal User.only_admin.reorder('').pluck(:id).sort,
notification.subscriber_ids.sort
end
test 'notification message should be stored' do
host = FactoryGirl.create(:host)
blueprint = FactoryGirl.create(
:notification_blueprint,
:message => "%{subject} has been lost",
:level => 'error'
)
notice = FactoryGirl.create(:notification,
:audience => 'global',
:subject => host,
:notification_blueprint => blueprint)
assert_equal "#{host} has been lost", notice.message
end
end
test/test_helper.rb
require 'facet_test_helper'
require 'active_support_test_case_helper'
# load notification blueprint seeds
require File.join(Rails.root,'db','seeds.d','17-notification_blueprints.rb')
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :minitest_4
test/unit/ui_notifications/base_test.rb
require 'test_helper'
class UINotificationsTest < ActiveSupport::TestCase
test 'notification should raise without a subject' do
assert_raise(Foreman::Exception) { UINotifications::Base.new(nil) }
end
test 'notification logger exists' do
assert_equal 'notifications', Foreman::Logging.logger('notifications').name
end
test 'event should default to class name' do
class Event < ::UINotifications::Base; end
assert_equal "Event", Event.new(Object.new).send(:event)
end
end
test/unit/ui_notifications/hosts/base_test.rb
require 'test_helper'
class UINotificationsHostsTest < ActiveSupport::TestCase
test 'notification audience should be user' do
host.owner = FactoryGirl.build(:user)
assert_equal 'user', audience
end
test 'notification audience should be usergroup' do
host.owner = FactoryGirl.build(:usergroup)
assert_equal 'usergroup', audience
end
test 'notification audience should be nil if there is no owner' do
host.owner = nil
assert_nil audience
end
test 'deliver! should not run if audience is nil' do
host.owner = nil
assert !base.deliver!
end
private
def host
@host ||= FactoryGirl.build(:host, :managed)
end
def base
UINotifications::Hosts::Base.new(host)
end
def audience
base.send(:audience)
end
end
test/unit/ui_notifications/hosts/build_completed_test.rb
require 'test_helper'
class UINotificationsHostsBuildCompletedTest < ActiveSupport::TestCase
test 'create new host build notification' do
host.update_attribute(:build, true)
assert_difference("blueprint.notifications.where(:subject => host).count", 1) do
assert host.built
end
end
test 'multiple build events should update current build notification' do
assert_difference("Notification.where(:subject => host).count", 1) do
host.built
host.setBuild
host.built
end
end
private
def host
@host ||= FactoryGirl.create(:host, :managed)
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_build_completed')
end
end
test/unit/ui_notifications/hosts/destroy_test.rb
require 'test_helper'
class UINotificationsHostsDestroyTest < ActiveSupport::TestCase
test 'destroying a host should create a notification' do
assert_equal 0, Notification.where(:subject => host).count
host.destroy
# destory events do not store subject as its being deleted.
assert_equal 0, Notification.where(:subject => host).count
assert_equal 1, Notification.where(
notification_blueprint: blueprint,
message: "#{host} has been deleted successfully"
).count
end
test 'destorying host should remove other notification' do
assert_difference("Notification.where(:subject => host).count", 1) do
UINotifications::Hosts::BuildCompleted.deliver!(host)
end
UINotifications::Hosts::Destroy.deliver!(host)
assert_equal 0, Notification.where(:subject => host).count
assert_equal 1, blueprint.notifications.count
end
private
def host
@host ||= FactoryGirl.create(:host, :managed)
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_destroyed')
end
end
test/unit/ui_notifications/hosts/missing_owner_test.rb
require 'test_helper'
class UINotificationsHostsMissingOwnerTest < ActiveSupport::TestCase
test 'add missing host owner notification' do
assert_difference("Notification.where(:subject => host).count", 1) do
UINotifications::Hosts::MissingOwner.deliver!(host)
end
end
test 'multiple build events should update current build notification' do
assert_difference("Notification.where(:subject => host).count", 1) do
UINotifications::Hosts::MissingOwner.deliver!(host)
UINotifications::Hosts::MissingOwner.deliver!(host)
end
end
private
def host
@host ||= FactoryGirl.create(:host, :managed)
end
def blueprint
@blueprint ||= NotificationBlueprint.find_by(name: 'host_missing_owner')
end
end
test/unit/ui_notifications/string_parser_test.rb
require 'test_helper'
class UINotificationsTest < ActiveSupport::TestCase
test 'should parse a messsage with a subject' do
subject = FactoryGirl.create(:host)
template = "hello %{subject}"
options = {subject: subject}
resolver = UINotifications::StringParser.new(template, options)
assert_equal "hello #{subject}", resolver.to_s
end
test 'should parse a messsage with a subject twice' do
subject = FactoryGirl.create(:host)
template = "hello %{subject} / %{subject}"
options = {subject: subject}
resolver = UINotifications::StringParser.new(template, options)
assert_equal "hello #{subject} / #{subject}", resolver.to_s
end
end
test/unit/ui_notifications/url_resolver_test.rb
require 'test_helper'
class UINotificationsTest < ActiveSupport::TestCase
test 'should parse a hard coded url' do
actions = {links: [ {href: '/static_path', title: 'some hard coded url'}] }
resolver = UINotifications::URLResolver.new(subject, actions)
assert_equal actions, resolver.actions
end
test 'should parse a dynamic url for a given subject' do
subject = FactoryGirl.create(:host)
actions = {links: [ {path_method: :host_path, title: 'link_to_host'}] }
resolver = UINotifications::URLResolver.new(subject, actions)
assert_equal resolver.actions, {links: [{href: "/hosts/#{subject}", title: 'link_to_host'}]}
end
test 'should parse a url title for a given subject' do
subject = FactoryGirl.create(:host)
actions = {links: [ {path_method: :edit_host_path, title: "edit %{subject}"}] }
resolver = UINotifications::URLResolver.new(subject, actions)
assert_equal resolver.actions, {links: [{href: "/hosts/#{subject}/edit", title: "edit #{subject}"}]}
end
test 'it should not accept path_method that does not edit with _path' do
actions = {links: [ {path_method: :somewhere, title: 'aha'} ] }
assert_raise(Foreman::Exception) { UINotifications::URLResolver.new(subject, actions).actions }
end
test 'it should not accept path_method that does not edit with _path' do
actions = {links: [ {path_method: :somewhere, title: 'aha'} ] }
assert_raise(Foreman::Exception) { UINotifications::URLResolver.new(subject, actions).actions }
end
test 'it should not accept empty titles' do
actions = {links: [ {href: '/somewhere'} ] }
assert_raise(Foreman::Exception) { UINotifications::URLResolver.new(subject, actions).actions }
end
test 'it should link to a collection url' do
actions = {links: [ {path_method: :bookmarks_path, title: 'bookmarks'} ] }
resolver = UINotifications::URLResolver.new(subject, actions)
assert_equal resolver.actions, {links: [{href: "/bookmarks", title: "bookmarks"}]}
end
end

Also available in: Unified diff