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