From 11e75eaf6689b405263b876eabc65c3f8717057b Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
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 <tcit@tcit.fr>
---
 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