feat: port basic functionality to elixir
This commit is contained in:
67
lib/components_elixir/auth.ex
Normal file
67
lib/components_elixir/auth.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule ComponentsElixir.Auth do
|
||||
@moduledoc """
|
||||
Simple authentication system for the components inventory.
|
||||
|
||||
Uses a configured password for authentication with session management.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Validates the provided password against the configured password.
|
||||
"""
|
||||
def authenticate(password) do
|
||||
configured_password = Application.get_env(:components_elixir, :auth_password, "changeme")
|
||||
|
||||
if password == configured_password do
|
||||
{:ok, :authenticated}
|
||||
else
|
||||
{:error, :invalid_credentials}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the current session is authenticated.
|
||||
"""
|
||||
def authenticated?(conn_or_socket_or_session) do
|
||||
case get_session_value(conn_or_socket_or_session, :authenticated) do
|
||||
true -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks the session as authenticated.
|
||||
"""
|
||||
def sign_in(conn_or_socket) do
|
||||
put_session_value(conn_or_socket, :authenticated, true)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the authentication from the session.
|
||||
"""
|
||||
def sign_out(conn_or_socket) do
|
||||
put_session_value(conn_or_socket, :authenticated, nil)
|
||||
end
|
||||
|
||||
# Helper functions to handle both Plug.Conn and Phoenix.LiveView.Socket
|
||||
defp get_session_value(%Plug.Conn{} = conn, key) do
|
||||
Plug.Conn.get_session(conn, key)
|
||||
end
|
||||
|
||||
defp get_session_value(%Phoenix.LiveView.Socket{} = socket, key) do
|
||||
get_in(socket.assigns, [:session, key])
|
||||
end
|
||||
|
||||
defp get_session_value(session, key) when is_map(session) do
|
||||
# Handle both string and atom keys
|
||||
Map.get(session, to_string(key)) || Map.get(session, key)
|
||||
end
|
||||
|
||||
defp put_session_value(%Plug.Conn{} = conn, key, value) do
|
||||
Plug.Conn.put_session(conn, key, value)
|
||||
end
|
||||
|
||||
defp put_session_value(%Phoenix.LiveView.Socket{} = socket, key, value) do
|
||||
session = Map.put(socket.assigns[:session] || %{}, key, value)
|
||||
Phoenix.LiveView.assign(socket, session: session)
|
||||
end
|
||||
end
|
||||
254
lib/components_elixir/inventory.ex
Normal file
254
lib/components_elixir/inventory.ex
Normal file
@@ -0,0 +1,254 @@
|
||||
defmodule ComponentsElixir.Inventory do
|
||||
@moduledoc """
|
||||
The Inventory context for managing components and categories.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias ComponentsElixir.Repo
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
|
||||
## Categories
|
||||
|
||||
@doc """
|
||||
Returns the list of categories.
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> preload(:parent)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of root categories (no parent).
|
||||
"""
|
||||
def list_root_categories do
|
||||
Category
|
||||
|> where([c], is_nil(c.parent_id))
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of child categories for a given parent.
|
||||
"""
|
||||
def list_child_categories(parent_id) do
|
||||
Category
|
||||
|> where([c], c.parent_id == ^parent_id)
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single category.
|
||||
"""
|
||||
def get_category!(id) do
|
||||
Category
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a category.
|
||||
"""
|
||||
def create_category(attrs \\ %{}) do
|
||||
%Category{}
|
||||
|> Category.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a category.
|
||||
"""
|
||||
def update_category(%Category{} = category, attrs) do
|
||||
category
|
||||
|> Category.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a category.
|
||||
"""
|
||||
def delete_category(%Category{} = category) do
|
||||
Repo.delete(category)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking category changes.
|
||||
"""
|
||||
def change_category(%Category{} = category, attrs \\ %{}) do
|
||||
Category.changeset(category, attrs)
|
||||
end
|
||||
|
||||
## Components
|
||||
|
||||
@doc """
|
||||
Returns the list of components with optional filtering and pagination.
|
||||
"""
|
||||
def list_components(opts \\ []) do
|
||||
Component
|
||||
|> apply_component_filters(opts)
|
||||
|> preload(:category)
|
||||
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns paginated components with search and filtering.
|
||||
"""
|
||||
def paginate_components(opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
offset = Keyword.get(opts, :offset, 0)
|
||||
|
||||
query =
|
||||
Component
|
||||
|> apply_component_filters(opts)
|
||||
|> preload(:category)
|
||||
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||
|
||||
components =
|
||||
query
|
||||
|> limit(^limit)
|
||||
|> offset(^offset)
|
||||
|> Repo.all()
|
||||
|
||||
total_count = Repo.aggregate(query, :count, :id)
|
||||
|
||||
%{
|
||||
components: components,
|
||||
total_count: total_count,
|
||||
has_more: total_count > offset + length(components)
|
||||
}
|
||||
end
|
||||
|
||||
defp apply_component_filters(query, opts) do
|
||||
Enum.reduce(opts, query, fn
|
||||
{:search, search}, query when is_binary(search) and search != "" ->
|
||||
if String.length(search) > 3 do
|
||||
# Use full-text search for longer queries
|
||||
where(query, [c],
|
||||
fragment("to_tsvector('english', ? || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) @@ plainto_tsquery(?)",
|
||||
c.name, c.description, c.keywords, ^search))
|
||||
else
|
||||
# Use ILIKE for shorter queries
|
||||
search_term = "%#{search}%"
|
||||
where(query, [c],
|
||||
ilike(c.name, ^search_term) or
|
||||
ilike(c.description, ^search_term) or
|
||||
ilike(c.keywords, ^search_term))
|
||||
end
|
||||
|
||||
{:category_id, category_id}, query when is_integer(category_id) ->
|
||||
where(query, [c], c.category_id == ^category_id)
|
||||
|
||||
{:sort_criteria, "name"}, query ->
|
||||
order_by(query, [c], [asc: c.name])
|
||||
|
||||
{:sort_criteria, "description"}, query ->
|
||||
order_by(query, [c], [asc: c.description])
|
||||
|
||||
{:sort_criteria, "id"}, query ->
|
||||
order_by(query, [c], [asc: c.id])
|
||||
|
||||
{:sort_criteria, "category_id"}, query ->
|
||||
order_by(query, [c], [asc: c.category_id, asc: c.name])
|
||||
|
||||
_, query ->
|
||||
query
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single component.
|
||||
"""
|
||||
def get_component!(id) do
|
||||
Component
|
||||
|> preload(:category)
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a component.
|
||||
"""
|
||||
def create_component(attrs \\ %{}) do
|
||||
%Component{}
|
||||
|> Component.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component.
|
||||
"""
|
||||
def update_component(%Component{} = component, attrs) do
|
||||
component
|
||||
|> Component.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component's count.
|
||||
"""
|
||||
def update_component_count(%Component{} = component, count) when is_integer(count) do
|
||||
component
|
||||
|> Component.count_changeset(%{count: count})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments a component's count.
|
||||
"""
|
||||
def increment_component_count(%Component{} = component) do
|
||||
update_component_count(component, component.count + 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrements a component's count (minimum 0).
|
||||
"""
|
||||
def decrement_component_count(%Component{} = component) do
|
||||
new_count = max(0, component.count - 1)
|
||||
update_component_count(component, new_count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component's image filename.
|
||||
"""
|
||||
def update_component_image(%Component{} = component, image_filename) do
|
||||
component
|
||||
|> Component.image_changeset(%{image_filename: image_filename})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a component.
|
||||
"""
|
||||
def delete_component(%Component{} = component) do
|
||||
Repo.delete(component)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking component changes.
|
||||
"""
|
||||
def change_component(%Component{} = component, attrs \\ %{}) do
|
||||
Component.changeset(component, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns component statistics.
|
||||
"""
|
||||
def component_stats do
|
||||
total_components = Repo.aggregate(Component, :count, :id)
|
||||
total_stock = Repo.aggregate(Component, :sum, :count) || 0
|
||||
categories_with_components =
|
||||
Component
|
||||
|> select([c], c.category_id)
|
||||
|> distinct(true)
|
||||
|> Repo.aggregate(:count, :category_id)
|
||||
|
||||
%{
|
||||
total_components: total_components,
|
||||
total_stock: total_stock,
|
||||
categories_with_components: categories_with_components
|
||||
}
|
||||
end
|
||||
end
|
||||
44
lib/components_elixir/inventory/category.ex
Normal file
44
lib/components_elixir/inventory/category.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule ComponentsElixir.Inventory.Category do
|
||||
@moduledoc """
|
||||
Schema for component categories.
|
||||
|
||||
Categories can be hierarchical with parent-child relationships.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
|
||||
schema "categories" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
|
||||
belongs_to :parent, Category
|
||||
has_many :children, Category, foreign_key: :parent_id
|
||||
has_many :components, Component
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(category, attrs) do
|
||||
category
|
||||
|> cast(attrs, [:name, :description, :parent_id])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|> unique_constraint([:name, :parent_id])
|
||||
|> foreign_key_constraint(:parent_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the full path of the category including parent names.
|
||||
"""
|
||||
def full_path(%Category{parent: nil} = category), do: category.name
|
||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||
"#{full_path(parent)} > #{category.name}"
|
||||
end
|
||||
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
||||
category.name
|
||||
end
|
||||
end
|
||||
86
lib/components_elixir/inventory/component.ex
Normal file
86
lib/components_elixir/inventory/component.ex
Normal file
@@ -0,0 +1,86 @@
|
||||
defmodule ComponentsElixir.Inventory.Component do
|
||||
@moduledoc """
|
||||
Schema for electronic components.
|
||||
|
||||
Each component belongs to a category and tracks inventory information
|
||||
including count, location, and optional image and datasheet.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias ComponentsElixir.Inventory.Category
|
||||
|
||||
schema "components" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :keywords, :string
|
||||
field :position, :string
|
||||
field :count, :integer, default: 0
|
||||
field :datasheet_url, :string
|
||||
field :image_filename, :string
|
||||
|
||||
belongs_to :category, Category
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id])
|
||||
|> validate_required([:name, :category_id])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_length(:description, max: 2000)
|
||||
|> validate_length(:keywords, max: 500)
|
||||
|> validate_length(:position, max: 100)
|
||||
|> validate_number(:count, greater_than_or_equal_to: 0)
|
||||
|> validate_url(:datasheet_url)
|
||||
|> foreign_key_constraint(:category_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating component count.
|
||||
"""
|
||||
def count_changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:count])
|
||||
|> validate_required([:count])
|
||||
|> validate_number(:count, greater_than_or_equal_to: 0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating component image.
|
||||
"""
|
||||
def image_changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:image_filename])
|
||||
end
|
||||
|
||||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, url ->
|
||||
if url && url != "" do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] -> []
|
||||
_ -> [{field, "must be a valid URL"}]
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the component has an image.
|
||||
"""
|
||||
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
||||
def has_image?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns the search text for this component.
|
||||
"""
|
||||
def search_text(%__MODULE__{} = component) do
|
||||
[component.name, component.description, component.keywords]
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user