towerops/lib/towerops/sites/site.ex
Graham McIntire a4e08b2aca Major codebase cleanup: DRY schemas, remove dead config, consolidate modules
- Created Towerops.Schema base macro (95+ schemas updated to use it)
- Created Towerops.Snmp.Reading macro (7 reading schemas consolidated)
- Created Towerops.Gaiia.BaseSchema macro (4 Gaiia schemas consolidated)
- Created Towerops.SyncLog shared module (UISP + Preseem merge)
- Created Towerops.LogFilters (3 log filter modules merged into 1)
- Created Towerops.OnCall.ChangesetHelpers (9 on-call schemas simplified)
- Created API v1 ResourceController shared helpers (7 controllers)
- Extracted ConnectionHelpers.format_connection_result (2 LiveViews)
- Removed 5 dead config keys (scopes, mib_dirs, stripe_meter_id, etc.)
- Fixed 2 pre-existing broken tests
- All 13,219 tests pass, Credo clean (0 issues)
2026-06-16 15:29:22 -05:00

275 lines
8.1 KiB
Elixir

defmodule Towerops.Sites.Site do
@moduledoc """
Site schema representing physical locations within an organization.
Sites contain devices and can have site-level SNMP configuration defaults.
"""
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
alias Towerops.Encrypted.Binary
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
schema "sites" do
field :name, :string
field :description, :string
field :location, :string
field :display_order, :integer
# Geographic location fields
field :address, :string
field :latitude, :float
field :longitude, :float
# SNMP configuration (overrides organization default)
field :snmp_version, :string
field :snmp_community, :string
field :snmp_port, :integer
field :snmp_transport, :string
# SNMPv3 credentials (overrides organization default, all nullable)
field :snmpv3_security_level, :string
field :snmpv3_username, :string
field :snmpv3_auth_protocol, :string
field :snmpv3_auth_password, Binary
field :snmpv3_priv_protocol, :string
field :snmpv3_priv_password, Binary
# MikroTik API credentials (overrides organization default)
field :mikrotik_username, :string
field :mikrotik_password, Binary
field :mikrotik_port, :integer
field :mikrotik_ssh_port, :integer
field :mikrotik_use_ssl, :boolean
field :mikrotik_enabled, :boolean
belongs_to :organization, Organization
belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site
has_many :child_sites, Site, foreign_key: :parent_site_id
has_many :device, Device
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
description: String.t() | nil,
location: String.t() | nil,
display_order: integer() | nil,
address: String.t() | nil,
latitude: float() | nil,
longitude: float() | nil,
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
snmp_port: integer() | nil,
snmp_transport: String.t() | nil,
snmpv3_security_level: String.t() | nil,
snmpv3_username: String.t() | nil,
snmpv3_auth_protocol: String.t() | nil,
snmpv3_auth_password: String.t() | nil,
snmpv3_priv_protocol: String.t() | nil,
snmpv3_priv_password: String.t() | nil,
mikrotik_username: String.t() | nil,
mikrotik_password: String.t() | nil,
mikrotik_port: integer() | nil,
mikrotik_ssh_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
agent_token_id: Ecto.UUID.t() | nil,
agent_token: NotLoaded.t() | AgentToken.t() | nil,
parent_site_id: Ecto.UUID.t() | nil,
parent_site: NotLoaded.t() | t() | nil,
child_sites: NotLoaded.t() | [t()],
device: NotLoaded.t() | [Device.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(site, attrs) do
site
|> cast(attrs, [
:name,
:description,
:location,
:display_order,
:address,
:latitude,
:longitude,
:organization_id,
:agent_token_id,
:parent_site_id,
:snmp_version,
:snmp_community,
:snmp_port,
:snmp_transport,
:snmpv3_security_level,
:snmpv3_username,
:snmpv3_auth_protocol,
:snmpv3_auth_password,
:snmpv3_priv_protocol,
:snmpv3_priv_password,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_ssh_port,
:mikrotik_use_ssl,
:mikrotik_enabled
])
|> validate_required([:name, :organization_id])
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_length(:location, max: 200)
|> validate_length(:address, max: 500)
|> validate_latitude_range()
|> validate_longitude_range()
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
|> validate_snmpv3_fields()
|> validate_mikrotik_fields()
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id)
|> unique_constraint(:name,
name: :sites_organization_id_name_index,
message: "has already been taken for this organization"
)
|> validate_not_circular_parent()
end
defp validate_snmpv3_fields(changeset) do
snmp_version = get_field(changeset, :snmp_version)
if snmp_version == "3" do
changeset
|> derive_snmpv3_security_level()
|> validate_snmpv3_auth_required()
|> validate_snmpv3_priv_required()
|> validate_password_length(:snmpv3_auth_password, 8)
|> validate_password_length(:snmpv3_priv_password, 8)
else
changeset
end
end
defp derive_snmpv3_security_level(changeset) do
# Automatically derive security level based on what fields are present
auth_protocol = get_field(changeset, :snmpv3_auth_protocol)
priv_protocol = get_field(changeset, :snmpv3_priv_protocol)
security_level =
cond do
auth_protocol in [nil, ""] -> "noAuthNoPriv"
priv_protocol not in [nil, ""] -> "authPriv"
true -> "authNoPriv"
end
put_change(changeset, :snmpv3_security_level, security_level)
end
defp validate_snmpv3_auth_required(changeset) do
auth_protocol = get_field(changeset, :snmpv3_auth_protocol)
if auth_protocol in [nil, ""] do
changeset
else
changeset
|> validate_required([:snmpv3_auth_password],
message: "required when auth protocol is selected"
)
|> validate_inclusion(:snmpv3_auth_protocol, [
"MD5",
"SHA",
"SHA-224",
"SHA-256",
"SHA-384",
"SHA-512"
])
end
end
defp validate_snmpv3_priv_required(changeset) do
priv_protocol = get_field(changeset, :snmpv3_priv_protocol)
if priv_protocol in [nil, ""] do
changeset
else
changeset
|> validate_required([:snmpv3_priv_password],
message: "required when privacy protocol is selected"
)
|> validate_inclusion(:snmpv3_priv_protocol, ["DES", "AES", "AES-192", "AES-256", "AES-256-C"])
end
end
defp validate_password_length(changeset, field, min_length) do
case get_change(changeset, field) do
nil ->
changeset
password when byte_size(password) < min_length ->
add_error(changeset, field, "must be at least #{min_length} characters")
_ ->
changeset
end
end
defp validate_mikrotik_fields(changeset) do
changeset
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|> validate_mikrotik_required_fields()
end
defp validate_mikrotik_required_fields(changeset) do
if get_field(changeset, :mikrotik_enabled) do
validate_required(changeset, [:mikrotik_username])
else
changeset
end
end
defp validate_not_circular_parent(changeset) do
parent_site_id = get_change(changeset, :parent_site_id)
site_id = get_field(changeset, :id)
if parent_site_id && site_id && parent_site_id == site_id do
add_error(changeset, :parent_site_id, "cannot be the same as the site itself")
else
changeset
end
end
defp validate_latitude_range(changeset) do
case get_change(changeset, :latitude) do
nil ->
changeset
latitude when latitude < -90 or latitude > 90 ->
add_error(changeset, :latitude, "must be between -90 and 90")
_ ->
changeset
end
end
defp validate_longitude_range(changeset) do
case get_change(changeset, :longitude) do
nil ->
changeset
longitude when longitude < -180 or longitude > 180 ->
add_error(changeset, :longitude, "must be between -180 and 180")
_ ->
changeset
end
end
end