towerops/lib/towerops/reports.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

201 lines
5.5 KiB
Elixir

defmodule Towerops.Reports do
@moduledoc """
Context for managing scheduled reports.
Handles CRUD operations and report generation/delivery.
"""
import Ecto.Query
alias Towerops.Alerts
alias Towerops.Capacity
alias Towerops.Devices
alias Towerops.Repo
alias Towerops.Reports.Report
alias Towerops.RfLinks
require Logger
# -- CRUD --
def list_reports(organization_id) do
Report
|> where(organization_id: ^organization_id)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
def get_report!(id), do: Repo.get!(Report, id)
def get_report(id) do
case Repo.get(Report, id) do
nil -> {:error, :not_found}
report -> {:ok, report}
end
end
def create_report(attrs) do
%Report{}
|> Report.changeset(attrs)
|> Repo.insert()
end
def update_report(%Report{} = report, attrs) do
report
|> Report.changeset(attrs)
|> Repo.update()
end
def delete_report(%Report{} = report) do
Repo.delete(report)
end
def toggle_report(%Report{} = report) do
update_report(report, %{enabled: !report.enabled})
end
# -- Report Generation --
@doc """
Generates report data as a CSV string based on report type and scope.
"""
def generate_csv(%Report{} = report) do
org_id = report.organization_id
time_range = parse_time_range(report.scope)
case report.report_type do
"uptime_summary" -> generate_uptime_csv(org_id, time_range)
"alert_history" -> generate_alert_history_csv(org_id, time_range)
"capacity_trends" -> generate_capacity_csv(org_id)
"rf_link_health" -> generate_rf_link_csv(org_id)
end
end
defp generate_uptime_csv(org_id, _time_range) do
devices = Devices.list_organization_devices(org_id)
header = "Device,IP Address,Status,Last Seen\n"
rows =
Enum.map_join(devices, "\n", fn device ->
status = if device.monitoring_enabled, do: "monitored", else: "unmonitored"
"#{csv_escape(device.name)},#{device.ip_address},#{status},#{device.updated_at}"
end)
{:ok, header <> rows}
end
defp generate_alert_history_csv(org_id, _time_range) do
alerts = Alerts.list_organization_alerts(org_id, 500)
header = "Alert,Severity,Status,Device,Created At,Resolved At\n"
rows =
Enum.map_join(alerts, "\n", fn alert ->
device_name = if alert.device, do: alert.device.name, else: "-"
resolved = if alert.resolved_at, do: to_string(alert.resolved_at), else: "-"
"#{csv_escape(alert.message)},#{alert.severity},#{alert.status},#{csv_escape(device_name)},#{alert.inserted_at},#{resolved}"
end)
{:ok, header <> rows}
end
defp generate_capacity_csv(org_id) do
summaries = Capacity.get_organization_capacity_summary(org_id)
header = "Site,Total Capacity (bps),Throughput (bps),Utilization %\n"
rows =
Enum.map_join(summaries, "\n", fn s ->
"#{csv_escape(s.site_name || "Unknown")},#{s.total_capacity_bps},#{s.total_throughput_bps},#{s.utilization_pct}"
end)
{:ok, header <> rows}
end
defp generate_rf_link_csv(org_id) do
links = RfLinks.list_rf_links(org_id)
header = "Client,MAC,Signal (dBm),SNR (dB),TX Rate,RX Rate,Health,Last Seen\n"
rows =
Enum.map_join(links, "\n", fn link ->
name = link.hostname || link.ip_address || link.mac_address || "-"
"#{csv_escape(name)},#{link.mac_address},#{link.signal_strength || "-"},#{link.snr || "-"},#{link.tx_rate || "-"},#{link.rx_rate || "-"},#{link.health},#{link.last_seen_at}"
end)
{:ok, header <> rows}
end
@doc """
Escapes a value for inclusion in a CSV cell.
Wraps values containing commas, quotes, or newlines in double-quotes and
doubles any embedded quotes. `nil` becomes the empty string; non-binary
non-nil values are coerced via `to_string/1`.
"""
@spec csv_escape(term()) :: String.t()
def csv_escape(nil), do: ""
def csv_escape(value) when is_binary(value) do
if String.contains?(value, [",", "\"", "\n"]) do
"\"#{String.replace(value, "\"", "\"\"")}\""
else
value
end
end
def csv_escape(value), do: to_string(value)
@doc """
Parses the `days` field of a report's scope map into a `%{from, to}` range
ending at `DateTime.utc_now/0`. Defaults to 7 days when `days` is missing.
"""
@spec parse_time_range(map()) :: %{from: DateTime.t(), to: DateTime.t()}
def parse_time_range(scope) when is_map(scope) do
days = Map.get(scope, "days", 7)
now = DateTime.utc_now()
%{from: DateTime.add(now, -days, :day), to: now}
end
# -- Schedule Helpers --
@doc """
Returns true if a report is due to run based on its schedule.
"""
def due?(%Report{schedule: %{"type" => "one_time"}} = report) do
is_nil(report.last_run_at)
end
def due?(%Report{schedule: %{"type" => "daily"}} = report) do
elapsed_since_last_run(report) >= 86_400
end
def due?(%Report{schedule: %{"type" => "weekly"}} = report) do
elapsed_since_last_run(report) >= 604_800
end
def due?(%Report{schedule: %{"type" => "monthly"}} = report) do
elapsed_since_last_run(report) >= 2_592_000
end
def due?(_), do: false
defp elapsed_since_last_run(%{last_run_at: nil}), do: :infinity
defp elapsed_since_last_run(%{last_run_at: last_run_at}) do
DateTime.diff(DateTime.utc_now(), last_run_at, :second)
end
@doc """
Marks a report as having been run.
"""
def mark_run(%Report{} = report, status) do
update_report(report, %{
last_run_at: Towerops.Time.now(),
last_run_status: status
})
end
end