- H1: batch count queries in MobileController (org sites/devices/alerts, site device counts) — reduces 1+3N+2M queries to 4 + 2 - H2: batch Oban cancel for stop_device_checks — single ANY(?) query instead of one per check - H3: resolve_active_alerts_for_device uses update_all + deduped PubSub broadcasts (was N updates + N broadcasts) - H4: atomic ON CONFLICT upsert for auto-discovery checks; restore partial unique index dropped in 20260404000002 (dedup migration included) - H5: wrap apply_agent_to_all_equipment delete+insert in transaction so a failed insert_all rolls back the prior delete - H14: replace String.to_integer/1 on untrusted SNMP OID components with Integer.parse/1 + validation (neighbor_discovery, discovery storage index, printer supply index) - H15: ActivityFeedLive whitelists activity types against @all_types instead of String.to_existing_atom/1 - H16: defensive page param parsing in user_settings and admin dashboard LiveViews - H27: search sanitize/1 delegates to QueryHelpers.sanitize_like/1 which escapes backslash first - H30: JobCleanupTask no longer cancels jobs in "executing" state so in-flight polls complete naturally
341 lines
9.6 KiB
Elixir
341 lines
9.6 KiB
Elixir
defmodule Towerops.Sites do
|
|
@moduledoc """
|
|
The Sites context.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Agents.AgentAssignment
|
|
alias Towerops.Contexts.ConfigChangeTracker
|
|
alias Towerops.Devices.Device
|
|
alias Towerops.Repo
|
|
alias Towerops.Sites.Site
|
|
alias Towerops.Sites.SiteQuery
|
|
|
|
@site_preloads [:parent_site, :child_sites, :device]
|
|
|
|
@doc """
|
|
Lists all sites across all organizations that have geographic coordinates set.
|
|
Used by the weather sync worker to fetch weather data.
|
|
"""
|
|
def list_sites_with_coordinates do
|
|
Repo.all(from(s in Site, where: not is_nil(s.latitude) and not is_nil(s.longitude), select: s))
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of sites for an organization.
|
|
Ordered by custom display_order (if set), then alphabetically by name.
|
|
"""
|
|
@spec list_organization_sites(String.t()) :: [Site.t()]
|
|
def list_organization_sites(organization_id) do
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> SiteQuery.order_by_display()
|
|
|> preload(:parent_site)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc "Search sites by name for an organization. Returns up to 10 results."
|
|
def search_sites(organization_id, query) do
|
|
search_term = "%#{Towerops.QueryHelpers.sanitize_like(query)}%"
|
|
|
|
Site
|
|
|> where(organization_id: ^organization_id)
|
|
|> where([s], ilike(s.name, ^search_term))
|
|
|> order_by(:name)
|
|
|> limit(10)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Returns the count of sites for an organization.
|
|
"""
|
|
@spec count_organization_sites(String.t()) :: integer()
|
|
def count_organization_sites(organization_id) do
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> Repo.aggregate(:count)
|
|
end
|
|
|
|
@doc """
|
|
Batch count sites for multiple organizations.
|
|
|
|
Returns a map of `%{organization_id => count}`. Organizations with zero sites
|
|
are omitted from the map; callers should use `Map.get(map, org_id, 0)`.
|
|
"""
|
|
@spec batch_count_organization_sites([String.t()]) :: %{String.t() => non_neg_integer()}
|
|
def batch_count_organization_sites(organization_ids) when is_list(organization_ids) do
|
|
from(s in Site,
|
|
where: s.organization_id in ^organization_ids,
|
|
group_by: s.organization_id,
|
|
select: {s.organization_id, count(s.id)}
|
|
)
|
|
|> Repo.all()
|
|
|> Map.new()
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of root sites (sites without a parent) for an organization.
|
|
Ordered by custom display_order (if set), then alphabetically by name.
|
|
"""
|
|
@spec list_root_sites(String.t()) :: [Site.t()]
|
|
def list_root_sites(organization_id) do
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> SiteQuery.roots()
|
|
|> SiteQuery.order_by_display()
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of child sites for a parent site.
|
|
Ordered by custom display_order (if set), then alphabetically by name.
|
|
"""
|
|
@spec list_child_sites(String.t()) :: [Site.t()]
|
|
def list_child_sites(parent_site_id) do
|
|
Repo.all(
|
|
from(s in Site,
|
|
where: s.parent_site_id == ^parent_site_id,
|
|
order_by: [asc: s.display_order, asc: s.name]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single site.
|
|
"""
|
|
@spec get_site!(String.t()) :: Site.t()
|
|
def get_site!(id) do
|
|
Site
|
|
|> Repo.get!(id)
|
|
|> Repo.preload(@site_preloads)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single site. Returns nil if not found.
|
|
"""
|
|
@spec get_site(String.t()) :: Site.t() | nil
|
|
def get_site(id) do
|
|
case Repo.get(Site, id) do
|
|
nil -> nil
|
|
site -> Repo.preload(site, @site_preloads)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets a single site belonging to an organization.
|
|
"""
|
|
@spec get_organization_site!(String.t(), String.t()) :: Site.t()
|
|
def get_organization_site!(organization_id, site_id) do
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> where([s], s.id == ^site_id)
|
|
|> preload(^@site_preloads)
|
|
|> Repo.one!()
|
|
end
|
|
|
|
@doc """
|
|
Creates a site.
|
|
"""
|
|
@spec create_site(map()) :: {:ok, Site.t()} | {:error, Ecto.Changeset.t()}
|
|
def create_site(attrs) do
|
|
%Site{}
|
|
|> Site.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Updates a site.
|
|
"""
|
|
def update_site(%Site{} = site, attrs) do
|
|
old_config = ConfigChangeTracker.capture_config_state(site)
|
|
|
|
case site
|
|
|> Site.changeset(attrs)
|
|
|> Repo.update() do
|
|
{:ok, updated_site} = result ->
|
|
# If community string changed, propagate to inheriting devices
|
|
if ConfigChangeTracker.snmp_changed?(updated_site, old_config) do
|
|
Towerops.Devices.propagate_site_community_change(updated_site.id, updated_site.snmp_community)
|
|
end
|
|
|
|
# If any MikroTik settings changed, propagate to inheriting devices
|
|
if ConfigChangeTracker.mikrotik_changed?(updated_site, old_config) do
|
|
mikrotik_attrs = ConfigChangeTracker.extract_mikrotik_attrs(updated_site)
|
|
Towerops.Devices.propagate_site_mikrotik_change(updated_site.id, mikrotik_attrs)
|
|
end
|
|
|
|
result
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Deletes a site.
|
|
"""
|
|
def delete_site(%Site{} = site) do
|
|
Repo.delete(site)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking site changes.
|
|
"""
|
|
def change_site(%Site{} = site, attrs \\ %{}) do
|
|
Site.changeset(site, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Builds a hierarchical tree structure of sites.
|
|
"""
|
|
def build_site_tree(organization_id) do
|
|
sites = list_organization_sites(organization_id)
|
|
children_by_parent = Enum.group_by(sites, & &1.parent_site_id)
|
|
|
|
children_by_parent
|
|
|> Map.get(nil, [])
|
|
|> Enum.map(&build_tree_node(&1, children_by_parent))
|
|
end
|
|
|
|
defp build_tree_node(site, children_by_parent) do
|
|
children =
|
|
children_by_parent
|
|
|> Map.get(site.id, [])
|
|
|> Enum.map(&build_tree_node(&1, children_by_parent))
|
|
|
|
%{site: site, children: children}
|
|
end
|
|
|
|
## Bulk Configuration Updates
|
|
|
|
@doc """
|
|
Applies site's SNMP configuration to all devices at the site.
|
|
|
|
This updates all devices records to use the site's SNMP version and community string,
|
|
overwriting any existing device-level configurations.
|
|
|
|
Returns the number of device records updated.
|
|
"""
|
|
def apply_snmp_config_to_all_equipment(site_id) do
|
|
site = get_site!(site_id)
|
|
|
|
Repo.update_all(from(e in Device, where: e.site_id == ^site_id),
|
|
set: [
|
|
snmp_version: site.snmp_version,
|
|
snmp_community: site.snmp_community,
|
|
updated_at: Towerops.Time.now()
|
|
]
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Applies site's agent to all devices at the site.
|
|
|
|
This creates/updates agent assignments for all devices at the site to use the site's
|
|
agent, overwriting any existing device-level assignments.
|
|
|
|
Returns the number of device records assigned.
|
|
"""
|
|
def apply_agent_to_all_equipment(site_id) do
|
|
site = Repo.get!(Site, site_id)
|
|
device_ids = Repo.all(from(e in Device, where: e.site_id == ^site_id, select: e.id))
|
|
|
|
# Wrap delete + insert in a transaction so an insert_all failure doesn't
|
|
# leave the site with empty assignments — either all old assignments are
|
|
# replaced atomically or the prior state is preserved.
|
|
{:ok, result} =
|
|
Repo.transaction(fn ->
|
|
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
|
|
reassign_devices(device_ids, site.agent_token_id)
|
|
end)
|
|
|
|
result
|
|
end
|
|
|
|
defp reassign_devices(device_ids, nil) do
|
|
# No agent set; assignments stay deleted (devices inherit from org or use cloud).
|
|
{length(device_ids), nil}
|
|
end
|
|
|
|
defp reassign_devices(device_ids, agent_token_id) do
|
|
now = Towerops.Time.now()
|
|
|
|
assignments =
|
|
Enum.map(device_ids, fn device_id ->
|
|
%{
|
|
device_id: device_id,
|
|
agent_token_id: agent_token_id,
|
|
inserted_at: now,
|
|
updated_at: now
|
|
}
|
|
end)
|
|
|
|
{count, _} = Repo.insert_all(AgentAssignment, assignments)
|
|
{count, nil}
|
|
end
|
|
|
|
## Display Order Management
|
|
|
|
@doc """
|
|
Reorders a site to a new position within the organization.
|
|
|
|
Updates display_order for all sites to maintain continuous ordering (1, 2, 3, ...).
|
|
The new_position is 1-based (1 = first position).
|
|
|
|
Returns {:ok, site} on success, {:error, changeset} on failure.
|
|
"""
|
|
def reorder_site(site_id, new_position) when is_integer(new_position) and new_position > 0 do
|
|
Repo.transaction(fn ->
|
|
site = Repo.get!(Site, site_id)
|
|
organization_id = site.organization_id
|
|
|
|
# Get all sites excluding the one being moved, in current order
|
|
other_sites =
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> where([s], s.id != ^site_id)
|
|
|> SiteQuery.order_by_display()
|
|
|> Repo.all()
|
|
|
|
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
|
|
new_order = List.insert_at(other_sites, new_position - 1, site)
|
|
|
|
{site_ids, orders} =
|
|
new_order
|
|
|> Enum.with_index(1)
|
|
|> Enum.map(fn {s, order} -> {s.id, order} end)
|
|
|> Enum.unzip()
|
|
|
|
site_ids = Enum.map(site_ids, &Ecto.UUID.dump!/1)
|
|
now = Towerops.Time.now()
|
|
|
|
query =
|
|
from(s in Site,
|
|
join:
|
|
data in fragment(
|
|
"SELECT * FROM unnest(?::uuid[], ?::int[]) AS data(id, ordering)",
|
|
^site_ids,
|
|
^orders
|
|
),
|
|
on: s.id == data.id,
|
|
update: [set: [display_order: field(data, :ordering), updated_at: ^now]]
|
|
)
|
|
|
|
Repo.update_all(query, [])
|
|
|
|
Repo.get!(Site, site_id)
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Reset all sites in organization to alphabetical order.
|
|
|
|
Clears display_order field for all sites, allowing them to fall back to alphabetical sorting.
|
|
"""
|
|
def reset_site_order(organization_id) do
|
|
organization_id
|
|
|> SiteQuery.for_organization()
|
|
|> Repo.update_all(set: [display_order: nil, updated_at: Towerops.Time.now()])
|
|
end
|
|
end
|