Removes unreachable catch-all clauses, drops unused `require Logger` lines, adds pin operators to bitstring size patterns, reorders function heads for correct dispatch, and replaces deprecated LoggerBackends calls.
208 lines
5.8 KiB
Elixir
208 lines
5.8 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
|
|
|
|
# -- 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
|
|
|
|
@doc """
|
|
Fetches a report scoped to an organization. Returns `nil` if the report
|
|
doesn't exist or belongs to a different organization. Use this for any
|
|
user-driven operation (toggle, delete, run) to prevent IDOR.
|
|
"""
|
|
def get_organization_report(id, organization_id) when is_binary(organization_id) do
|
|
Repo.get_by(Report, id: id, organization_id: organization_id)
|
|
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
|