add maintenance windows REST API and GraphQL support

REST endpoints for CRUD, scoped filtering (active/upcoming/past).
GraphQL queries and mutations for maintenance windows.
Fix agent edit test assertion message.
This commit is contained in:
Graham McIntire 2026-03-11 14:33:25 -05:00
parent f6e9063577
commit 17fa005781
No known key found for this signature in database
8 changed files with 600 additions and 1 deletions

View file

@ -0,0 +1,163 @@
defmodule ToweropsWeb.Api.V1.MaintenanceWindowsController do
@moduledoc """
API controller for managing maintenance windows.
All endpoints require API token authentication and operations are scoped
to the organization associated with the token.
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Maintenance
alias Towerops.Maintenance.MaintenanceWindow
alias ToweropsWeb.ScopedResource
@doc "GET /api/v1/maintenance_windows"
def index(conn, params) do
organization_id = conn.assigns.current_organization_id
filter = Map.get(params, "filter")
opts =
case filter do
"active" -> [filter: :active]
"upcoming" -> [filter: :upcoming]
"past" -> [filter: :past]
_ -> []
end
windows =
organization_id
|> Maintenance.list_windows(opts)
|> Enum.map(&format_window/1)
json(conn, %{maintenance_windows: windows})
end
@doc "POST /api/v1/maintenance_windows"
def create(conn, %{"maintenance_window" => window_params}) do
organization_id = conn.assigns.current_organization_id
user_id = conn.assigns.current_user.id
attrs =
window_params
|> Map.put("organization_id", organization_id)
|> Map.put("created_by_id", user_id)
case Maintenance.create_window(attrs) do
{:ok, window} ->
conn
|> put_status(:created)
|> json(format_window(window))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'maintenance_window' parameter"})
end
@doc "GET /api/v1/maintenance_windows/:id"
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do
{:ok, window} ->
json(conn, format_window(window))
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this maintenance window"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Maintenance window not found"})
end
end
@doc "PATCH /api/v1/maintenance_windows/:id"
def update(conn, %{"id" => id, "maintenance_window" => window_params}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do
{:ok, window} ->
case Maintenance.update_window(window, window_params) do
{:ok, updated} ->
json(conn, format_window(updated))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this maintenance window"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Maintenance window not found"})
end
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'maintenance_window' parameter"})
end
@doc "DELETE /api/v1/maintenance_windows/:id"
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do
{:ok, window} ->
case Maintenance.delete_window(window) do
{:ok, _} ->
json(conn, %{success: true})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
{:error, :forbidden} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this maintenance window"})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Maintenance window not found"})
end
end
defp format_window(window) do
%{
id: window.id,
name: window.name,
reason: window.reason,
starts_at: window.starts_at,
ends_at: window.ends_at,
recurring: window.recurring,
recurrence_rule: window.recurrence_rule,
suppress_alerts: window.suppress_alerts,
site_id: window.site_id,
device_id: window.device_id,
inserted_at: window.inserted_at
}
end
end

View file

@ -0,0 +1,79 @@
defmodule ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow do
@moduledoc "GraphQL resolvers for maintenance window queries and mutations."
alias Towerops.Maintenance
alias Towerops.Maintenance.MaintenanceWindow
alias ToweropsWeb.GraphQL.Resolvers.Helpers
alias ToweropsWeb.ScopedResource
def list(_parent, args, %{context: %{organization_id: org_id}}) do
opts =
case Map.get(args, :filter) do
"active" -> [filter: :active]
"upcoming" -> [filter: :upcoming]
"past" -> [filter: :past]
_ -> []
end
{:ok, Maintenance.list_windows(org_id, opts)}
end
def list(_parent, _args, _resolution), do: Helpers.authentication_error()
def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
case ScopedResource.fetch(MaintenanceWindow, id, org_id) do
{:ok, window} -> {:ok, window}
{:error, _} -> {:error, "Maintenance window not found"}
end
end
def get(_parent, _args, _resolution), do: Helpers.authentication_error()
def create(_parent, %{input: input}, %{context: %{organization_id: org_id, user: user}}) do
user_id = user.id
attrs =
input
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put("organization_id", org_id)
|> Map.put("created_by_id", user_id)
case Maintenance.create_window(attrs) do
{:ok, window} -> {:ok, window}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
end
end
def create(_parent, _args, _resolution), do: Helpers.authentication_error()
def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
with {:ok, window} <- fetch_org_window(id, org_id) do
attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
case Maintenance.update_window(window, attrs) do
{:ok, updated} -> {:ok, updated}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
end
end
end
def update(_parent, _args, _resolution), do: Helpers.authentication_error()
def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
with {:ok, window} <- fetch_org_window(id, org_id) do
case Maintenance.delete_window(window) do
{:ok, _} -> {:ok, %{success: true, message: "Maintenance window deleted"}}
{:error, _} -> {:ok, %{success: false, message: "Could not delete maintenance window"}}
end
end
end
def delete(_parent, _args, _resolution), do: Helpers.authentication_error()
defp fetch_org_window(id, org_id) do
case ScopedResource.fetch(MaintenanceWindow, id, org_id) do
{:ok, window} -> {:ok, window}
{:error, _} -> {:error, "Maintenance window not found"}
end
end
end

View file

@ -18,6 +18,7 @@ defmodule ToweropsWeb.GraphQL.Schema do
import_types(ToweropsWeb.GraphQL.Types.Activity)
import_types(ToweropsWeb.GraphQL.Types.Schedule)
import_types(ToweropsWeb.GraphQL.Types.EscalationPolicy)
import_types(ToweropsWeb.GraphQL.Types.MaintenanceWindow)
query do
# Devices
@ -126,6 +127,17 @@ defmodule ToweropsWeb.GraphQL.Schema do
arg(:id, non_null(:id))
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3)
end
# Maintenance Windows
field :maintenance_windows, list_of(:maintenance_window) do
arg(:filter, :string)
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.list/3)
end
field :maintenance_window, :maintenance_window do
arg(:id, non_null(:id))
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.get/3)
end
end
mutation do
@ -342,5 +354,22 @@ defmodule ToweropsWeb.GraphQL.Schema do
arg(:id, non_null(:id))
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.delete_target/3)
end
# Maintenance Window CRUD
field :create_maintenance_window, :maintenance_window do
arg(:input, non_null(:maintenance_window_input))
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.create/3)
end
field :update_maintenance_window, :maintenance_window do
arg(:id, non_null(:id))
arg(:input, non_null(:maintenance_window_input))
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.update/3)
end
field :delete_maintenance_window, :delete_result do
arg(:id, non_null(:id))
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.delete/3)
end
end
end

View file

@ -0,0 +1,32 @@
defmodule ToweropsWeb.GraphQL.Types.MaintenanceWindow do
@moduledoc "GraphQL types for maintenance windows."
use Absinthe.Schema.Notation
object :maintenance_window do
field :id, :id
field :name, :string
field :reason, :string
field :starts_at, :string
field :ends_at, :string
field :recurring, :boolean
field :recurrence_rule, :string
field :suppress_alerts, :boolean
field :site_id, :id
field :device_id, :id
field :organization_id, :id
field :inserted_at, :string
field :updated_at, :string
end
input_object :maintenance_window_input do
field :name, :string
field :reason, :string
field :starts_at, :string
field :ends_at, :string
field :recurring, :boolean
field :recurrence_rule, :string
field :suppress_alerts, :boolean
field :site_id, :id
field :device_id, :id
end
end

View file

@ -188,6 +188,9 @@ defmodule ToweropsWeb.Router do
delete "/escalation_policies/:id/rules/:rule_id", EscalationPoliciesController, :delete_rule
post "/escalation_policies/:id/rules/:rule_id/targets", EscalationPoliciesController, :create_target
delete "/escalation_policies/:id/rules/:rule_id/targets/:target_id", EscalationPoliciesController, :delete_target
# Maintenance windows
resources "/maintenance_windows", MaintenanceWindowsController, except: [:new, :edit]
end
# Webhook routes (shared secret authentication)

View file

@ -0,0 +1,22 @@
defmodule Towerops.MaintenanceFixtures do
@moduledoc """
Test helpers for creating maintenance window entities.
"""
alias Towerops.Maintenance.MaintenanceWindow
alias Towerops.Repo
def maintenance_window_fixture(attrs) do
attrs =
Enum.into(attrs, %{
name: "Maintenance #{System.unique_integer([:positive])}",
starts_at: DateTime.add(DateTime.utc_now(), -3600, :second),
ends_at: DateTime.add(DateTime.utc_now(), 3600, :second),
suppress_alerts: true
})
%MaintenanceWindow{}
|> MaintenanceWindow.changeset(attrs)
|> Repo.insert!()
end
end

View file

@ -0,0 +1,271 @@
defmodule ToweropsWeb.Api.V1.MaintenanceWindowsControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
import Towerops.MaintenanceFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.Maintenance
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
conn =
build_conn()
|> put_req_header("authorization", "Bearer #{raw_token}")
|> put_req_header("accept", "application/json")
%{
conn: conn,
user: user,
organization: organization
}
end
describe "index/2" do
test "lists all maintenance windows for organization", %{
conn: conn,
user: user,
organization: organization
} do
_window =
maintenance_window_fixture(%{
organization_id: organization.id,
created_by_id: user.id
})
conn = get(conn, ~p"/api/v1/maintenance_windows")
assert %{"maintenance_windows" => windows} = json_response(conn, 200)
assert length(windows) == 1
window_json = List.first(windows)
assert Map.has_key?(window_json, "id")
assert Map.has_key?(window_json, "name")
assert Map.has_key?(window_json, "starts_at")
assert Map.has_key?(window_json, "ends_at")
end
test "returns empty list when organization has no windows", %{conn: conn} do
conn = get(conn, ~p"/api/v1/maintenance_windows")
assert %{"maintenance_windows" => []} = json_response(conn, 200)
end
test "filters by status", %{conn: conn, user: user, organization: organization} do
_active_window =
maintenance_window_fixture(%{
organization_id: organization.id,
created_by_id: user.id,
starts_at: DateTime.add(DateTime.utc_now(), -3600, :second),
ends_at: DateTime.add(DateTime.utc_now(), 3600, :second)
})
conn = get(conn, ~p"/api/v1/maintenance_windows?filter=active")
assert %{"maintenance_windows" => windows} = json_response(conn, 200)
assert length(windows) == 1
end
test "does not return windows from other organizations", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
_other_window =
maintenance_window_fixture(%{
organization_id: other_org.id,
created_by_id: other_user.id
})
conn = get(conn, ~p"/api/v1/maintenance_windows")
assert %{"maintenance_windows" => []} = json_response(conn, 200)
end
end
describe "create/2" do
test "creates window with valid attributes", %{
conn: conn,
user: user,
organization: organization
} do
starts_at = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601()
ends_at = DateTime.utc_now() |> DateTime.add(7200, :second) |> DateTime.to_iso8601()
params = %{
"maintenance_window" => %{
"name" => "Scheduled Upgrade",
"reason" => "Router firmware upgrade",
"starts_at" => starts_at,
"ends_at" => ends_at,
"suppress_alerts" => true
}
}
conn = post(conn, ~p"/api/v1/maintenance_windows", params)
assert %{
"id" => id,
"name" => "Scheduled Upgrade",
"reason" => "Router firmware upgrade"
} = json_response(conn, 201)
assert is_binary(id)
window = Maintenance.get_window!(id)
assert window.organization_id == organization.id
assert window.created_by_id == user.id
end
test "returns 422 with invalid params", %{conn: conn} do
params = %{"maintenance_window" => %{"name" => ""}}
conn = post(conn, ~p"/api/v1/maintenance_windows", params)
assert %{"errors" => _} = json_response(conn, 422)
end
test "returns 400 when maintenance_window parameter is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/maintenance_windows", %{})
assert %{"error" => "Missing 'maintenance_window' parameter"} = json_response(conn, 400)
end
end
describe "show/2" do
test "returns window when it belongs to organization", %{
conn: conn,
user: user,
organization: organization
} do
window =
maintenance_window_fixture(%{
name: "Test Window",
organization_id: organization.id,
created_by_id: user.id
})
conn = get(conn, ~p"/api/v1/maintenance_windows/#{window.id}")
assert %{"id" => _, "name" => "Test Window"} = json_response(conn, 200)
end
test "returns 404 when window does not exist", %{conn: conn} do
conn = get(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}")
assert %{"error" => "Maintenance window not found"} = json_response(conn, 404)
end
test "returns 403 when window belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_window =
maintenance_window_fixture(%{
organization_id: other_org.id,
created_by_id: other_user.id
})
conn = get(conn, ~p"/api/v1/maintenance_windows/#{other_window.id}")
assert %{"error" => "Access denied to this maintenance window"} = json_response(conn, 403)
end
end
describe "update/2" do
test "updates window with valid attributes", %{
conn: conn,
user: user,
organization: organization
} do
window =
maintenance_window_fixture(%{
name: "Original",
organization_id: organization.id,
created_by_id: user.id
})
params = %{"maintenance_window" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/maintenance_windows/#{window.id}", params)
assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200)
end
test "returns 404 when window does not exist", %{conn: conn} do
params = %{"maintenance_window" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}", params)
assert %{"error" => "Maintenance window not found"} = json_response(conn, 404)
end
test "returns 400 when maintenance_window parameter is missing", %{
conn: conn,
user: user,
organization: organization
} do
window =
maintenance_window_fixture(%{
organization_id: organization.id,
created_by_id: user.id
})
conn = patch(conn, ~p"/api/v1/maintenance_windows/#{window.id}", %{})
assert %{"error" => "Missing 'maintenance_window' parameter"} = json_response(conn, 400)
end
end
describe "delete/2" do
test "deletes window successfully", %{conn: conn, user: user, organization: organization} do
window =
maintenance_window_fixture(%{
organization_id: organization.id,
created_by_id: user.id
})
conn = delete(conn, ~p"/api/v1/maintenance_windows/#{window.id}")
assert %{"success" => true} = json_response(conn, 200)
end
test "returns 404 when window does not exist", %{conn: conn} do
conn = delete(conn, ~p"/api/v1/maintenance_windows/#{Ecto.UUID.generate()}")
assert %{"error" => "Maintenance window not found"} = json_response(conn, 404)
end
test "returns 403 when window belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_window =
maintenance_window_fixture(%{
organization_id: other_org.id,
created_by_id: other_user.id
})
conn = delete(conn, ~p"/api/v1/maintenance_windows/#{other_window.id}")
assert %{"error" => "Access denied to this maintenance window"} = json_response(conn, 403)
end
end
describe "authentication" do
test "returns 401 without authorization header" do
conn =
build_conn()
|> put_req_header("accept", "application/json")
|> get(~p"/api/v1/maintenance_windows")
assert %{"error" => _message} = json_response(conn, 401)
end
end
end

View file

@ -103,7 +103,7 @@ defmodule ToweropsWeb.AgentLive.EditTest do
flash = assert_redirect(view, ~p"/agents/#{agent.id}")
assert flash["info"] =~ "Device updated successfully"
assert flash["info"] =~ "Agent updated successfully"
end
test "shows error when saving with blank name", %{conn: conn, organization: org} do