chore(specs): add @spec to public API in accounts/release/backtest

This commit is contained in:
Graham McIntire 2026-04-21 13:32:42 -05:00
parent b7261e772c
commit 7a7b30f7bf
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 51 additions and 0 deletions

View file

@ -24,6 +24,7 @@ defmodule Microwaveprop.Accounts do
nil nil
""" """
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email) Repo.get_by(User, email: email)
end end
@ -63,6 +64,7 @@ defmodule Microwaveprop.Accounts do
nil nil
""" """
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email) user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user if User.valid_password?(user, password), do: user
@ -82,16 +84,19 @@ defmodule Microwaveprop.Accounts do
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_user!(Ecto.UUID.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id) def get_user!(id), do: Repo.get!(User, id)
## Admin user management ## Admin user management
@doc "Returns all users ordered by callsign." @doc "Returns all users ordered by callsign."
@spec list_users() :: [User.t()]
def list_users do def list_users do
Repo.all(from u in User, order_by: [asc: u.callsign]) Repo.all(from u in User, order_by: [asc: u.callsign])
end end
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)." @doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
@spec admin_update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def admin_update_user(%User{} = user, attrs) do def admin_update_user(%User{} = user, attrs) do
user user
|> User.admin_changeset(attrs) |> User.admin_changeset(attrs)
@ -99,11 +104,13 @@ defmodule Microwaveprop.Accounts do
end end
@doc "Returns an admin-edit changeset for rendering forms." @doc "Returns an admin-edit changeset for rendering forms."
@spec change_admin_user(User.t(), map()) :: Ecto.Changeset.t()
def change_admin_user(%User{} = user, attrs \\ %{}) do def change_admin_user(%User{} = user, attrs \\ %{}) do
User.admin_changeset(user, attrs) User.admin_changeset(user, attrs)
end end
@doc "Deletes a user." @doc "Deletes a user."
@spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def delete_user(%User{} = user), do: Repo.delete(user) def delete_user(%User{} = user), do: Repo.delete(user)
## User registration ## User registration
@ -120,6 +127,7 @@ defmodule Microwaveprop.Accounts do
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do def register_user(attrs) do
%User{} %User{}
|> User.registration_changeset(attrs) |> User.registration_changeset(attrs)
@ -129,6 +137,7 @@ defmodule Microwaveprop.Accounts do
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking user registration changes. Returns an `%Ecto.Changeset{}` for tracking user registration changes.
""" """
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
def change_user_registration(%User{} = user, attrs \\ %{}) do def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false) User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
end end
@ -141,6 +150,7 @@ defmodule Microwaveprop.Accounts do
The user is in sudo mode when the last authentication was done no further The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes. than 20 minutes ago. The limit can be given as second argument in minutes.
""" """
@spec sudo_mode?(term(), integer()) :: boolean()
def sudo_mode?(user, minutes \\ -20) def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
@ -160,6 +170,7 @@ defmodule Microwaveprop.Accounts do
%Ecto.Changeset{data: %User{}} %Ecto.Changeset{data: %User{}}
""" """
@spec change_user_email(User.t(), map(), keyword()) :: Ecto.Changeset.t()
def change_user_email(user, attrs \\ %{}, opts \\ []) do def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts) User.email_changeset(user, attrs, opts)
end end
@ -169,6 +180,7 @@ defmodule Microwaveprop.Accounts do
If the token matches, the user email is updated and the token is deleted. If the token matches, the user email is updated and the token is deleted.
""" """
@spec update_user_email(User.t(), String.t()) :: {:ok, User.t()} | {:error, :transaction_aborted}
def update_user_email(user, token) do def update_user_email(user, token) do
context = "change:#{user.email}" context = "change:#{user.email}"
@ -196,6 +208,7 @@ defmodule Microwaveprop.Accounts do
%Ecto.Changeset{data: %User{}} %Ecto.Changeset{data: %User{}}
""" """
@spec change_user_password(User.t(), map(), keyword()) :: Ecto.Changeset.t()
def change_user_password(user, attrs \\ %{}, opts \\ []) do def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts) User.password_changeset(user, attrs, opts)
end end
@ -214,6 +227,8 @@ defmodule Microwaveprop.Accounts do
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec update_user_password(User.t(), map()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
def update_user_password(user, attrs) do def update_user_password(user, attrs) do
user user
|> User.password_changeset(attrs) |> User.password_changeset(attrs)
@ -225,6 +240,7 @@ defmodule Microwaveprop.Accounts do
@doc """ @doc """
Generates a session token. Generates a session token.
""" """
@spec generate_user_session_token(User.t()) :: binary()
def generate_user_session_token(user) do def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user) {token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token) Repo.insert!(user_token)
@ -236,6 +252,7 @@ defmodule Microwaveprop.Accounts do
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned. If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
""" """
@spec get_user_by_session_token(binary()) :: {User.t(), DateTime.t()} | nil
def get_user_by_session_token(token) do def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token) {:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query) Repo.one(query)
@ -248,6 +265,8 @@ defmodule Microwaveprop.Accounts do
Returns `{:error, :already_confirmed}` if the user is already confirmed. Returns `{:error, :already_confirmed}` if the user is already confirmed.
""" """
@spec deliver_user_confirmation_instructions(User.t(), (String.t() -> String.t())) ::
{:ok, Swoosh.Email.t()} | {:error, :already_confirmed}
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
when is_function(confirmation_url_fun, 1) do when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do if user.confirmed_at do
@ -265,6 +284,8 @@ defmodule Microwaveprop.Accounts do
On success, the confirmation token (and all other tokens tied to this user) On success, the confirmation token (and all other tokens tied to this user)
are deleted to prevent reuse. are deleted to prevent reuse.
""" """
@spec confirm_user_by_token(String.t()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, :not_found | Ecto.Changeset.t()}
def confirm_user_by_token(token) do def confirm_user_by_token(token) do
with {:ok, query} <- UserToken.verify_confirm_token_query(token), with {:ok, query} <- UserToken.verify_confirm_token_query(token),
{%User{} = user, _token} <- Repo.one(query) do {%User{} = user, _token} <- Repo.one(query) do
@ -285,6 +306,11 @@ defmodule Microwaveprop.Accounts do
{:ok, %{to: ..., body: ...}} {:ok, %{to: ..., body: ...}}
""" """
@spec deliver_user_update_email_instructions(
User.t(),
String.t(),
(String.t() -> String.t())
) :: {:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
@ -304,6 +330,8 @@ defmodule Microwaveprop.Accounts do
{:ok, %{to: ..., body: ...}} {:ok, %{to: ..., body: ...}}
""" """
@spec deliver_user_reset_password_instructions(User.t(), (String.t() -> String.t())) ::
{:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
@ -314,6 +342,7 @@ defmodule Microwaveprop.Accounts do
@doc """ @doc """
Gets the user by a password-reset token, or nil if the token is invalid or expired. Gets the user by a password-reset token, or nil if the token is invalid or expired.
""" """
@spec get_user_by_reset_password_token(String.t()) :: User.t() | nil
def get_user_by_reset_password_token(token) do def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_password_reset_token_query(token), with {:ok, query} <- UserToken.verify_password_reset_token_query(token),
{%User{} = user, _token} <- Repo.one(query) do {%User{} = user, _token} <- Repo.one(query) do
@ -328,6 +357,8 @@ defmodule Microwaveprop.Accounts do
Returns `{:ok, {user, expired_tokens}}` on success. Returns `{:ok, {user, expired_tokens}}` on success.
""" """
@spec reset_user_password(User.t(), map()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
def reset_user_password(%User{} = user, attrs) do def reset_user_password(%User{} = user, attrs) do
user user
|> User.password_changeset(attrs) |> User.password_changeset(attrs)
@ -337,6 +368,7 @@ defmodule Microwaveprop.Accounts do
@doc """ @doc """
Deletes the signed token with the given context. Deletes the signed token with the given context.
""" """
@spec delete_user_session_token(binary()) :: :ok
def delete_user_session_token(token) do def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"])) Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok :ok

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Accounts.Scope do
Returns nil if no user is given. Returns nil if no user is given.
""" """
@spec for_user(User.t() | nil) :: t() | nil
def for_user(%User{} = user) do def for_user(%User{} = user) do
%__MODULE__{user: user} %__MODULE__{user: user}
end end

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Accounts.User do
Returns the email address that is automatically granted admin Returns the email address that is automatically granted admin
privileges on registration. privileges on registration.
""" """
@spec admin_email() :: String.t()
def admin_email, do: @admin_email def admin_email, do: @admin_email
@doc """ @doc """
@ -43,6 +44,7 @@ defmodule Microwaveprop.Accounts.User do
* `:validate_unique` - Set to false to skip uniqueness validation. * `:validate_unique` - Set to false to skip uniqueness validation.
Defaults to `true`. Defaults to `true`.
""" """
@spec registration_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def registration_changeset(user, attrs, opts \\ []) do def registration_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:callsign, :name, :email, :password]) |> cast(attrs, [:callsign, :name, :email, :password])
@ -65,6 +67,7 @@ defmodule Microwaveprop.Accounts.User do
Admin-only changeset for editing another user's profile fields Admin-only changeset for editing another user's profile fields
and admin flag. Does not touch the password. and admin flag. Does not touch the password.
""" """
@spec admin_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def admin_changeset(user, attrs) do def admin_changeset(user, attrs) do
user user
|> cast(attrs, [:callsign, :name, :email, :is_admin]) |> cast(attrs, [:callsign, :name, :email, :is_admin])
@ -113,6 +116,7 @@ defmodule Microwaveprop.Accounts.User do
uniqueness of the email, useful when displaying live validations. uniqueness of the email, useful when displaying live validations.
Defaults to `true`. Defaults to `true`.
""" """
@spec email_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def email_changeset(user, attrs, opts \\ []) do def email_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:email]) |> cast(attrs, [:email])
@ -159,6 +163,7 @@ defmodule Microwaveprop.Accounts.User do
validations on a LiveView form), this option can be set to `false`. validations on a LiveView form), this option can be set to `false`.
Defaults to `true`. Defaults to `true`.
""" """
@spec password_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def password_changeset(user, attrs, opts \\ []) do def password_changeset(user, attrs, opts \\ []) do
user user
|> cast(attrs, [:password]) |> cast(attrs, [:password])
@ -197,6 +202,7 @@ defmodule Microwaveprop.Accounts.User do
@doc """ @doc """
Confirms the account by setting `confirmed_at`. Confirms the account by setting `confirmed_at`.
""" """
@spec confirm_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t()
def confirm_changeset(user) do def confirm_changeset(user) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
change(user, confirmed_at: now) change(user, confirmed_at: now)
@ -208,6 +214,7 @@ defmodule Microwaveprop.Accounts.User do
If there is no user or the user doesn't have a password, we call If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks. `Bcrypt.no_user_verify/0` to avoid timing attacks.
""" """
@spec valid_password?(t() | nil, String.t()) :: boolean()
def valid_password?(%Microwaveprop.Accounts.User{hashed_password: hashed_password}, password) def valid_password?(%Microwaveprop.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password) Bcrypt.verify_pass(password, hashed_password)

