330 lines
9 KiB
Elixir
330 lines
9 KiB
Elixir
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_<token>`. Mint a
|
|
token from /settings → API tokens.
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
|
|
|
alias Towerops.Coverages
|
|
|
|
require Logger
|
|
|
|
@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} ->
|
|
# Log the underlying reason server-side so we can debug, but don't
|
|
# leak filesystem paths / zip errors / IO details to the caller.
|
|
Logger.error("Failed to build KMZ for coverage: #{inspect(reason)}")
|
|
|
|
conn
|
|
|> put_status(:internal_server_error)
|
|
|> json(%{error: "Failed to build KMZ"})
|
|
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
|
|
"""
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
|
<Document>
|
|
<name>#{escape(c.name)}</name>
|
|
<description>Towerops RF coverage prediction</description>
|
|
<GroundOverlay>
|
|
<name>#{escape(c.name)} (RSSI)</name>
|
|
<Icon><href>overlay.png</href></Icon>
|
|
<LatLonBox>
|
|
<north>#{c.bbox_max_lat}</north>
|
|
<south>#{c.bbox_min_lat}</south>
|
|
<east>#{c.bbox_max_lon}</east>
|
|
<west>#{c.bbox_min_lon}</west>
|
|
</LatLonBox>
|
|
</GroundOverlay>
|
|
</Document>
|
|
</kml>
|
|
"""
|
|
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
|