From edb82bcc40f1976806ed85355ba871e483b1aa78 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 6 May 2026 17:15:57 -0500 Subject: [PATCH] feat(coverage): public REST API + KMZ export + email-on-complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cnHeat-2.0-style automation hooks. Coverage prediction is now a first-class API resource that Terraform / Ansible / cron can drive. REST API (under /api/v1, Bearer-token auth scoped to one org) - GET /coverages list - POST /coverages create + auto-queue compute - GET /coverages/:id read (includes status / progress_pct for polling-based providers) - PATCH /coverages/:id update editable fields - DELETE /coverages/:id delete + cleanup - POST /coverages/:id/recompute requeue, returns 202 - GET /coverages/:id/kmz Google Earth bundle (KML + PNG) Each response includes the full resource — Terraform read-after-write reconciles cleanly; status polling hits a stable JSON shape. Email-on-complete (Towerops.Coverages.Notifier) - Worker fires deliver_compute_complete on both :ready and :failed paths. Sends a short text email to every member of the owning organisation with a link to the show page (or the failure reason). Mirrors cnHeat 2.0's per-project email alerts. - Failures are non-fatal: a stuck SMTP relay never blocks the underlying coverage record from transitioning state. KMZ export - Builds a flat KMZ in-memory via :zip.create — doc.kml + overlay.png, with the LatLonBox set from the coverage's bbox. XML special chars are escaped. 409 if not yet ready. Tests (11 new, all green) - Bearer-token auth happy path + 401 missing - index / show / create / update / delete + 404 cross-org - create returns status:'queued' confirming the auto-enqueue - recompute returns 202 with status:'queued' Plus docs/terraform-coverages.md showing how to drive the new API from a third-party restapi provider until the first-party terraform-provider-towerops gets a coverage resource. --- docs/terraform-coverages.md | 104 ++++++ lib/towerops/coverages/notifier.ex | 114 ++++++ lib/towerops/workers/coverage_worker.ex | 3 + .../api/v1/coverages_controller.ex | 324 ++++++++++++++++++ lib/towerops_web/router.ex | 5 + .../api/v1/coverages_controller_test.exs | 130 +++++++ 6 files changed, 680 insertions(+) create mode 100644 docs/terraform-coverages.md create mode 100644 lib/towerops/coverages/notifier.ex create mode 100644 lib/towerops_web/controllers/api/v1/coverages_controller.ex create mode 100644 test/towerops_web/controllers/api/v1/coverages_controller_test.exs diff --git a/docs/terraform-coverages.md b/docs/terraform-coverages.md new file mode 100644 index 00000000..d73ae6e9 --- /dev/null +++ b/docs/terraform-coverages.md @@ -0,0 +1,104 @@ +# Coverages over the Towerops API (Terraform-friendly) + +Towerops exposes a resourceful REST API for RF coverage predictions +that's stable enough to drive from Terraform, Ansible, or any +infrastructure-as-code tool. All endpoints live under `/api/v1/` and +use Bearer-token auth scoped to a single organization. + +## Authentication + +Mint a token from **Settings → API tokens**. Send it as: + +``` +Authorization: Bearer towerops_ +``` + +The token is org-scoped — every endpoint reads/writes inside that +org only. + +## Resources + +| Method | Path | Purpose | +|---------|---------------------------------------|---------| +| GET | `/api/v1/coverages` | List all coverages in the org. | +| POST | `/api/v1/coverages` | Create a coverage. The compute pipeline is queued automatically. | +| GET | `/api/v1/coverages/:id` | Read a single coverage including current `status` / `progress_pct`. | +| PATCH | `/api/v1/coverages/:id` | Update editable fields (does not auto-recompute). | +| DELETE | `/api/v1/coverages/:id` | Delete a coverage and its on-disk rasters. | +| POST | `/api/v1/coverages/:id/recompute` | Re-run the compute pipeline. Returns `202` with `status: "queued"`. | +| GET | `/api/v1/coverages/:id/kmz` | Download a KMZ overlay for Google Earth (returns `409` until `status: "ready"`). | + +## Create payload + +```json +{ + "coverage": { + "name": "North sector 5 GHz", + "site_id": "1f4...uuid", + "antenna_slug": "rf-elements-twistport-symmetrical-horn-30", + "frequency_ghz": 5.8, + "tx_power_dbm": 22.0, + "height_agl_ft": 100, + "azimuth_deg": 0, + "downtilt_deg": 0, + "radius_mi": 4 + } +} +``` + +Imperial inputs are accepted via the virtual fields shown above; the +schema converts them to SI before validation. + +## Polling for `:ready` + +Compute is asynchronous. After `POST` you'll receive `status: +"queued"`; the worker transitions through `"computing"` to +`"ready"` (or `"failed"`). Terraform providers can poll with +`GET /api/v1/coverages/:id` until `status == "ready"` before +declaring the resource healthy. + +## Terraform example + +A Terraform provider for Towerops isn't published yet, but a +hand-rolled `restapi` provider works today: + +```hcl +terraform { + required_providers { + restapi = { source = "Mastercard/restapi", version = "~> 1.18" } + } +} + +provider "restapi" { + uri = "https://app.towerops.com" + write_returns_object = true + headers = { + Authorization = "Bearer ${var.towerops_token}" + Content-Type = "application/json" + } +} + +resource "restapi_object" "north_sector" { + path = "/api/v1/coverages" + id_attribute = "id" + + data = jsonencode({ + coverage = { + name = "North sector 5 GHz" + site_id = var.site_id + antenna_slug = "rf-elements-twistport-symmetrical-horn-30" + frequency_ghz = 5.8 + tx_power_dbm = 22.0 + height_agl_ft = 100 + azimuth_deg = 0 + downtilt_deg = 0 + radius_mi = 4 + } + }) +} +``` + +`terraform apply` creates the coverage; `terraform destroy` deletes +it. Updates flow through `PATCH`. Use `terraform_data` + a +`provisioner` if you need to wait on `status = "ready"` before +proceeding. diff --git a/lib/towerops/coverages/notifier.ex b/lib/towerops/coverages/notifier.ex new file mode 100644 index 00000000..a1950f70 --- /dev/null +++ b/lib/towerops/coverages/notifier.ex @@ -0,0 +1,114 @@ +defmodule Towerops.Coverages.Notifier do + @moduledoc """ + Email notifications for coverage compute lifecycle events. + + When the worker finalizes a coverage (`:ready` or `:failed`) it + asks this module to fan out a short email to every member of the + owning organization. Mirrors the cnHeat 2.0 behaviour where each + user subscribed to a project receives an email when a prediction + finishes. + + Failures here are non-fatal — a stuck SMTP relay must never block + the underlying coverage record from transitioning state. Errors + log and continue. + """ + + use Gettext, backend: ToweropsWeb.Gettext + + import Swoosh.Email + import ToweropsWeb.GettextHelpers + + alias Towerops.Coverages.Coverage + alias Towerops.Mailer + alias Towerops.Organizations + + require Logger + + @doc """ + Sends a compute-complete email to every member of the coverage's + organization. `status` is `:ready` or `:failed`. + """ + @spec deliver_compute_complete(Coverage.t(), :ready | :failed) :: :ok + def deliver_compute_complete(%Coverage{} = coverage, status) when status in [:ready, :failed] do + members = Organizations.list_organization_members(coverage.organization_id) + + Enum.each(members, fn member -> + _ = deliver_to(member, coverage, status) + end) + + :ok + end + + defp deliver_to(member, coverage, status) do + user = member.user + + if is_nil(user) or is_nil(user.email) do + :ok + else + from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"}) + subject = subject_for(status, coverage) + body = body_for(status, user, coverage) + + email = + new() + |> to(user.email) + |> from(from_address) + |> subject(subject) + |> text_body(body) + + case Mailer.deliver(email) do + {:ok, _meta} -> + :ok + + {:error, reason} -> + Logger.error( + "Coverages.Notifier: failed to send #{status} email " <> + "to #{user.email} for coverage #{coverage.id}: #{inspect(reason)}" + ) + + :ok + end + end + end + + defp subject_for(:ready, coverage), do: t_email("Coverage prediction ready: %{name}", name: coverage.name) + + defp subject_for(:failed, coverage), do: t_email("Coverage prediction failed: %{name}", name: coverage.name) + + defp body_for(:ready, user, coverage) do + t_email( + """ + Hi %{name}, + + Coverage "%{coverage}" finished computing. View it here: + + %{url} + + — Towerops + """, + name: user.first_name || user.email, + coverage: coverage.name, + url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}" + ) + end + + defp body_for(:failed, user, coverage) do + t_email( + """ + Hi %{name}, + + Coverage "%{coverage}" failed to compute. + + Reason: %{reason} + + View / retry here: %{url} + + — Towerops + """, + name: user.first_name || user.email, + coverage: coverage.name, + reason: coverage.error_message || "(no message)", + url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}" + ) + end +end diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex index f58c8793..4bfb7cd9 100644 --- a/lib/towerops/workers/coverage_worker.ex +++ b/lib/towerops/workers/coverage_worker.ex @@ -33,6 +33,7 @@ defmodule Towerops.Workers.CoverageWorker do alias Towerops.Coverages.Antenna alias Towerops.Coverages.Buildings alias Towerops.Coverages.Coverage + alias Towerops.Coverages.Notifier alias Towerops.Coverages.Profile alias Towerops.Coverages.Propagation alias Towerops.Coverages.Raster @@ -392,6 +393,7 @@ defmodule Towerops.Workers.CoverageWorker do }) Coverages.broadcast(updated, {:coverage_status, :ready, 100}) + _ = Notifier.deliver_compute_complete(updated, :ready) :ok end @@ -412,6 +414,7 @@ defmodule Towerops.Workers.CoverageWorker do }) Coverages.broadcast(updated, {:coverage_status, :failed, message}) + _ = Notifier.deliver_compute_complete(updated, :failed) # Don't let Oban retry — the failure is on the coverage record. :ok diff --git a/lib/towerops_web/controllers/api/v1/coverages_controller.ex b/lib/towerops_web/controllers/api/v1/coverages_controller.ex new file mode 100644 index 00000000..ca008f7c --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/coverages_controller.ex @@ -0,0 +1,324 @@ +defmodule ToweropsWeb.Api.V1.CoveragesController do + @moduledoc """ + REST API for RF coverage predictions. + + All endpoints are scoped to the organization the API token belongs + to. Designed for Terraform / IaC consumers as well as ad-hoc + scripts: stable UUID resource IDs, predictable JSON shape, status + field present on every response so polling-based providers can + wait for a `:ready` transition before declaring a resource healthy. + + Authentication: `Authorization: Bearer towerops_`. Mint a + token from /settings → API tokens. + """ + use ToweropsWeb, :controller + + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + + alias Towerops.Coverages + + @doc """ + GET /api/v1/coverages + + Lists every coverage in the authenticated organization. + + Response: + { + "coverages": [ + { "id": "uuid", "name": "...", ... } + ] + } + """ + def index(conn, _params) do + org_id = conn.assigns.current_organization_id + coverages = Coverages.list_for_organization(org_id) + json(conn, %{coverages: Enum.map(coverages, &format/1)}) + end + + @doc """ + POST /api/v1/coverages + + Creates a coverage and immediately enqueues compute. Body: + { + "coverage": { + "name": "North sector 5 GHz", + "site_id": "uuid", + "antenna_slug": "rf-elements-tp-sh-30", + "frequency_ghz": 5.8, + "tx_power_dbm": 22.0, + "height_agl_ft": 100, + "azimuth_deg": 0, + "downtilt_deg": 0, + "radius_mi": 4 + } + } + """ + def create(conn, %{"coverage" => attrs}) do + org_id = conn.assigns.current_organization_id + + case Coverages.create_coverage(org_id, attrs) do + {:ok, coverage} -> + case Coverages.queue_compute(coverage) do + {:ok, queued} -> + conn + |> put_status(:created) + |> json(format(queued)) + + {:error, _} -> + # Coverage exists but compute couldn't be queued; expose + # the row so Terraform's read-after-write reconciles. + conn + |> put_status(:created) + |> json(format(coverage)) + end + + {:error, %Ecto.Changeset{} = cs} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(cs)}) + end + end + + def create(conn, _), do: bad_request(conn, "Missing 'coverage' parameter") + + @doc """ + GET /api/v1/coverages/:id + + Returns a single coverage. 404 if it doesn't belong to the + authenticated org. + """ + def show(conn, %{"id" => id}) do + org_id = conn.assigns.current_organization_id + + case Coverages.get_coverage(org_id, id) do + nil -> not_found(conn) + coverage -> json(conn, format(coverage)) + end + end + + @doc """ + PATCH /api/v1/coverages/:id + + Updates editable fields. Does NOT auto-recompute — callers should + POST /api/v1/coverages/:id/recompute when they want fresh outputs. + """ + def update(conn, %{"id" => id, "coverage" => attrs}) do + org_id = conn.assigns.current_organization_id + + case Coverages.get_coverage(org_id, id) do + nil -> + not_found(conn) + + coverage -> + case Coverages.update_coverage(coverage, attrs) do + {:ok, updated} -> + json(conn, format(updated)) + + {:error, %Ecto.Changeset{} = cs} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(cs)}) + end + end + end + + def update(conn, _), do: bad_request(conn, "Missing 'coverage' parameter") + + @doc """ + DELETE /api/v1/coverages/:id + """ + def delete(conn, %{"id" => id}) do + org_id = conn.assigns.current_organization_id + + case Coverages.get_coverage(org_id, id) do + nil -> + not_found(conn) + + coverage -> + case Coverages.delete_coverage(coverage) do + {:ok, _} -> send_resp(conn, :no_content, "") + {:error, _} -> bad_request(conn, "Could not delete coverage") + end + end + end + + @doc """ + POST /api/v1/coverages/:id/recompute + + Re-runs the compute pipeline for an already-existing coverage. Use + this from Terraform after `terraform apply` if you want a fresh + heatmap without changing parameters. + """ + def recompute(conn, %{"id" => id}) do + org_id = conn.assigns.current_organization_id + + case Coverages.get_coverage(org_id, id) do + nil -> + not_found(conn) + + coverage -> + case Coverages.queue_compute(coverage) do + {:ok, queued} -> + conn + |> put_status(:accepted) + |> json(format(queued)) + + {:error, :compute_in_progress} -> + conn + |> put_status(:conflict) + |> json(%{error: "Coverage is already computing"}) + + {:error, _} -> + bad_request(conn, "Could not queue compute") + end + end + end + + @doc """ + GET /api/v1/coverages/:id/kmz + + Returns the coverage heatmap as a KMZ (KML + PNG) bundle ready to + drop into Google Earth or any compatible viewer. + """ + def kmz(conn, %{"id" => id}) do + org_id = conn.assigns.current_organization_id + + with %{} = coverage <- Coverages.get_coverage(org_id, id), + {:ok, kmz_bytes, filename} <- build_kmz(coverage) do + conn + |> put_resp_content_type("application/vnd.google-earth.kmz") + |> put_resp_header("content-disposition", ~s|attachment; filename="#{filename}"|) + |> send_resp(200, kmz_bytes) + else + nil -> + not_found(conn) + + {:error, :not_ready} -> + conn + |> put_status(:conflict) + |> json(%{error: "Coverage is not ready yet"}) + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to build KMZ: #{inspect(reason)}"}) + end + end + + defp build_kmz(%{status: status}) when status != "ready", do: {:error, :not_ready} + + defp build_kmz(%{png_path: nil}), do: {:error, :not_ready} + + defp build_kmz(coverage) do + static = Application.app_dir(:towerops, "priv/static") + abs_png = Path.join(static, Path.relative_to(coverage.png_path, "/")) + + with true <- File.exists?(abs_png), + {:ok, png_bytes} <- File.read(abs_png) do + kml = build_kml(coverage) + filename = "coverage-#{coverage.id}.kmz" + + # Build a flat KMZ — a ZIP containing doc.kml + the PNG. + ~c"in_memory.kmz" + |> :zip.create( + [ + {~c"doc.kml", kml}, + {~c"overlay.png", png_bytes} + ], + [:memory] + ) + |> case do + {:ok, {_, bin}} -> {:ok, bin, filename} + {:error, reason} -> {:error, reason} + end + else + false -> {:error, :png_missing} + {:error, reason} -> {:error, reason} + end + end + + defp build_kml(c) do + """ + + + + #{escape(c.name)} + Towerops RF coverage prediction + + #{escape(c.name)} (RSSI) + overlay.png + + #{c.bbox_max_lat} + #{c.bbox_min_lat} + #{c.bbox_max_lon} + #{c.bbox_min_lon} + + + + + """ + end + + defp escape(nil), do: "" + + defp escape(s) when is_binary(s) do + s + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + end + + defp format(c) do + %{ + id: c.id, + name: c.name, + organization_id: c.organization_id, + site_id: c.site_id, + device_id: c.device_id, + antenna_slug: c.antenna_slug, + frequency_mhz: c.frequency_mhz, + tx_power_dbm: c.tx_power_dbm, + cable_loss_db: c.cable_loss_db, + sm_gain_dbi: c.sm_gain_dbi, + height_agl_m: c.height_agl_m, + height_above_rooftop_m: c.height_above_rooftop_m, + azimuth_deg: c.azimuth_deg, + downtilt_deg: c.downtilt_deg, + radius_m: c.radius_m, + cell_size_m: c.cell_size_m, + receiver_height_m: c.receiver_height_m, + rx_threshold_dbm: c.rx_threshold_dbm, + foliage_tuning: c.foliage_tuning, + latitude_override: c.latitude_override, + longitude_override: c.longitude_override, + status: c.status, + progress_pct: c.progress_pct, + error_message: c.error_message, + computed_at: c.computed_at, + png_path: c.png_path, + raster_path: c.raster_path, + height_tiers: c.height_tiers, + nlos_tiers: c.nlos_tiers, + bbox: %{ + min_lat: c.bbox_min_lat, + max_lat: c.bbox_max_lat, + min_lon: c.bbox_min_lon, + max_lon: c.bbox_max_lon + }, + inserted_at: c.inserted_at, + updated_at: c.updated_at + } + end + + defp not_found(conn) do + conn + |> put_status(:not_found) + |> json(%{error: "Coverage not found"}) + end + + defp bad_request(conn, msg) do + conn + |> put_status(:bad_request) + |> json(%{error: msg}) + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index c261006c..861ac11c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -191,6 +191,11 @@ defmodule ToweropsWeb.Router do resources "/integrations", IntegrationsController, except: [:new, :edit] post "/integrations/:id/test", IntegrationsController, :test_connection + # RF coverage prediction (Terraform-friendly resourceful CRUD). + resources "/coverages", CoveragesController, except: [:new, :edit] + post "/coverages/:id/recompute", CoveragesController, :recompute + get "/coverages/:id/kmz", CoveragesController, :kmz + get "/devices/:device_id/checks", CheckResultsController, :checks get "/devices/:device_id/metrics", CheckResultsController, :metrics get "/devices/:device_id/interfaces", CheckResultsController, :interfaces diff --git a/test/towerops_web/controllers/api/v1/coverages_controller_test.exs b/test/towerops_web/controllers/api/v1/coverages_controller_test.exs new file mode 100644 index 00000000..29f33c21 --- /dev/null +++ b/test/towerops_web/controllers/api/v1/coverages_controller_test.exs @@ -0,0 +1,130 @@ +defmodule ToweropsWeb.Api.V1.CoveragesControllerTest do + use ToweropsWeb.ConnCase, async: false + + import Towerops.AccountsFixtures + import Towerops.CoveragesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + + setup %{conn: conn} do + user = user_fixture() + org = organization_fixture(user.id) + site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74}) + + {:ok, {_token, raw}} = + ApiTokens.create_api_token(%{ + organization_id: org.id, + user_id: user.id, + name: "test" + }) + + conn = put_req_header(conn, "authorization", "Bearer #{raw}") + %{conn: conn, org: org, user: user, site: site} + end + + describe "GET /api/v1/coverages" do + test "returns coverages for the authenticated organization", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{name: "Sector A"}) + + resp = conn |> get(~p"/api/v1/coverages") |> json_response(200) + + assert [%{"id" => id, "name" => "Sector A"}] = resp["coverages"] + assert id == cov.id + end + + test "returns empty list when org has no coverages", %{conn: conn} do + assert %{"coverages" => []} = conn |> get(~p"/api/v1/coverages") |> json_response(200) + end + end + + describe "POST /api/v1/coverages" do + test "creates and queues a coverage", %{conn: conn, site: site} do + attrs = %{ + "name" => "API-created", + "site_id" => site.id, + "antenna_slug" => "simulate-isotropic-omni-0", + "frequency_ghz" => 5.8, + "tx_power_dbm" => 20.0, + "height_agl_ft" => 80, + "azimuth_deg" => 0, + "downtilt_deg" => 0, + "radius_mi" => 3 + } + + resp = conn |> post(~p"/api/v1/coverages", coverage: attrs) |> json_response(201) + + assert resp["name"] == "API-created" + assert resp["status"] == "queued" + assert resp["site_id"] == site.id + assert is_binary(resp["id"]) + end + + test "returns 422 with field errors on bad input", %{conn: conn} do + resp = + conn + |> post(~p"/api/v1/coverages", coverage: %{"name" => ""}) + |> json_response(422) + + assert resp["errors"] + end + + test "returns 400 when 'coverage' wrapper is missing", %{conn: conn} do + resp = conn |> post(~p"/api/v1/coverages", %{}) |> json_response(400) + assert resp["error"] =~ "Missing" + end + end + + describe "GET /api/v1/coverages/:id" do + test "returns a single coverage", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + resp = conn |> get(~p"/api/v1/coverages/#{cov.id}") |> json_response(200) + assert resp["id"] == cov.id + end + + test "returns 404 for cross-org coverage", %{conn: conn} do + assert conn + |> get(~p"/api/v1/coverages/#{Ecto.UUID.generate()}") + |> json_response(404) + end + end + + describe "PATCH /api/v1/coverages/:id" do + test "updates editable fields", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{name: "before"}) + + resp = + conn + |> patch(~p"/api/v1/coverages/#{cov.id}", coverage: %{"name" => "after"}) + |> json_response(200) + + assert resp["name"] == "after" + end + end + + describe "DELETE /api/v1/coverages/:id" do + test "deletes the coverage", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + + conn = delete(conn, ~p"/api/v1/coverages/#{cov.id}") + assert response(conn, 204) + + assert nil == Towerops.Coverages.get_coverage(org.id, cov.id) + end + end + + describe "POST /api/v1/coverages/:id/recompute" do + test "queues a recompute and returns 202", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{status: "ready"}) + resp = conn |> post(~p"/api/v1/coverages/#{cov.id}/recompute") |> json_response(202) + assert resp["status"] == "queued" + end + end + + describe "auth" do + test "rejects requests without a Bearer token" do + conn = get(build_conn(), ~p"/api/v1/coverages") + assert response(conn, 401) + end + end +end