View file

@ -41,6 +41,7 @@ defmodule Microwaveprop.Backtest do
max: float | nil max: float | nil
} }
@spec from_values([number()]) :: t()
def from_values([]), do: %__MODULE__{count: 0} def from_values([]), do: %__MODULE__{count: 0}
def from_values(values) when is_list(values) do def from_values(values) when is_list(values) do

View file

@ -20,6 +20,7 @@ defmodule Microwaveprop.Release do
@app :microwaveprop @app :microwaveprop
@spec migrate() :: [term()]
def migrate do def migrate do
_ = load_app() _ = load_app()
@ -28,29 +29,34 @@ defmodule Microwaveprop.Release do
end end
end end
@spec rollback(module(), integer()) :: {:ok, term(), term()}
def rollback(repo, version) do def rollback(repo, version) do
_ = load_app() _ = load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end end
@spec backtest(String.t()) :: :ok
def backtest(feature_name) when is_binary(feature_name) do def backtest(feature_name) when is_binary(feature_name) do
_ = start_app() _ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest", feature: feature_name})) {:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest", feature: feature_name}))
IO.puts("Enqueued backtest for #{feature_name} (job #{job.id})") IO.puts("Enqueued backtest for #{feature_name} (job #{job.id})")
end end
@spec backtest_all() :: :ok
def backtest_all do def backtest_all do
_ = start_app() _ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest_all"})) {:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest_all"}))
IO.puts("Enqueued consolidated backtest (job #{job.id})") IO.puts("Enqueued consolidated backtest (job #{job.id})")
end end
@spec climatology(pos_integer()) :: :ok
def climatology(min_samples \\ 3) do def climatology(min_samples \\ 3) do
_ = start_app() _ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "climatology", min_samples: min_samples})) {:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "climatology", min_samples: min_samples}))
IO.puts("Enqueued climatology build (job #{job.id})") IO.puts("Enqueued climatology build (job #{job.id})")
end end
@spec native_backfill(pos_integer()) :: :ok
def native_backfill(limit \\ 500) do def native_backfill(limit \\ 500) do
_ = start_app() _ = start_app()
@ -87,6 +93,7 @@ defmodule Microwaveprop.Release do
IO.puts("Done.") IO.puts("Done.")
end end
@spec recalibrate() :: :ok
def recalibrate do def recalibrate do
_ = start_app() _ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "recalibrate"})) {:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "recalibrate"}))
@ -97,11 +104,13 @@ defmodule Microwaveprop.Release do
# propagation_scores, which are gone now that scores live as binary # propagation_scores, which are gone now that scores live as binary
# files on disk. Left as a stub that tells the operator what # files on disk. Left as a stub that tells the operator what
# happened if they shell into a running release from muscle memory. # happened if they shell into a running release from muscle memory.
@spec scorer_diff(String.t()) :: :ok
def scorer_diff(_new_weights_json) do def scorer_diff(_new_weights_json) do
_ = start_app() _ = start_app()
IO.puts("scorer_diff is disabled: propagation_scores table + factors storage have been dropped.") IO.puts("scorer_diff is disabled: propagation_scores table + factors storage have been dropped.")
end end
@spec native_derive(pos_integer()) :: :ok
def native_derive(limit \\ 10_000) do def native_derive(limit \\ 10_000) do
_ = start_app() _ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "native_derive", limit: limit})) {:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "native_derive", limit: limit}))

View file

@ -11,6 +11,7 @@ defmodule Microwaveprop.RepoListener do
@channels ["contact_status_changed", "oban_job_changed"] @channels ["contact_status_changed", "oban_job_changed"]
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__) GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end end