From 11e75eaf6689b405263b876eabc65c3f8717057b Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 20 Jul 2021 18:22:18 +0200 Subject: [PATCH] Add the possibility to create profiles and groups from CLI - Create an actor at the same time when creating an user - or create either a profile and attach it to an existing user - or create a group and set the admin to an existing profile Closes #785 Signed-off-by: Thomas Citharel --- lib/mix/tasks/mobilizon/actors/new.ex | 102 +++++++++++++ lib/mix/tasks/mobilizon/actors/utils.ex | 58 ++++++++ lib/mix/tasks/mobilizon/common.ex | 14 +- lib/mix/tasks/mobilizon/users/new.ex | 33 ++++- lib/mobilizon/actors/actors.ex | 9 ++ test/tasks/actors/new_test.exs | 185 ++++++++++++++++++++++++ test/tasks/users_test.exs | 85 ++++++++++- 7 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 lib/mix/tasks/mobilizon/actors/new.ex create mode 100644 lib/mix/tasks/mobilizon/actors/utils.ex create mode 100644 test/tasks/actors/new_test.exs diff --git a/lib/mix/tasks/mobilizon/actors/new.ex b/lib/mix/tasks/mobilizon/actors/new.ex new file mode 100644 index 000000000..48124d816 --- /dev/null +++ b/lib/mix/tasks/mobilizon/actors/new.ex @@ -0,0 +1,102 @@ +defmodule Mix.Tasks.Mobilizon.Actors.New do + @moduledoc """ + Task to create a new user + """ + use Mix.Task + import Mix.Tasks.Mobilizon.Actors.Utils + import Mix.Tasks.Mobilizon.Common + alias Mobilizon.Actors.Actor + alias Mobilizon.{Actors, Users} + alias Mobilizon.Users.User + + @shortdoc "Manages Mobilizon users" + + @impl Mix.Task + def run(rest) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + email: :string, + username: :string, + display_name: :string, + group_admin: :string, + type: :string + ], + aliases: [ + e: :email, + u: :username, + d: :display_name, + t: :type, + a: :group_admin + ] + ) + + start_mobilizon() + + profile_username = Keyword.get(options, :username) + profile_name = Keyword.get(options, :display_name) + + if profile_name != nil || profile_username != nil do + else + shell_error("You need to provide at least --username or --display-name.") + end + + case Keyword.get(options, :type, "profile") do + "profile" -> + do_create_profile(options, profile_username, profile_name) + + "group" -> + do_create_group(options, profile_username, profile_name) + end + end + + @spec do_create_profile(Keyword.t(), String.t(), String.t()) :: Actor.t() | nil + defp do_create_profile(options, profile_username, profile_name) do + with {:email, email} when is_binary(email) <- {:email, Keyword.get(options, :email)}, + {:ok, %User{} = user} <- Users.get_user_by_email(email), + %Actor{preferred_username: preferred_username, name: name} <- + create_profile(user, profile_username, profile_name, default: false) do + shell_info(""" + A profile was created for user #{email} with the following information: + - username: #{preferred_username} + - display name: #{name} + """) + else + {:email, nil} -> + shell_error("You need to provide an email for creating a new profile.") + + {:error, :user_not_found} -> + shell_error("No user with this email was found.") + + nil -> + nil + end + end + + defp do_create_group(options, profile_username, profile_name) do + with {:option, admin_name} when is_binary(admin_name) <- + {:option, Keyword.get(options, :group_admin)}, + {:admin, %Actor{} = admin} <- {:admin, Actors.get_local_actor_by_name(admin_name)}, + {:ok, %Actor{preferred_username: preferred_username, name: name}} <- + create_group(admin, profile_username, profile_name) do + shell_info(""" + A group was created with profile #{admin_name} as the admin and with the following information: + - username: #{preferred_username} + - display name: #{name} + """) + else + {:option, nil} -> + shell_error( + "You need to provide --group-admin with the username of the admin to create a group." + ) + + {:admin, nil} -> + shell_error("Profile with username #{Keyword.get(options, :group_admin)} wasn't found") + + {:error, :insert_group, %Ecto.Changeset{errors: errors}, _} -> + shell_error(inspect(errors)) + shell_error("Error while creating group because of the above reason") + end + end +end diff --git a/lib/mix/tasks/mobilizon/actors/utils.ex b/lib/mix/tasks/mobilizon/actors/utils.ex new file mode 100644 index 000000000..fd7e2e911 --- /dev/null +++ b/lib/mix/tasks/mobilizon/actors/utils.ex @@ -0,0 +1,58 @@ +defmodule Mix.Tasks.Mobilizon.Actors.Utils do + @moduledoc """ + Tools for generating usernames from display names + """ + + alias Mobilizon.Actors + alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User + + @doc """ + Removes all spaces, accents, special characters and diacritics from a string to create a plain ascii username (a-z0-9_) + + See https://stackoverflow.com/a/37511463 + """ + @spec generate_username(String.t()) :: String.t() + def generate_username(""), do: "" + + def generate_username(name) do + name + |> String.downcase() + |> String.normalize(:nfd) + |> String.replace(~r/[\x{0300}-\x{036f}]/u, "") + |> String.replace(~r/ /, "_") + |> String.replace(~r/[^a-z0-9_]/, "") + end + + # Profile from name + @spec username_and_name(String.t() | nil, String.t() | nil) :: String.t() + def username_and_name(nil, profile_name) do + {generate_username(profile_name), profile_name} + end + + def username_and_name(profile_username, nil) do + {profile_username, profile_username} + end + + def username_and_name(profile_username, profile_name) do + {profile_username, profile_name} + end + + def create_profile(%User{id: user_id}, username, name, options \\ []) do + {username, name} = username_and_name(username, name) + + {:ok, %Actor{} = new_person} = + Actors.new_person( + %{preferred_username: username, user_id: user_id, name: name}, + Keyword.get(options, :default, true) + ) + + new_person + end + + def create_group(%Actor{id: admin_id}, username, name, _options \\ []) do + {username, name} = username_and_name(username, name) + + Actors.create_group(%{creator_actor_id: admin_id, preferred_username: username, name: name}) + end +end diff --git a/lib/mix/tasks/mobilizon/common.ex b/lib/mix/tasks/mobilizon/common.ex index 4ee5d96f9..8cc3ccffe 100644 --- a/lib/mix/tasks/mobilizon/common.ex +++ b/lib/mix/tasks/mobilizon/common.ex @@ -62,10 +62,16 @@ defmodule Mix.Tasks.Mobilizon.Common do end @spec shell_error(String.t()) :: :ok - def shell_error(message) do - if mix_shell?(), - do: Mix.shell().error(message), - else: IO.puts(:stderr, message) + def shell_error(message, options \\ []) do + if mix_shell?() do + Mix.shell().error(message) + else + IO.puts(:stderr, message) + end + + if Application.fetch_env!(:mobilizon, :env) != :test do + exit({:shutdown, Keyword.get(options, :error_code, 1)}) + end end @doc "Performs a safe check whether `Mix.shell/0` is available (does not raise if Mix is not loaded)" diff --git a/lib/mix/tasks/mobilizon/users/new.ex b/lib/mix/tasks/mobilizon/users/new.ex index 453762798..a91ef8d52 100644 --- a/lib/mix/tasks/mobilizon/users/new.ex +++ b/lib/mix/tasks/mobilizon/users/new.ex @@ -4,6 +4,8 @@ defmodule Mix.Tasks.Mobilizon.Users.New do """ use Mix.Task import Mix.Tasks.Mobilizon.Common + import Mix.Tasks.Mobilizon.Actors.Utils + alias Mobilizon.Actors.Actor alias Mobilizon.Users alias Mobilizon.Users.User @@ -17,7 +19,9 @@ defmodule Mix.Tasks.Mobilizon.Users.New do strict: [ password: :string, moderator: :boolean, - admin: :boolean + admin: :boolean, + profile_username: :string, + profile_display_name: :string ], aliases: [ p: :password @@ -52,14 +56,27 @@ defmodule Mix.Tasks.Mobilizon.Users.New do confirmation_token: nil }) do {:ok, %User{} = user} -> + profile = maybe_create_profile(user, options) + 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. """) + if is_nil(profile) do + shell_info(""" + The user will be prompted to create a new profile after login for the first time. + """) + else + shell_info(""" + A profile was added with the following information: + - username: #{profile.preferred_username} + - display name: #{profile.name} + """) + end + {:error, %Ecto.Changeset{errors: errors}} -> shell_error(inspect(errors)) shell_error("User has not been created because of the above reason.") @@ -73,4 +90,16 @@ defmodule Mix.Tasks.Mobilizon.Users.New do def run(_) do shell_error("mobilizon.users.new requires an email as argument") end + + @spec maybe_create_profile(User.t(), Keyword.t()) :: Actor.t() | nil + defp maybe_create_profile(%User{} = user, options) do + profile_username = Keyword.get(options, :profile_username) + profile_name = Keyword.get(options, :profile_display_name) + + if profile_name != nil || profile_username != nil do + create_profile(user, profile_username, profile_name) + else + nil + end + end end diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 5b1466f45..f24b68c30 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -741,6 +741,9 @@ defmodule Mobilizon.Actors do end end + @doc """ + Returns whether the `actor_id` is a confirmed member for the group `parent_id` + """ @spec is_member?(integer | String.t(), integer | String.t()) :: boolean() def is_member?(actor_id, parent_id) do match?( @@ -749,6 +752,9 @@ defmodule Mobilizon.Actors do ) end + @doc """ + Returns whether the `actor_id` is a moderator for the group `parent_id` + """ @spec is_moderator?(integer | String.t(), integer | String.t()) :: boolean() def is_moderator?(actor_id, parent_id) do match?( @@ -757,6 +763,9 @@ defmodule Mobilizon.Actors do ) end + @doc """ + Returns whether the `actor_id` is an administrator for the group `parent_id` + """ @spec is_administrator?(integer | String.t(), integer | String.t()) :: boolean() def is_administrator?(actor_id, parent_id) do match?( diff --git a/test/tasks/actors/new_test.exs b/test/tasks/actors/new_test.exs new file mode 100644 index 000000000..ff93a2388 --- /dev/null +++ b/test/tasks/actors/new_test.exs @@ -0,0 +1,185 @@ +defmodule Mix.Tasks.Mobilizon.Actors.NewTest do + use Mobilizon.DataCase + + import Mobilizon.Factory + + alias Mix.Tasks.Mobilizon.Actors.New + + alias Mobilizon.{Actors, Users} + alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User + + Mix.shell(Mix.Shell.Process) + + @email "me@some.where" + describe "create profile" do + setup do + %User{} = user = insert(:user, email: @email) + + {:ok, user: user} + end + + @preferred_username "toto" + @name "Léo Pandaï" + @converted_username "leo_pandai" + + test "create with no options" do + New.run([]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "You need to provide at least --username or --display-name." + end + + test "create when email isn't set" do + New.run(["--display-name", @name]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "You need to provide an email for creating a new profile." + end + + test "create when email doesn't exist" do + New.run(["--email", "toto@somewhere.else", "--display-name", @name]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "No user with this email was found." + end + + test "create with --display-name" do + New.run(["--email", @email, "--display-name", @name]) + + assert {:ok, %User{id: user_id}} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @converted_username, + name: @name, + domain: nil, + user_id: ^user_id + } = Actors.get_local_actor_by_name(@converted_username) + end + + test "create with --username" do + New.run(["--email", @email, "--username", @preferred_username]) + + assert {:ok, %User{id: user_id}} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @preferred_username, + name: @preferred_username, + domain: nil, + user_id: ^user_id + } = Actors.get_local_actor_by_name(@preferred_username) + end + + test "create with --username and --display-name" do + New.run(["--email", @email, "--username", @preferred_username, "--display-name", @name]) + + assert {:ok, %User{id: user_id}} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @preferred_username, + name: @name, + domain: nil, + user_id: ^user_id + } = Actors.get_local_actor_by_name(@preferred_username) + end + end + + describe "create group" do + @already_existing_group "already_there" + @already_existing_group_name "Already Thére" + @profile_username "theo" + + setup do + group = insert(:group, preferred_username: @already_existing_group) + profile = insert(:actor, preferred_username: @profile_username) + {:ok, group: group, profile: profile} + end + + test "create with no options" do + New.run(["--type", "group"]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "You need to provide at least --username or --display-name." + end + + test "create when email isn't set" do + New.run(["--type", "group", "--display-name", @name]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "You need to provide --group-admin with the username of the admin to create a group." + end + + test "create when group admin doesn't exist" do + New.run([ + "--type", + "group", + "--display-name", + @name, + "--group-admin", + "some0ne_98" + ]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "Profile with username some0ne_98 wasn't found" + end + + test "create but the group already exists" do + New.run([ + "--type", + "group", + "--display-name", + @already_existing_group_name, + "--group-admin", + @profile_username + ]) + + # Debug message + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "[preferred_username: {\"This username is already taken.\", []}]" + + assert_received {:mix_shell, :error, [message]} + + assert message =~ + "Error while creating group because of the above reason" + end + + @group_name "My Awesome Group" + @group_username "my_awesome_group" + + test "create group", %{profile: %Actor{id: admin_id}} do + New.run([ + "--type", + "group", + "--display-name", + @group_name, + "--group-admin", + @profile_username + ]) + + assert %Actor{name: @group_name, preferred_username: @group_username, id: group_id} = + Actors.get_group_by_title(@group_username) + + assert Actors.is_administrator?(admin_id, group_id) + end + end +end diff --git a/test/tasks/users_test.exs b/test/tasks/users_test.exs index 7400be48b..d0b0e7ee3 100644 --- a/test/tasks/users_test.exs +++ b/test/tasks/users_test.exs @@ -5,7 +5,8 @@ defmodule Mix.Tasks.Mobilizon.UsersTest do alias Mix.Tasks.Mobilizon.Users.{Delete, Modify, New, Show} - alias Mobilizon.Users + alias Mobilizon.{Actors, Users} + alias Mobilizon.Actors.Actor alias Mobilizon.Users.User Mix.shell(Mix.Shell.Process) @@ -52,6 +53,88 @@ defmodule Mix.Tasks.Mobilizon.UsersTest do assert_received {:mix_shell, :error, [message]} assert message =~ "User has not been created because of the above reason." end + + @preferred_username "toto" + @name "Léo Pandaï" + @converted_username "leo_pandai" + + test "create a profile with the user" do + New.run([@email, "--profile-username", @preferred_username, "--profile-display-name", @name]) + + assert {:ok, + %User{ + email: email, + role: role, + confirmed_at: confirmed_at, + id: user_id, + default_actor_id: user_default_actor_id + }} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @preferred_username, + name: @name, + domain: nil, + user_id: ^user_id, + id: actor_id + } = Actors.get_local_actor_by_name(@preferred_username) + + assert user_default_actor_id == actor_id + assert email == @email + assert role == :user + refute is_nil(confirmed_at) + end + + test "create a profile from displayed name only" do + New.run([@email, "--profile-display-name", @name]) + + assert {:ok, + %User{ + email: email, + role: role, + confirmed_at: confirmed_at, + id: user_id, + default_actor_id: user_default_actor_id + }} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @converted_username, + name: @name, + domain: nil, + user_id: ^user_id, + id: actor_id + } = Actors.get_local_actor_by_name(@converted_username) + + assert user_default_actor_id == actor_id + assert email == @email + assert role == :user + refute is_nil(confirmed_at) + end + + test "create a profile from username only" do + New.run([@email, "--profile-username", @preferred_username]) + + assert {:ok, + %User{ + email: email, + role: role, + confirmed_at: confirmed_at, + id: user_id, + default_actor_id: user_default_actor_id + }} = Users.get_user_by_email(@email) + + assert %Actor{ + preferred_username: @preferred_username, + name: @preferred_username, + domain: nil, + user_id: ^user_id, + id: actor_id + } = Actors.get_local_actor_by_name(@preferred_username) + + assert user_default_actor_id == actor_id + assert email == @email + assert role == :user + refute is_nil(confirmed_at) + end end describe "delete user" do