From 6cff9f46cec1cb225b4db4ec660b660f09ea1479 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Thu, 21 Nov 2019 15:51:13 +0100 Subject: [PATCH] Added mix commands to manage users and view actors Signed-off-by: Thomas Citharel --- CHANGELOG.md | 3 +- lib/mix/tasks/mobilizon/actors/show.ex | 34 +++++ lib/mix/tasks/mobilizon/users.ex | 14 +++ lib/mix/tasks/mobilizon/users/delete.ex | 47 +++++++ lib/mix/tasks/mobilizon/users/modify.ex | 102 +++++++++++++++ lib/mix/tasks/mobilizon/users/new.ex | 75 +++++++++++ lib/mix/tasks/mobilizon/users/show.ex | 48 +++++++ lib/mobilizon/actors/actors.ex | 2 +- lib/mobilizon/users/user.ex | 16 +-- test/tasks/actors_test.exs | 52 ++++++++ test/tasks/users_test.exs | 158 ++++++++++++++++++++++++ 11 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 lib/mix/tasks/mobilizon/actors/show.ex create mode 100644 lib/mix/tasks/mobilizon/users.ex create mode 100644 lib/mix/tasks/mobilizon/users/delete.ex create mode 100644 lib/mix/tasks/mobilizon/users/modify.ex create mode 100644 lib/mix/tasks/mobilizon/users/new.ex create mode 100644 lib/mix/tasks/mobilizon/users/show.ex create mode 100644 test/tasks/actors_test.exs create mode 100644 test/tasks/users_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a7e0cb9..438aafd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ In order to move participant stats to the event table for existing events, you n - Make tags clickable, redirecting to search - Add a different welcome message when coming from registration - Link to participation page from event page when you are an organizer -- Added a warning on login that everything is deleted regularily +- Added mix commands to manage users and view actors +- Added a warning on login that everything is deleted regularly - Updated Occitan translations (Quentin) - Updated French translations (Gavy, Zilverspar, ty kayn) - Updated Swedish translations (Anton Strömkvist) diff --git a/lib/mix/tasks/mobilizon/actors/show.ex b/lib/mix/tasks/mobilizon/actors/show.ex new file mode 100644 index 000000000..8abab9e78 --- /dev/null +++ b/lib/mix/tasks/mobilizon/actors/show.ex @@ -0,0 +1,34 @@ +defmodule Mix.Tasks.Mobilizon.Actors.Show do + @moduledoc """ + Task to display an actor details + """ + use Mix.Task + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + + @shortdoc "Show a Mobilizon user details" + + @impl Mix.Task + def run([preferred_username]) do + Mix.Task.run("app.start") + + case {:actor, Actors.get_actor_by_name_with_preload(preferred_username)} do + {:actor, %Actor{} = actor} -> + Mix.shell().info(""" + Informations for the actor #{actor.preferred_username}: + - Type: #{actor.type} + - Domain: #{if is_nil(actor.domain), do: "Local", else: actor.domain} + - Name: #{actor.name} + - Summary: #{actor.summary} + - User: #{if is_nil(actor.user), do: "Remote", else: actor.user.email} + """) + + {:actor, nil} -> + Mix.raise("Error: No such actor") + end + end + + def run(_) do + Mix.raise("mobilizon.actors.show requires an username as argument") + end +end diff --git a/lib/mix/tasks/mobilizon/users.ex b/lib/mix/tasks/mobilizon/users.ex new file mode 100644 index 000000000..6365b5519 --- /dev/null +++ b/lib/mix/tasks/mobilizon/users.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.Mobilizon.Users do + @moduledoc """ + Tasks to manage users + """ + use Mix.Task + + @shortdoc "Manages Mobilizon users" + + @impl Mix.Task + def run(_) do + Mix.shell().info("\nAvailable tasks:") + Mix.Tasks.Help.run(["--search", "mobilizon.users."]) + end +end diff --git a/lib/mix/tasks/mobilizon/users/delete.ex b/lib/mix/tasks/mobilizon/users/delete.ex new file mode 100644 index 000000000..49bb6a3bc --- /dev/null +++ b/lib/mix/tasks/mobilizon/users/delete.ex @@ -0,0 +1,47 @@ +defmodule Mix.Tasks.Mobilizon.Users.Delete do + @moduledoc """ + Task to delete a user + """ + use Mix.Task + alias Mobilizon.Users + alias Mobilizon.Users.User + + @shortdoc "Deletes a Mobilizon user" + + @impl Mix.Task + def run([email | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + assume_yes: :boolean + ], + aliases: [ + y: :assume_yes + ] + ) + + assume_yes? = Keyword.get(options, :assume_yes, false) + + Mix.Task.run("app.start") + + with {:ok, %User{} = user} <- Users.get_user_by_email(email), + true <- assume_yes? or Mix.shell().yes?("Continue with deleting user #{user.email}?"), + {:ok, %User{} = user} <- + Users.delete_user(user) do + Mix.shell().info(""" + The user #{user.email} has been deleted + """) + else + {:error, :user_not_found} -> + Mix.raise("Error: No such user") + + _ -> + Mix.raise("User has not been deleted.") + end + end + + def run(_) do + Mix.raise("mobilizon.users.delete requires an email as argument") + end +end diff --git a/lib/mix/tasks/mobilizon/users/modify.ex b/lib/mix/tasks/mobilizon/users/modify.ex new file mode 100644 index 000000000..66e0b26ee --- /dev/null +++ b/lib/mix/tasks/mobilizon/users/modify.ex @@ -0,0 +1,102 @@ +defmodule Mix.Tasks.Mobilizon.Users.Modify do + @moduledoc """ + Task to modify an existing Mobilizon user + """ + use Mix.Task + alias Mobilizon.Users + alias Mobilizon.Users.User + + @shortdoc "Modify a Mobilizon user" + + @impl Mix.Task + def run([email | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + email: :string, + disable: :boolean, + enable: :boolean, + user: :boolean, + moderator: :boolean, + admin: :boolean + ] + ) + + user? = Keyword.get(options, :user, false) + moderator? = Keyword.get(options, :moderator, false) + admin? = Keyword.get(options, :admin, false) + disable? = Keyword.get(options, :disable, false) + enable? = Keyword.get(options, :enable, false) + new_email = Keyword.get(options, :email) + + if disable? && enable? do + Mix.raise("Can't use both --enabled and --disable options at the same time.") + end + + Mix.Task.run("app.start") + + with {:ok, %User{} = user} <- Users.get_user_by_email(email), + attrs <- %{}, + role <- calculate_role(admin?, moderator?, user?), + attrs <- process_new_value(attrs, :mail, new_email, user.email), + attrs <- process_new_value(attrs, :role, role, user.role), + attrs <- + if(disable? && !is_nil(user.confirmed_at), + do: Map.put(attrs, :confirmed_at, nil), + else: attrs + ), + attrs <- + if(enable? && is_nil(user.confirmed_at), + do: Map.put(attrs, :confirmed_at, DateTime.utc_now()), + else: attrs + ), + {:makes_changes, true} <- {:makes_changes, attrs != %{}}, + {:ok, %User{} = user} <- Users.update_user(user, attrs) do + Mix.shell().info(""" + An user has been modified with the following information: + - email: #{user.email} + - Role: #{user.role} + - Activated: #{if user.confirmed_at, do: user.confirmed_at, else: "False"} + """) + else + {:makes_changes, false} -> + Mix.shell().info("No change has been made") + + {:error, :user_not_found} -> + Mix.raise("Error: No such user") + + {:error, %Ecto.Changeset{errors: errors}} -> + Mix.shell().error(inspect(errors)) + Mix.raise("User has not been modified because of the above reason.") + + err -> + Mix.shell().error(inspect(err)) + Mix.raise("User has not been modified because of an unknown reason.") + end + end + + def run(_) do + Mix.raise("mobilizon.users.new requires an email as argument") + end + + @spec process_new_value(map(), atom(), any(), any()) :: map() + defp process_new_value(attrs, attribute, new_value, old_value) do + if !is_nil(new_value) && new_value != old_value do + Map.put(attrs, attribute, new_value) + else + attrs + end + end + + @spec calculate_role(boolean(), boolean(), boolean()) :: + :administrator | :moderator | :user | nil + defp calculate_role(admin?, moderator?, user?) do + cond do + admin? -> :administrator + moderator? -> :moderator + user? -> :user + true -> nil + end + end +end diff --git a/lib/mix/tasks/mobilizon/users/new.ex b/lib/mix/tasks/mobilizon/users/new.ex new file mode 100644 index 000000000..d0802b329 --- /dev/null +++ b/lib/mix/tasks/mobilizon/users/new.ex @@ -0,0 +1,75 @@ +defmodule Mix.Tasks.Mobilizon.Users.New do + @moduledoc """ + Task to create a new user + """ + use Mix.Task + alias Mobilizon.Users + alias Mobilizon.Users.User + + @shortdoc "Manages Mobilizon users" + + @impl Mix.Task + def run([email | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + password: :string, + moderator: :boolean, + admin: :boolean + ], + aliases: [ + p: :password + ] + ) + + moderator? = Keyword.get(options, :moderator, false) + admin? = Keyword.get(options, :admin, false) + + role = + cond do + admin? -> :administrator + moderator? -> :moderator + true -> :user + end + + password = + Keyword.get( + options, + :password, + :crypto.strong_rand_bytes(16) |> Base.encode64() |> binary_part(0, 16) + ) + + Mix.Task.run("app.start") + + case Users.register(%{ + email: email, + password: password, + role: role, + confirmed_at: DateTime.utc_now(), + confirmation_sent_at: nil, + confirmation_token: nil + }) do + {:ok, %User{} = user} -> + Mix.shell().info(""" + An user has been created with the following information: + - email: #{user.email} + - password: #{password} + - Role: #{user.role} + The user will be prompted to create a new profile after login for the first time. + """) + + {:error, %Ecto.Changeset{errors: errors}} -> + Mix.shell().error(inspect(errors)) + Mix.raise("User has not been created because of the above reason.") + + err -> + Mix.shell().error(inspect(err)) + Mix.raise("User has not been created because of an unknown reason.") + end + end + + def run(_) do + Mix.raise("mobilizon.users.new requires an email as argument") + end +end diff --git a/lib/mix/tasks/mobilizon/users/show.ex b/lib/mix/tasks/mobilizon/users/show.ex new file mode 100644 index 000000000..515c62caa --- /dev/null +++ b/lib/mix/tasks/mobilizon/users/show.ex @@ -0,0 +1,48 @@ +defmodule Mix.Tasks.Mobilizon.Users.Show do + @moduledoc """ + Task to display an user details + """ + use Mix.Task + alias Mobilizon.Users + alias Mobilizon.Users.User + alias Mobilizon.Actors.Actor + + @shortdoc "Show a Mobilizon user details" + + @impl Mix.Task + def run([email]) do + Mix.Task.run("app.start") + + with {:ok, %User{} = user} <- Users.get_user_by_email(email), + actors <- Users.get_actors_for_user(user) do + Mix.shell().info(""" + Informations for the user #{user.email}: + - Activated: #{user.confirmed_at} + - Role: #{user.role} + #{display_actors(actors)} + """) + else + {:error, :user_not_found} -> + Mix.raise("Error: No such user") + end + end + + def run(_) do + Mix.raise("mobilizon.users.show requires an email as argument") + end + + defp display_actors([]), do: "" + + defp display_actors(actors) do + """ + Identities (#{length(actors)}): + #{actors |> Enum.map(&display_actor/1) |> Enum.join("")} + """ + end + + defp display_actor(%Actor{} = actor) do + """ + - @#{actor.preferred_username} / #{actor.name} + """ + end +end diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 84477b9ed..a928b85e7 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -156,7 +156,7 @@ defmodule Mobilizon.Actors do def get_actor_by_name_with_preload(name, type \\ nil) do name |> get_actor_by_name(type) - |> Repo.preload(:organized_events) + |> Repo.preload([:organized_events, :user]) end @doc """ diff --git a/lib/mobilizon/users/user.ex b/lib/mobilizon/users/user.ex index 84617f1fe..a87875158 100644 --- a/lib/mobilizon/users/user.ex +++ b/lib/mobilizon/users/user.ex @@ -137,7 +137,7 @@ defmodule Mobilizon.Users.User do end @doc """ - Checks whether an user is confirmed. + Checks whether an user is confirmed. """ @spec is_confirmed(t) :: boolean def is_confirmed(%__MODULE__{confirmed_at: nil}), do: false @@ -154,20 +154,22 @@ defmodule Mobilizon.Users.User do end @spec save_confirmation_token(Ecto.Changeset.t()) :: Ecto.Changeset.t() - defp save_confirmation_token(%Ecto.Changeset{} = changeset) do - case changeset do - %Ecto.Changeset{valid?: true, changes: %{email: _email}} -> - now = DateTime.utc_now() - + defp save_confirmation_token( + %Ecto.Changeset{valid?: true, changes: %{email: _email}} = changeset + ) do + case fetch_change(changeset, :confirmed_at) do + :error -> changeset |> put_change(:confirmation_token, Crypto.random_string(@confirmation_token_length)) - |> put_change(:confirmation_sent_at, DateTime.truncate(now, :second)) + |> put_change(:confirmation_sent_at, DateTime.utc_now() |> DateTime.truncate(:second)) _ -> changeset end end + defp save_confirmation_token(%Ecto.Changeset{} = changeset), do: changeset + @spec validate_email(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp validate_email(%Ecto.Changeset{} = changeset) do changeset = validate_length(changeset, :email, min: 3, max: 250) diff --git a/test/tasks/actors_test.exs b/test/tasks/actors_test.exs new file mode 100644 index 000000000..05fc460c0 --- /dev/null +++ b/test/tasks/actors_test.exs @@ -0,0 +1,52 @@ +defmodule Mix.Tasks.Mobilizon.ActorsTest do + use Mobilizon.DataCase + + alias Mobilizon.Actors.Actor + alias Mix.Tasks.Mobilizon.Actors.Show + import Mobilizon.Factory + + Mix.shell(Mix.Shell.Process) + + @username "someone" + @domain "somewhere.tld" + + describe "show actor" do + test "show existing local actor" do + %Actor{} = actor = insert(:actor, preferred_username: @username) + + output = """ + Informations for the actor #{@username}: + - Type: Person + - Domain: Local + - Name: #{actor.name} + - Summary: #{actor.summary} + - User: #{actor.user.email} + """ + + Show.run([@username]) + assert_received {:mix_shell, :info, [output_received]} + assert output_received == output + end + + test "show existing remote actor" do + %Actor{} = actor = insert(:actor, preferred_username: @username, user: nil, domain: @domain) + + output = """ + Informations for the actor #{@username}: + - Type: Person + - Domain: #{@domain} + - Name: #{actor.name} + - Summary: #{actor.summary} + - User: Remote + """ + + Show.run(["#{@username}@#{@domain}"]) + assert_received {:mix_shell, :info, [output_received]} + assert output_received == output + end + + test "show non-existing actor" do + assert_raise Mix.Error, "Error: No such actor", fn -> Show.run([@username]) end + end + end +end diff --git a/test/tasks/users_test.exs b/test/tasks/users_test.exs new file mode 100644 index 000000000..812666c90 --- /dev/null +++ b/test/tasks/users_test.exs @@ -0,0 +1,158 @@ +defmodule Mix.Tasks.Mobilizon.UsersTest do + use Mobilizon.DataCase + + alias Mobilizon.Users + alias Mobilizon.Users.User + alias Mix.Tasks.Mobilizon.Users.{New, Delete, Show, Modify} + import Mobilizon.Factory + + Mix.shell(Mix.Shell.Process) + + @email "test@email.tld" + describe "create user" do + test "create with no options" do + New.run([@email]) + + assert {:ok, %User{email: email, role: role, confirmed_at: confirmed_at}} = + Users.get_user_by_email(@email) + + assert email == @email + assert role == :user + refute is_nil(confirmed_at) + end + + test "create a moderator" do + New.run([@email, "--moderator"]) + + assert {:ok, %User{email: email, role: role}} = Users.get_user_by_email(@email) + assert email == @email + assert role == :moderator + end + + test "create an administrator" do + New.run([@email, "--admin"]) + + assert {:ok, %User{email: email, role: role}} = Users.get_user_by_email(@email) + assert email == @email + assert role == :administrator + end + + test "create with already used email" do + insert(:user, email: @email) + + assert_raise Mix.Error, "User has not been created because of the above reason.", fn -> + New.run([@email]) + end + end + end + + describe "delete user" do + test "delete existing user" do + insert(:user, email: @email) + Delete.run([@email, "-y"]) + assert {:error, :user_not_found} == Users.get_user_by_email(@email) + end + + test "delete non-existing user" do + assert_raise Mix.Error, "Error: No such user", fn -> Delete.run([@email, "-y"]) end + end + end + + describe "show user" do + test "show existing user" do + %User{confirmed_at: confirmed_at, role: role} = user = insert(:user, email: @email) + actor1 = insert(:actor, user: user) + actor2 = insert(:actor, user: user) + + output = + "Informations for the user #{@email}:\n - Activated: #{confirmed_at}\n - Role: #{role}\n Identities (2):\n - @#{ + actor1.preferred_username + } / \n - @#{actor2.preferred_username} / \n\n\n" + + Show.run([@email]) + assert_received {:mix_shell, :info, [output_received]} + assert output_received == output + end + + test "show non-existing user" do + assert_raise Mix.Error, "Error: No such user", fn -> Show.run([@email]) end + end + end + + describe "modify user" do + test "modify existing user without any changes" do + insert(:user, email: @email) + + Modify.run([@email]) + assert_received {:mix_shell, :info, [output_received]} + assert output_received == "No change has been made" + end + + test "promote an user to moderator" do + insert(:user, email: @email) + + Modify.run([@email, "--moderator"]) + assert {:ok, %User{role: role}} = Users.get_user_by_email(@email) + assert role == :moderator + end + + test "promote an user to administrator" do + insert(:user, email: @email) + + Modify.run([@email, "--admin"]) + assert {:ok, %User{role: role}} = Users.get_user_by_email(@email) + assert role == :administrator + + Modify.run([@email, "--user"]) + assert {:ok, %User{role: role}} = Users.get_user_by_email(@email) + assert role == :user + end + + test "disable and enable an user" do + user = insert(:user, email: @email) + + Modify.run([@email, "--disable"]) + assert_received {:mix_shell, :info, [output_received]} + + assert output_received == + "An user has been modified with the following information:\n - email: #{ + user.email + }\n - Role: #{user.role}\n - Activated: False\n" + + assert {:ok, %User{email: email, confirmed_at: confirmed_at}} = + Users.get_user_by_email(@email) + + assert is_nil(confirmed_at) + + Modify.run([@email, "--enable"]) + assert_received {:mix_shell, :info, [output_received]} + + assert {:ok, %User{email: email, confirmed_at: confirmed_at}} = + Users.get_user_by_email(@email) + + assert output_received == + "An user has been modified with the following information:\n - email: #{ + user.email + }\n - Role: #{user.role}\n - Activated: #{confirmed_at}\n" + + refute is_nil(confirmed_at) + + Modify.run([@email, "--enable"]) + + assert {:ok, %User{email: email, confirmed_at: confirmed_at}} = + Users.get_user_by_email(@email) + + refute is_nil(confirmed_at) + assert_received {:mix_shell, :info, [output_received]} + assert output_received == "No change has been made" + end + + test "enable and disable at the same time" do + assert_raise Mix.Error, + "Can't use both --enabled and --disable options at the same time.", + fn -> + Modify.run([@email, "--disable", "--enable"]) + end + end + end +end