252 lines
6.9 KiB
Go
252 lines
6.9 KiB
Go
package glpi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
)
|
|
|
|
// Change status constants (GLPI change lifecycle).
|
|
const (
|
|
StatusNew = 1
|
|
StatusEvaluation = 2
|
|
StatusApproval = 3
|
|
StatusTest = 4
|
|
StatusQualification = 5
|
|
StatusWaiting = 6
|
|
StatusAccepted = 7
|
|
StatusAssigned = 8
|
|
StatusPlanned = 9
|
|
StatusPending = 10
|
|
StatusSolved = 11
|
|
StatusClosed = 12
|
|
)
|
|
|
|
// statusNames maps status ids to human names (CLI display + name-based
|
|
// transitions; GLPI has no enumeration endpoint for these).
|
|
var statusNames = map[int]string{
|
|
StatusNew: "new",
|
|
StatusEvaluation: "evaluation",
|
|
StatusApproval: "approval",
|
|
StatusTest: "test",
|
|
StatusQualification: "qualification",
|
|
StatusWaiting: "waiting",
|
|
StatusAccepted: "accepted",
|
|
StatusAssigned: "assigned",
|
|
StatusPlanned: "planned",
|
|
StatusPending: "pending",
|
|
StatusSolved: "solved",
|
|
StatusClosed: "closed",
|
|
}
|
|
|
|
// StatusName renders a status id ("new", "solved", ...) or "status N".
|
|
func StatusName(id int) string {
|
|
if n, ok := statusNames[id]; ok {
|
|
return n
|
|
}
|
|
return fmt.Sprintf("status %d", id)
|
|
}
|
|
|
|
// StatusID resolves a status name case-insensitively ("Solved" -> 11).
|
|
func StatusID(name string) (int, bool) {
|
|
for id, n := range statusNames {
|
|
if eqfold(n, name) {
|
|
return id, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// Change is GLPI's read shape for one change (GET /change/<id>).
|
|
type Change struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
Status int `json:"status"`
|
|
Urgency int `json:"urgency"`
|
|
Impact int `json:"impact"`
|
|
Date string `json:"date"`
|
|
DateMod string `json:"date_mod"`
|
|
}
|
|
|
|
// ChangeRow is one row of a change search (fields 1 name, 2 id,
|
|
// 12 status).
|
|
type ChangeRow struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Status int `json:"status"`
|
|
}
|
|
|
|
// CreateChange creates a change (POST /change/) and returns its id.
|
|
//
|
|
// GLPI contract modeled faithfully: the payload is {"input":{...}} — an
|
|
// OBJECT (the ITILFollowup endpoint is the array-flavored one) — and
|
|
// the reply is an ARRAY: [{"id":N,"message":"..."}]. urgency and impact
|
|
// are GLPI's 1..5 scales (3 = medium).
|
|
func (c *Client) CreateChange(ctx context.Context, name, content string, urgency, impact int) (int, error) {
|
|
if name == "" {
|
|
return 0, fmt.Errorf("%w: change name required", ErrValidation)
|
|
}
|
|
if urgency < 1 || urgency > 5 || impact < 1 || impact > 5 {
|
|
return 0, fmt.Errorf("%w: urgency/impact must be 1..5", ErrValidation)
|
|
}
|
|
input := map[string]any{
|
|
"name": name,
|
|
"content": content,
|
|
}
|
|
if urgency != 0 {
|
|
input["urgency"] = urgency
|
|
}
|
|
if impact != 0 {
|
|
input["impact"] = impact
|
|
}
|
|
ids, err := c.postCreate(ctx, "/change", map[string]any{"input": input})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(ids) == 0 || ids[0] == 0 {
|
|
return 0, fmt.Errorf("%w: create returned no id", ErrMalformedResponse)
|
|
}
|
|
return ids[0], nil
|
|
}
|
|
|
|
// postCreate sends a create payload and parses GLPI's ARRAY reply
|
|
// [{"id":N,"message":"..."}] into ids.
|
|
func (c *Client) postCreate(ctx context.Context, path string, payload any) ([]int, error) {
|
|
var arr []struct {
|
|
ID int `json:"id"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := c.call(ctx, http.MethodPost, path, nil, payload, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int, 0, len(arr))
|
|
for _, a := range arr {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// GetChange fetches one change (GET /change/<id>).
|
|
func (c *Client) GetChange(ctx context.Context, id int) (*Change, error) {
|
|
var ch Change
|
|
if err := c.call(ctx, http.MethodGet, "/change/"+itoa(id), nil, nil, &ch); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ch, nil
|
|
}
|
|
|
|
// ListChanges searches changes (GET /search/Change/) with forcedisplay
|
|
// fields 1 (name), 2 (id), 12 (status). GLPI returns rows as OBJECTS
|
|
// KEYED BY FIELD-ID STRING — parsed here into ChangeRow. status 0 lists
|
|
// all; any other value filters field 12 with an equals criterion.
|
|
func (c *Client) ListChanges(ctx context.Context, status int) ([]ChangeRow, error) {
|
|
q := url.Values{}
|
|
forced := []string{"1", "2", "12"}
|
|
for _, f := range forced {
|
|
q.Add("forcedisplay[]", f)
|
|
}
|
|
if status > 0 {
|
|
crit, err := json.Marshal([]map[string]any{{
|
|
"field": 12, "searchtype": "equals", "value": status,
|
|
}})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: cannot encode criteria", ErrMalformedResponse)
|
|
}
|
|
q.Set("criteria", string(crit))
|
|
}
|
|
var body struct {
|
|
Total int `json:"totalcount"`
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := c.call(ctx, http.MethodGet, "/search/Change", q, nil, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
rows := make([]ChangeRow, 0, len(body.Data))
|
|
for _, d := range body.Data {
|
|
rows = append(rows, ChangeRow{
|
|
Name: asString(d["1"]),
|
|
ID: asInt(d["2"]),
|
|
Status: asInt(d["12"]),
|
|
})
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// TransitionChange moves a change to a status (PUT /change/<id> with
|
|
// {"input":{"status":N}}). GLPI answers 200 with an array whose members
|
|
// carry a boolean result — a false anywhere is a failed update.
|
|
func (c *Client) TransitionChange(ctx context.Context, id, status int) error {
|
|
if status < StatusNew || status > StatusClosed {
|
|
return fmt.Errorf("%w: unknown change status %d", ErrValidation, status)
|
|
}
|
|
payload := map[string]any{"input": map[string]any{"status": status}}
|
|
var arr []map[string]any
|
|
if err := c.call(ctx, http.MethodPut, "/change/"+itoa(id), nil, payload, &arr); err != nil {
|
|
return err
|
|
}
|
|
for _, m := range arr {
|
|
for _, v := range m {
|
|
if b, ok := v.(bool); ok && !b {
|
|
return fmt.Errorf("%w: transition rejected", ErrValidation)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddFollowup appends a followup note to a change (POST /ITILFollowup).
|
|
//
|
|
// GLPI contract modeled faithfully: this endpoint REQUIRES the input to
|
|
// be an ARRAY of objects — {"input":[{...}]} — where the Change-create
|
|
// endpoint takes the object form.
|
|
func (c *Client) AddFollowup(ctx context.Context, changeID int, content string) error {
|
|
if content == "" {
|
|
return fmt.Errorf("%w: followup content required", ErrValidation)
|
|
}
|
|
payload := map[string]any{
|
|
"input": []map[string]any{{
|
|
"itemtype": "Change",
|
|
"items_id": changeID,
|
|
"content": content,
|
|
}},
|
|
}
|
|
_, err := c.postCreate(ctx, "/ITILFollowup", payload)
|
|
return err
|
|
}
|
|
|
|
// asString coerces a search-row cell (string or number) to string.
|
|
func asString(v any) string {
|
|
switch s := v.(type) {
|
|
case string:
|
|
return s
|
|
case float64:
|
|
return trimFloat(s)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
// asInt coerces a search-row cell (number or numeric string) to int.
|
|
// GLPI search cells arrive as JSON numbers or strings depending on the
|
|
// field; both must parse.
|
|
func asInt(v any) int {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
case string:
|
|
i, _ := parseLeadingInt(n)
|
|
return i
|
|
case nil:
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|