From 6ea89f9674d83a07e01e1536405ac0ea079fd713 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Wed, 1 Jul 2026 12:46:19 -0400 Subject: [PATCH] Add change management setup script --- change_mgmt.rb | 228 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 change_mgmt.rb diff --git a/change_mgmt.rb b/change_mgmt.rb new file mode 100644 index 0000000..2a99848 --- /dev/null +++ b/change_mgmt.rb @@ -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=="