Add change management setup script

This commit is contained in:
Zac Gaetano 2026-07-01 12:46:19 -04:00
parent b66ddc9809
commit 6ea89f9674

228
change_mgmt.rb Normal file
View file

@ -0,0 +1,228 @@
# Change Management setup for Zammad (ITIL option 1)
# Run: rails runner change_mgmt.rb
UserInfo.current_user_id = 1
out = []
def log(out, msg); out << msg; puts msg; end
# ---------------------------------------------------------------------------
# 1. GROUP
# ---------------------------------------------------------------------------
grp = Group.find_by(name: 'Change Management')
if grp.nil?
grp = Group.create!(
name: 'Change Management',
note: 'ITIL Change Management workflow',
active: true
)
log(out, "GROUP created id=#{grp.id}")
else
log(out, "GROUP exists id=#{grp.id}")
end
# Grant admin (user 1) full access to the group
admin = User.find(1)
admin.group_names_access_map = (admin.group_names_access_map || {}).merge(
'Change Management' => ['full']
)
admin.save!
log(out, "GROUP access granted to admin")
# ---------------------------------------------------------------------------
# 2. STATES (change state machine)
# ---------------------------------------------------------------------------
open_type = Ticket::StateType.find_by(name: 'open')
pending_type = Ticket::StateType.find_by(name: 'pending action')
closed_type = Ticket::StateType.find_by(name: 'closed')
change_states = [
{ name: 'CM: Requested', type: open_type, order: 100, note: 'Change request submitted, awaiting CAB' },
{ name: 'CM: In Review', type: open_type, order: 101, note: 'Under CAB assessment' },
{ name: 'CM: Approved', type: open_type, order: 102, note: 'Approved by CAB, ready to schedule' },
{ name: 'CM: Scheduled', type: pending_type, order: 103, note: 'Approved and scheduled for a window' },
{ name: 'CM: Implementing', type: open_type, order: 104, note: 'Change in progress' },
{ name: 'CM: Review', type: open_type, order: 105, note: 'Post-implementation review' },
{ name: 'CM: Closed', type: closed_type, order: 106, note: 'Change complete and closed' },
{ name: 'CM: Rejected', type: closed_type, order: 107, note: 'Change rejected by CAB' },
{ name: 'CM: Failed', type: closed_type, order: 108, note: 'Change failed / rolled back' },
]
state_ids = {}
change_states.each do |s|
st = Ticket::State.find_by(name: s[:name])
if st.nil?
st = Ticket::State.create!(
name: s[:name],
state_type_id: s[:type].id,
note: s[:note],
active: true,
created_by_id: 1,
updated_by_id: 1
)
log(out, "STATE created #{s[:name]} id=#{st.id}")
else
log(out, "STATE exists #{s[:name]} id=#{st.id}")
end
state_ids[s[:name]] = st.id
end
# ---------------------------------------------------------------------------
# 3. OBJECT FIELDS (custom ticket attributes for change data)
# ---------------------------------------------------------------------------
def add_attr(out, name, display, data_type, data_option)
existing = ObjectManager::Attribute.get(object: 'Ticket', name: name)
if existing
log(out, "ATTR exists #{name}")
return
end
ObjectManager::Attribute.add(
object: 'Ticket',
name: name,
display: display,
data_type: data_type,
data_option: data_option,
active: true,
screens: {
create_middle: { '-all-' => { null: true, shown: true } },
edit: { '-all-' => { null: true, shown: true } },
},
position: 800,
editable: true
)
log(out, "ATTR created #{name}")
end
add_attr(out, 'change_type', 'Change Type', 'select', {
default: 'normal',
options: { 'standard' => 'Standard (pre-approved)', 'normal' => 'Normal', 'emergency' => 'Emergency' },
nulloption: false, maxlength: 255, null: false
})
add_attr(out, 'change_risk', 'Risk Level', 'select', {
default: 'medium',
options: { 'low' => 'Low', 'medium' => 'Medium', 'high' => 'High' },
nulloption: false, maxlength: 255, null: false
})
add_attr(out, 'change_impact', 'Impact', 'select', {
default: 'medium',
options: { 'low' => 'Low', 'medium' => 'Medium', 'high' => 'High' },
nulloption: false, maxlength: 255, null: false
})
add_attr(out, 'change_rollback', 'Rollback Plan', 'textarea', {
type: 'textarea', maxlength: 5000, rows: 4, null: true
})
add_attr(out, 'change_start', 'Scheduled Start', 'datetime', {
future: true, past: false, diff: 0, null: true
})
add_attr(out, 'change_end', 'Scheduled End', 'datetime', {
future: true, past: false, diff: 0, null: true
})
add_attr(out, 'change_cab_decision', 'CAB Decision', 'select', {
default: 'pending',
options: { 'pending' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected' },
nulloption: false, maxlength: 255, null: false
})
# Apply the DB migration for the new columns
if ObjectManager::Attribute.pending_migration?
ObjectManager::Attribute.migration_execute
log(out, "MIGRATION executed")
else
log(out, "MIGRATION not needed")
end
# ---------------------------------------------------------------------------
# 4. OVERVIEWS (saved views for the Change Management group)
# ---------------------------------------------------------------------------
cm_group = Group.find_by(name: 'Change Management')
def add_overview(out, name, link, condition, order)
ov = Overview.find_by(link: link)
if ov
log(out, "OVERVIEW exists #{name}")
return
end
Overview.create!(
name: name,
link: link,
prio: order,
role_ids: Role.where(name: ['Admin', 'Agent']).pluck(:id),
condition: condition,
order: { by: 'created_at', direction: 'DESC' },
view: { d: %w[number title change_type change_risk state created_at], s: %w[number title change_type state created_at], m: %w[number title state] },
active: true,
created_by_id: 1,
updated_by_id: 1
)
log(out, "OVERVIEW created #{name}")
end
add_overview(out, 'CM: Pending CAB', 'cm_pending_cab', {
'ticket.group_id' => { operator: 'is', value: [cm_group.id.to_s] },
'ticket.change_cab_decision' => { operator: 'is', value: ['pending'] },
'ticket.state_id' => { operator: 'is not', value: Ticket::State.where(name: ['CM: Closed', 'CM: Rejected', 'CM: Failed']).pluck(:id).map(&:to_s) },
}, 1000)
add_overview(out, 'CM: Scheduled', 'cm_scheduled', {
'ticket.group_id' => { operator: 'is', value: [cm_group.id.to_s] },
'ticket.state_id' => { operator: 'is', value: Ticket::State.where(name: ['CM: Scheduled', 'CM: Approved']).pluck(:id).map(&:to_s) },
}, 1001)
add_overview(out, 'CM: All Open', 'cm_all_open', {
'ticket.group_id' => { operator: 'is', value: [cm_group.id.to_s] },
'ticket.state_id' => { operator: 'is not', value: Ticket::State.where(name: ['CM: Closed', 'CM: Rejected', 'CM: Failed']).pluck(:id).map(&:to_s) },
}, 1002)
# ---------------------------------------------------------------------------
# 5. TRIGGERS (approval gate + notifications)
# ---------------------------------------------------------------------------
def add_trigger(out, name, condition, perform)
tr = Trigger.find_by(name: name)
if tr
log(out, "TRIGGER exists #{name}")
return
end
Trigger.create!(
name: name,
condition: condition,
perform: perform,
active: true,
created_by_id: 1,
updated_by_id: 1
)
log(out, "TRIGGER created #{name}")
end
approved_state = Ticket::State.find_by(name: 'CM: Approved')
add_trigger(out, 'CM: CAB approved to Approved', {
'ticket.group_id' => { operator: 'is', value: cm_group.id.to_s },
'ticket.change_cab_decision' => { operator: 'is', value: 'approved' },
}, {
'ticket.state_id' => { value: approved_state.id.to_s },
'notification.email' => {
recipient: 'ticket_owner',
subject: 'Change #{ticket.number} approved by CAB',
body: 'Change request "#{ticket.title}" has been approved. Proceed to schedule and implement.'
}
})
rejected_state = Ticket::State.find_by(name: 'CM: Rejected')
add_trigger(out, 'CM: CAB rejected to Rejected', {
'ticket.group_id' => { operator: 'is', value: cm_group.id.to_s },
'ticket.change_cab_decision' => { operator: 'is', value: 'rejected' },
}, {
'ticket.state_id' => { value: rejected_state.id.to_s },
'notification.email' => {
recipient: 'ticket_owner',
subject: 'Change #{ticket.number} rejected by CAB',
body: 'Change request "#{ticket.title}" was rejected. See ticket notes for details.'
}
})
File.write('/tmp/cm_result.txt', out.join("\n"))
puts "==DONE=="