От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.

В этой части мы закончим разграничение прав доступа с использованием ролей. Ключевой момент данной серии статей — здесь очень много внимания уделяется тестам, а тесты — это здорово!


На данный момент наше приложение основано на:

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2
  • Comeonin: v2.5.2

Где мы остановились


В прошлый раз мы расстались с вами на добавлении понятия роли внутрь моделей и создании вспомогательных функций для тестов, чтобы немного облегчить себе жизнь. Теперь нам нужно добавить внутрь контроллеров основанные на ролях ограничения. Начнём с создания вспомогательной функции, которую мы сможем использовать в любом контроллере.

Создание вспомогательной функции для проверки ролей


Первым шагом на сегодня станет создание простой проверки пользователя на наличие прав администратора. Для этого создайте файл web/models/role_checker.ex и заполните его следующим кодом:

defmodule Pxblog.RoleChecker do
  alias Pxblog.Repo
  alias Pxblog.Role

  def is_admin?(user) do
    (role = Repo.get(Role, user.role_id)) && role.admin
  end
end

Также давайте напишем несколько тестов для покрытия этой функциональности. Откройте файл test/models/role_checker_test.exs:

defmodule Pxblog.RoleCheckerTest do
  use Pxblog.ModelCase
  alias Pxblog.TestHelper
  alias Pxblog.RoleChecker

  test "is_admin? is true when user has an admin role" do
    {:ok, role} = TestHelper.create_role(%{name: "Admin", admin: true})
    {:ok, user} = TestHelper.create_user(role, %{email: "test@test.com", username: "user", password: "test", password_confirmation: "test"})
    assert RoleChecker.is_admin?(user)
  end

  test "is_admin? is false when user does not have an admin role" do
    {:ok, role} = TestHelper.create_role(%{name: "User", admin: false})
    {:ok, user} = TestHelper.create_user(role, %{email: "test@test.com", username: "user", password: "test", password_confirmation: "test"})
    refute RoleChecker.is_admin?(user)
  end
end

В первом тесте мы создаём администратора, а во втором обычного пользователя. А в конце проверяем, что функция is_admin? возвращает true для первого и false для второго. Так как функция is_admin? из модуля RoleChecker требует наличие пользователя, мы можем написать очень простой тест, чтобы проверить работоспособность. Получается код, в котором мы можем быть уверены! Запускаем тесты и убеждаемся, что они остаются зелёными.

Разрешаем добавлять пользователей только администратору


Ранее мы не добавляли никаких ограничений в UserController, так что сейчас самое время подключить плаг authorize_user. Давайте быстренько спланируем, что же сейчас будем делать. Мы позволим пользователям редактировать, обновлять и удалять их собственные профили, но добавлять новых пользователей смогут только администраторы.

Под строчкой scrub_params в файле web/controllers/user_controller.ex добавим следующее:

plug :authorize_admin when action in [:new, :create]
plug :authorize_user when action in [:edit, :update, :delete]

И внизу файла добавим несколько приватных функций для обработки авторизации пользователей и авторизации администраторов:

defp authorize_user(conn, _) do
  user = get_session(conn, :current_user)
  if user && (Integer.to_string(user.id) == conn.params["id"] || Pxblog.RoleChecker.is_admin?(user)) do
    conn
  else
    conn
    |> put_flash(:error, "You are not authorized to modify that user!")
    |> redirect(to: page_path(conn, :index))
    |> halt()
  end
end

defp authorize_admin(conn, _) do
  user = get_session(conn, :current_user)
  if user && Pxblog.RoleChecker.is_admin?(user) do
    conn
  else
    conn
    |> put_flash(:error, "You are not authorized to create new users!")
    |> redirect(to: page_path(conn, :index))
    |> halt()
  end
end

Вызов authorize_user по сути идентичен тому, что у нас было в PostController за исключением проверки RoleChecker.is_admin?.

Функция authorize_admin ещё проще. Мы лишь проверяем, что текущий пользователь является администратором.

Вернёмся к файлу test/controllers/user_controller_test.exs и изменим наши тесты так, чтобы они учитывали новые условия.

Начнём с изменения блока setup.

setup do
  {:ok, user_role}     = TestHelper.create_role(%{name: "user", admin: false})
  {:ok, nonadmin_user} = TestHelper.create_user(user_role, %{email: "nonadmin@test.com", username: "nonadmin", password: "test", password_confirmation: "test"})

  {:ok, admin_role}    = TestHelper.create_role(%{name: "admin", admin: true})
  {:ok, admin_user}    = TestHelper.create_user(admin_role, %{email: "admin@test.com", username: "admin", password: "test", password_confirmation: "test"})

  {:ok, conn: build_conn(), admin_role: admin_role, user_role: user_role, nonadmin_user: nonadmin_user, admin_user: admin_user}
end

Создайте внутри него роль пользователя, роль администратора, обычного пользователя и администратора, после чего верните их. Тем самым мы получим возможность пользоваться ими в тестах через сопоставление с образцом. Нам также понадобится вспомогательная функция для входа, так что скопируйте функцию login_user из PostController.

defp login_user(conn, user) do
  post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end

Мы не добавляем никаких ограничений действию index, поэтому можем пропустить этот тест. На следующий же тест «renders form for new resources» (представляющий действие new) ограничение накладывается. Пользователь должен иметь права администратора.

Измените тест, чтобы он соответствовал следующему коду:

@tag admin: true
test "renders form for new resources", %{conn: conn, admin_user: admin_user} do
  conn = conn
    |> login_user(admin_user)
    |> get(user_path(conn, :new))
  assert html_response(conn, 200) =~ "New user"
end

Добавьте строчку @tag admin: true над этим тестом, чтобы пометить его в качестве администраторского. Таким образом мы сможем запускать только подобные тесты вместо всего набора. Давайте попробуем:

mix test --only admin

В выводе получаем ошибку:

1) test renders form for new resources (Pxblog.UserControllerTest)
 test/controllers/user_controller_test.exs:26
 ** (KeyError) key :role_id not found in: %{id: 348, username: “admin”}
 stacktrace:
 (pxblog) web/models/role_checker.ex:6: Pxblog.RoleChecker.is_admin?/1
 (pxblog) web/controllers/user_controller.ex:84: Pxblog.UserController.authorize_admin/2
 (pxblog) web/controllers/user_controller.ex:1: Pxblog.UserController.phoenix_controller_pipeline/2
 (pxblog) lib/phoenix/router.ex:255: Pxblog.Router.dispatch/2
 (pxblog) web/router.ex:1: Pxblog.Router.do_call/2
 (pxblog) lib/pxblog/endpoint.ex:1: Pxblog.Endpoint.phoenix_pipeline/1
 (pxblog) lib/phoenix/endpoint/render_errors.ex:34: Pxblog.Endpoint.call/2
 (phoenix) lib/phoenix/test/conn_test.ex:193: Phoenix.ConnTest.dispatch/5
 test/controllers/user_controller_test.exs:28

Проблема тут в том, что мы не передаём полную модель пользователя в функцию RoleChecker.is_admin?. А передаём небольшое подмножество данных, получаемое функцией current_user из функции sign_in модуля SessionController.

Давайте добавим к ним также и role_id. Я внёс изменения в файл web/controllers/session_controller.ex как показано ниже:

defp sign_in(user, password, conn) do
  if checkpw(password, user.password_digest) do
    conn
    |> put_session(:current_user, %{id: user.id, username: user.username, role_id: user.role_id})
    |> put_flash(:info, "Sign in successful!")
    |> redirect(to: page_path(conn, :index))
  else
    failed_login(conn)
  end
end

Теперь ещё раз попробуем запустить тесты с тегом admin.

$ mix test --only admin

Снова зелёные! Теперь нам нужно создать тесты для обратной ситуации, когда пользователь не является администратором, но при этом пытается зайти на действие new контроллера UserController. Возвращаемся к файлу test/controllers/user_controller_test.exs:

@tag admin: true
test "redirects from new form when not admin", %{conn: conn, nonadmin_user: nonadmin_user} do
  conn = login_user(conn, nonadmin_user)
  conn = get conn, user_path(conn, :new)
  assert get_flash(conn, :error) == "You are not authorized to create new users!"
  assert redirected_to(conn) == page_path(conn, :index)
  assert conn.halted
end

И сделаем то же самое для действия create. Создадим по одному тесту для обоих случаев.

@tag admin: true
test "creates resource and redirects when data is valid", %{conn: conn, user_role: user_role, admin_user: admin_user} do
  conn = login_user(conn, admin_user)
  conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
  assert redirected_to(conn) == user_path(conn, :index)
  assert Repo.get_by(User, @valid_attrs)
end

@tag admin: true
test "redirects from creating user when not admin", %{conn: conn, user_role: user_role, nonadmin_user: nonadmin_user} do
  conn = login_user(conn, nonadmin_user)
  conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
  assert get_flash(conn, :error) == "You are not authorized to create new users!"
  assert redirected_to(conn) == page_path(conn, :index)
  assert conn.halted
end

@tag admin: true
test "does not create resource and renders errors when data is invalid", %{conn: conn, admin_user: admin_user} do
  conn = login_user(conn, admin_user)
  conn = post conn, user_path(conn, :create), user: @invalid_attrs
  assert html_response(conn, 200) =~ "New user"
end

Мы можем пропустить действие show, т.к. мы не добавили ему никаких новых условий. Мы будем действовать по такому же шаблону до тех пор, пока файл user_controller_test.exs не станет похож на:

defmodule Pxblog.UserControllerTest do
  use Pxblog.ConnCase
  alias Pxblog.User
  alias Pxblog.TestHelper

  @valid_create_attrs %{email: "test@test.com", username: "test", password: "test", password_confirmation: "test"}
  @valid_attrs %{email: "test@test.com", username: "test"}
  @invalid_attrs %{}

  setup do
    {:ok, user_role}     = TestHelper.create_role(%{name: "user", admin: false})
    {:ok, nonadmin_user} = TestHelper.create_user(user_role, %{email: "nonadmin@test.com", username: "nonadmin", password: "test", password_confirmation: "test"})

{:ok, admin_role}    = TestHelper.create_role(%{name: "admin", admin: true})
    {:ok, admin_user}    = TestHelper.create_user(admin_role, %{email: "admin@test.com", username: "admin", password: "test", password_confirmation: "test"})

    {:ok, conn: build_conn(), admin_role: admin_role, user_role: user_role, nonadmin_user: nonadmin_user, admin_user: admin_user}
  end

  defp valid_create_attrs(role) do
    Map.put(@valid_create_attrs, :role_id, role.id)
  end

  defp login_user(conn, user) do
    post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
  end

  test "lists all entries on index", %{conn: conn} do
    conn = get conn, user_path(conn, :index)
    assert html_response(conn, 200) =~ "Listing users"
  end

  @tag admin: true
  test "renders form for new resources", %{conn: conn, admin_user: admin_user} do
    conn = login_user(conn, admin_user)
    conn = get conn, user_path(conn, :new)
    assert html_response(conn, 200) =~ "New user"
  end

  @tag admin: true
  test "redirects from new form when not admin", %{conn: conn, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = get conn, user_path(conn, :new)
    assert get_flash(conn, :error) == "You are not authorized to create new users!"
    assert redirected_to(conn) == page_path(conn, :index)
    assert conn.halted
  end

  @tag admin: true
  test "creates resource and redirects when data is valid", %{conn: conn, user_role: user_role, admin_user: admin_user} do
    conn = login_user(conn, admin_user)
    conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
    assert redirected_to(conn) == user_path(conn, :index)
    assert Repo.get_by(User, @valid_attrs)
  end

  @tag admin: true
  test "redirects from creating user when not admin", %{conn: conn, user_role: user_role, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
    assert get_flash(conn, :error) == "You are not authorized to create new users!"
    assert redirected_to(conn) == page_path(conn, :index)
    assert conn.halted
  end

  @tag admin: true
  test "does not create resource and renders errors when data is invalid", %{conn: conn, admin_user: admin_user} do
    conn = login_user(conn, admin_user)
    conn = post conn, user_path(conn, :create), user: @invalid_attrs
    assert html_response(conn, 200) =~ "New user"
  end

  test "shows chosen resource", %{conn: conn} do
    user = Repo.insert! %User{}
    conn = get conn, user_path(conn, :show, user)
    assert html_response(conn, 200) =~ "Show user"
  end

  test "renders page not found when id is nonexistent", %{conn: conn} do
    assert_error_sent 404, fn ->
      get conn, user_path(conn, :show, -1)
    end
  end

  @tag admin: true
  test "renders form for editing chosen resource when logged in as that user", %{conn: conn, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = get conn, user_path(conn, :edit, nonadmin_user)
    assert html_response(conn, 200) =~ "Edit user"
  end

  @tag admin: true
  test "renders form for editing chosen resource when logged in as an admin", %{conn: conn, admin_user: admin_user, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, admin_user)
    conn = get conn, user_path(conn, :edit, nonadmin_user)
    assert html_response(conn, 200) =~ "Edit user"
  end

  @tag admin: true
  test "redirects away from editing when logged in as a different user", %{conn: conn, nonadmin_user: nonadmin_user, admin_user: admin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = get conn, user_path(conn, :edit, admin_user)
    assert get_flash(conn, :error) == "You are not authorized to modify that user!"
    assert redirected_to(conn) == page_path(conn, :index)
    assert conn.halted
  end

  @tag admin: true
  test "updates chosen resource and redirects when data is valid when logged in as that user", %{conn: conn, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = put conn, user_path(conn, :update, nonadmin_user), user: @valid_create_attrs
    assert redirected_to(conn) == user_path(conn, :show, nonadmin_user)
    assert Repo.get_by(User, @valid_attrs)
  end

  @tag admin: true
  test "updates chosen resource and redirects when data is valid when logged in as an admin", %{conn: conn, admin_user: admin_user} do
    conn = login_user(conn, admin_user)
    conn = put conn, user_path(conn, :update, admin_user), user: @valid_create_attrs
    assert redirected_to(conn) == user_path(conn, :show, admin_user)
    assert Repo.get_by(User, @valid_attrs)
  end

  @tag admin: true
  test "does not update chosen resource when logged in as different user", %{conn: conn, nonadmin_user: nonadmin_user, admin_user: admin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = put conn, user_path(conn, :update, admin_user), user: @valid_create_attrs
    assert get_flash(conn, :error) == "You are not authorized to modify that user!"
    assert redirected_to(conn) == page_path(conn, :index)
    assert conn.halted
  end

  @tag admin: true
  test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, nonadmin_user: nonadmin_user} do
    conn = login_user(conn, nonadmin_user)
    conn = put conn, user_path(conn, :update, nonadmin_user), user: @invalid_attrs
    assert html_response(conn, 200) =~ "Edit user"
  end

  @tag admin: true
  test "deletes chosen resource when logged in as that user", %{conn: conn, user_role: user_role} do
    {:ok, user} = TestHelper.create_user(user_role, @valid_create_attrs)
    conn =
      login_user(conn, user)
      |> delete(user_path(conn, :delete, user))
    assert redirected_to(conn) == user_path(conn, :index)
    refute Repo.get(User, user.id)
  end

  @tag admin: true
  test "deletes chosen resource when logged in as an admin", %{conn: conn, user_role: user_role, admin_user: admin_user} do
    {:ok, user} = TestHelper.create_user(user_role, @valid_create_attrs)
    conn =
      login_user(conn, admin_user)
      |> delete(user_path(conn, :delete, user))
    assert redirected_to(conn) == user_path(conn, :index)
    refute Repo.get(User, user.id)
  end

  @tag admin: true
  test "redirects away from deleting chosen resource when logged in as a different user", %{conn: conn, user_role: user_role, nonadmin_user: nonadmin_user} do
    {:ok, user} = TestHelper.create_user(user_role, @valid_create_attrs)
    conn =
      login_user(conn, nonadmin_user)
      |> delete(user_path(conn, :delete, user))
    assert get_flash(conn, :error) == "You are not authorized to modify that user!"
    assert redirected_to(conn) == page_path(conn, :index)
    assert conn.halted
  end
end

Запускаем весь набор тестов. Все они снова проходят!

Разрешаем администратору изменять любые посты


К счастью, мы уже сделали большую часть работы и остался только этот последний кусочек. После того, как мы закончим с ним, функциональность администратора будет полностью готова. Давайте откроем файл web/controllers/post_controller.ex и изменим функцию authorize_user, чтобы она тоже использовала вспомогательную функцию RoleChecker.is_admin?. Если пользователь является администратором, то дадим ему полный контроль над изменением постов пользователей.

defp authorize_user(conn, _) do
  user = get_session(conn, :current_user)
  if user && (Integer.to_string(user.id) == conn.params["user_id"] || Pxblog.RoleChecker.is_admin?(user)) do
    conn
  else
    conn
    |> put_flash(:error, "You are not authorized to modify that post!")
    |> redirect(to: page_path(conn, :index))
    |> halt()
  end
end

В завершение откроем файл test/controllers/post_controller_test.exs и добавим ещё несколько тестов для покрытия правил авторизации:

test "redirects when trying to delete a post for a different user", %{conn: conn, role: role, post: post} do
  {:ok, other_user} = TestHelper.create_user(role, %{email: "test2@test.com", username: "test2", password: "test", password_confirmation: "test"})
  conn = delete conn, user_post_path(conn, :delete, other_user, post)
  assert get_flash(conn, :error) == "You are not authorized to modify that post!"
  assert redirected_to(conn) == page_path(conn, :index)
  assert conn.halted
end

test "renders form for editing chosen resource when logged in as admin", %{conn: conn, user: user, post: post} do
  {:ok, role}  = TestHelper.create_role(%{name: "Admin", admin: true})
  {:ok, admin} = TestHelper.create_user(role, %{username: "admin", email: "admin@test.com", password: "test", password_confirmation: "test"})
  conn =
    login_user(conn, admin)
    |> get(user_post_path(conn, :edit, user, post))
  assert html_response(conn, 200) =~ "Edit post"
end

test "updates chosen resource and redirects when data is valid when logged in as admin", %{conn: conn, user: user, post: post} do
  {:ok, role}  = TestHelper.create_role(%{name: "Admin", admin: true})
  {:ok, admin} = TestHelper.create_user(role, %{username: "admin", email: "admin@test.com", password: "test", password_confirmation: "test"})
  conn =
    login_user(conn, admin)
    |> put(user_post_path(conn, :update, user, post), post: @valid_attrs)
  assert redirected_to(conn) == user_post_path(conn, :show, user, post)
  assert Repo.get_by(Post, @valid_attrs)
end

test "does not update chosen resource and renders errors when data is invalid when logged in as admin", %{conn: conn, user: user, post: post} do
  {:ok, role}  = TestHelper.create_role(%{name: "Admin", admin: true})
  {:ok, admin} = TestHelper.create_user(role, %{username: "admin", email: "admin@test.com", password: "test", password_confirmation: "test"})
  conn =
    login_user(conn, admin)
    |> put(user_post_path(conn, :update, user, post), post: %{"body" => nil})
  assert html_response(conn, 200) =~ "Edit post"
end

test "deletes chosen resource when logged in as admin", %{conn: conn, user: user, post: post} do
  {:ok, role}  = TestHelper.create_role(%{name: "Admin", admin: true})
  {:ok, admin} = TestHelper.create_user(role, %{username: "admin", email: "admin@test.com", password: "test", password_confirmation: "test"})
  conn =
    login_user(conn, admin)
    |> delete(user_post_path(conn, :delete, user, post))
  assert redirected_to(conn) == user_post_path(conn, :index, user)
  refute Repo.get(Post, post.id)
end

Прямо сейчас наш блоговый движок в целом работает без сбоев, но есть несколько багов. Может быть они появились по оплошности. либо мы что-то забыли по пути. Поэтому давайте выявим и устраним их. Также давайте обновим все зависимости, чтобы приложение было запущено на самой последней версии всего чего возможно.

Добавление нового пользователя выдаёт ошибку об отсутствующих ролях


На это обратил внимание nolotus на странице Pxblog (https://github.com/Diamond/pxblog). Спасибо тебе!

В ветке part_3 попытки создать нового пользователя будут приводить к ошибке из-за отсутствия роли (так как мы сделали обязательным наличие role_id при создании пользователя). Давайте для начала изучим проблему, а только потом начнём её исправлять. Когда мы зайдя в качестве администратора, переходим по адресу /users/new, заполняем все поля и нажимаем на кнопку, то получаем следующую ошибку:


Которая происходит потому, что мы требуем от пользователя введения имени, электронной почты, пароля, подтверждения пароля. Но ничего не говорим по поводу роли. Теперь, зная об этом, приступим к решению. Начнём с передачи списка возможных ролей для выбора в форме.

Он понадобится нам в каждом из действий: new, create, edit и update. Добавьте alias Pxblog.Role наверх UserController (файл web/controllers/user_controller.ex) если этого пока ещё там нет. Затем внесём изменения во все ранее перечисленные действия:

def new(conn, _params) do
  roles = Repo.all(Role)
  changeset = User.changeset(%User{})
  render(conn, "new.html", changeset: changeset, roles: roles)
end
def edit(conn, %{"id" => id}) do
  roles = Repo.all(Role)
  user = Repo.get!(User, id)
  changeset = User.changeset(user)
  render(conn, "edit.html", user: user, changeset: changeset, roles: roles)
end
def create(conn, %{"user" => user_params}) do
  roles = Repo.all(Role)
  changeset = User.changeset(%User{}, user_params)

  case Repo.insert(changeset) do
    {:ok, _user} ->
      conn
      |> put_flash(:info, "User created successfully.")
      |> redirect(to: user_path(conn, :index))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset, roles: roles)
  end
end
def update(conn, %{"id" => id, "user" => user_params}) do
  roles = Repo.all(Role)
  user = Repo.get!(User, id)
  changeset = User.changeset(user, user_params)

  case Repo.update(changeset) do
    {:ok, user} ->
      conn
      |> put_flash(:info, "User updated successfully.")
      |> redirect(to: user_path(conn, :show, user))
    {:error, changeset} ->
      render(conn, "edit.html", user: user, changeset: changeset, roles: roles)
  end
end

Обратите внимание, что для каждого из них мы выбрали все роли с помощью Repo.all(Role) и добавили их в список assigns, который передаём представлению (в том числе и в случае ошибки).

Нам также нужно реализовать выпадающий список, используя вспомогательные функции для форм из Phoenix.Html. Так что давайте посмотрим как это делается в документации:

select(form, field, values, opts \\ [])
  Generates a select tag with the given values.

Выпадающие списки ожидают в качестве аргумента values либо обычный список (в формате [value, value, value]), либо список ключевых слов (в формате [displayed: value, displayed: value]). В нашем случае нам нужно отображать названия ролей и вместе с этим передавать значение идентификатора выбранной роли при отправке формы. Мы не можем просто слепо кидать переменную @roles во вспомогательную функцию, потому что она не подходит ни под один из перечисленных форматов. Так что давайте писать функцию во View, которая упростит нашу задачу.

defmodule Pxblog.UserView do
  use Pxblog.Web, :view

  def roles_for_select(roles) do
    roles
    |> Enum.map(&["#{&1.name}": &1.id])
    |> List.flatten
  end
end

Мы добавили функцию roles_for_select, просто принимающую коллекцию ролей. Давайте построчно рассмотрим что же делает данная функция. Начнём с коллекции ролей, которую передаём следующей функции по цепочке:

Enum.map(&["#{&1.name}": &1.id])

Снова напомню, что &/&1 — сокращение для анонимных функций, которое можно переписать в полном варианте так:

Enum.map(roles, fn role -> ["#{role.name}": role.id] end)

Мы запустили операцию map, чтобы вернуть список из более маленьких ключевых списков, где название роли — ключ, а идентификатор роли — значение.

Предположим, дано некое начальное значение для ролей:

roles = [%Role{name: "Admin Role", id: 1}, %Role{name: "User Role", id: 2}]

В этом случае, вызов функции map вернул бы такой список:

[["Admin Role": 1], ["User Role": 2]]

Который затем мы передадим в последнюю функцию List.flatten, убирающую лишнюю вложенность. Таким образом наш окончательный результат:

["Admin Role": 1, "User Role": 2]

Так случилось, что это и есть требуемый формат для вспомогательной функции выпадающего списка! Пока мы не можем похлопать себя по плечу, ведь нам всё ещё нужно изменить шаблоны в файле web/templates/user/new.html.eex:

<h2>New user</h2>

<%= render "form.html", changeset: @changeset,
                        action: user_path(@conn, :create),
                        roles: @roles %>

<%= link "Back", to: user_path(@conn, :index) %>

И в файле web/templates/user/edit.html.eex:

 <h2>Edit user</h2>
<%= render "form.html", changeset: @changeset,
                        action: user_path(@conn, :update, @user),
                        roles: @roles %>
<%= link "Back", to: user_path(@conn, :index) %>
 

Ну и наконец, я думаю вы не откажетесь добавить нашу новую вспомогательную функцию в файл web/templates/user/form.html.eex. Как итог, в форме появится выпадающий список, включающий все возможные для перевода пользователя роли. Добавьте следующий код до кнопки Submit:

<div class="form-group">
  <%= label f, :role_id, "Role", class: "control-label" %>
  <%= select f, :role_id, roles_for_select(@roles), class: "form-control" %>
  <%= error_tag f, :role_id %>
</div>

Теперь, если вы попробуете добавить нового пользователя или отредактировать существующего, то получите возможность присвоить роль этому человеку! Остался последний баг!

Загрузка начальных данных несколько раз подряд дублирует их


Прямо сейчас, если мы загрузим наши начальные данные несколько раз, то получим дубли, что является ошибкой. Давайте напишем пару вспомогательных анонимных функций find_or_create:

alias Pxblog.Repo
alias Pxblog.Role
alias Pxblog.User
import Ecto.Query, only: [from: 2]

find_or_create_role = fn role_name, admin ->
  case Repo.all(from r in Role, where: r.name == ^role_name and r.admin == ^admin) do
    [] ->
      %Role{}
      |> Role.changeset(%{name: role_name, admin: admin})
      |> Repo.insert!()
    _ ->
      IO.puts "Role: #{role_name} already exists, skipping"
  end
end

find_or_create_user = fn username, email, role ->
  case Repo.all(from u in User, where: u.username == ^username and u.email == ^email) do
    [] ->
      %User{}
      |> User.changeset(%{username: username, email: email, password: "test", password_confirmation: "test", role_id: role.id})
      |> Repo.insert!()
    _ ->
      IO.puts "User: #{username} already exists, skipping"
  end
end

_user_role  = find_or_create_role.("User Role", false)
admin_role  = find_or_create_role.("Admin Role", true)
_admin_user = find_or_create_user.("admin", "admin@test.com", admin_role)

Обратите внимание на добавление псевдонима Repo, Role и User. Мы также импортируем функцию from из модуля Ecto.Query, чтобы использовать удобный синтаксис запросов. Затем взгляните на анонимную функцию find_or_create_role. Функция сама по себе просто принимает название роли и флаг администратора в качестве аргументов.

Основываясь на этих критериях мы выполняем запрос с помощью Repo.all (обратите внимание на знак ^, следующий за каждой переменной внутри условия where, т.к. мы хотим сравнить значения, вместо сопоставления с образцом). И кидаем результат в оператор case. Если Repo.all ничего не нашёл, мы получим обратно пустой список, следовательно, нам нужно добавим роль. В противном случае мы предполагаем, что роль уже существует и переходим к загрузке остального файла. Функция find_or_create_user делает то же самое, но использует другие критерии.

Наконец, мы вызываем каждую из этих функций (обратите внимание на обязательную для анонимных функций точку между их названием и аргументами!). Для создания администратора, нам нужно повторно использовать его роль. Именно поэтому мы не предваряем название admin_role знаком подчёркивания. Позже мы возможно захотим пустить в ход user_role или admin_user для дальнейшего использования в файле начальных данных, но пока оставим этот код в покое, обратившись к знаку подчёркивания. Это позволит файлу начальных данных выглядеть опрятным и чистым. Теперь всё готово к загрузке начальных данных:

$ mix run priv/repo/seeds.exs
[debug] SELECT r0.”id”, r0.”name”, r0.”admin”, r0.”inserted_at”, r0.”updated_at” FROM “roles” AS r0 WHERE ((r0.”name” = $1) AND (r0.”admin” = $2)) [“User Role”, false] OK query=81.7ms queue=2.8ms
[debug] BEGIN [] OK query=0.2ms
[debug] INSERT INTO “roles” (“admin”, “inserted_at”, “name”, “updated_at”) VALUES ($1, $2, $3, $4) RETURNING “id” [false, {{2015, 11, 6}, {19, 35, 49, 0}}, “User Role”, {{2015, 11, 6}, {19, 35, 49, 0}}] OK query=0.8ms
[debug] COMMIT [] OK query=0.4ms
[debug] SELECT r0.”id”, r0.”name”, r0.”admin”, r0.”inserted_at”, r0.”updated_at” FROM “roles” AS r0 WHERE ((r0.”name” = $1) AND (r0.”admin” = $2)) [“Admin Role”, true] OK query=0.4ms
[debug] BEGIN [] OK query=0.2ms
[debug] INSERT INTO “roles” (“admin”, “inserted_at”, “name”, “updated_at”) VALUES ($1, $2, $3, $4) RETURNING “id” [true, {{2015, 11, 6}, {19, 35, 49, 0}}, “Admin Role”, {{2015, 11, 6}, {19, 35, 49, 0}}] OK query=0.4ms
[debug] COMMIT [] OK query=0.3ms
[debug] SELECT u0.”id”, u0.”username”, u0.”email”, u0.”password_digest”, u0.”role_id”, u0.”inserted_at”, u0.”updated_at” FROM “users” AS u0 WHERE ((u0.”username” = $1) AND (u0.”email” = $2)) [“admin”, “admin@test.com”] OK query=0.7ms
[debug] BEGIN [] OK query=0.3ms
[debug] INSERT INTO “users” (“email”, “inserted_at”, “password_digest”, “role_id”, “updated_at”, “username”) VALUES ($1, $2, $3, $4, $5, $6) RETURNING “id” [“admin@test.com”, {{2015, 11, 6}, {19, 35, 49, 0}}, “$2b$12$.MuPBUVe/7/9HSOsccJYUOAD5IKEB77Pgz2oTJ/UvTvWYwAGn/L.i”, 2, {{2015, 11, 6}, {19, 35, 49, 0}}, “admin”] OK query=1.2ms
[debug] COMMIT [] OK query=1.1ms

Когда мы загружаем их впервые, то видим пачку конструкций INSERT. Потрясающе! Чтобы быть полностью уверенными, что всё работает как надо, давайте попытаемся загрузить их ещё раз и убедимся, что не происходит никаких операций вставок:

$ mix run priv/repo/seeds.exs
Role: User Role already exists, skipping
[debug] SELECT r0.”id”, r0.”name”, r0.”admin”, r0.”inserted_at”, r0.”updated_at” FROM “roles” AS r0 WHERE ((r0.”name” = $1) AND (r0.”admin” = $2)) [“User Role”, false] OK query=104.8ms queue=3.6ms
Role: Admin Role already exists, skipping
[debug] SELECT r0.”id”, r0.”name”, r0.”admin”, r0.”inserted_at”, r0.”updated_at” FROM “roles” AS r0 WHERE ((r0.”name” = $1) AND (r0.”admin” = $2)) [“Admin Role”, true] OK query=0.6ms
User: admin already exists, skipping
[debug] SELECT u0.”id”, u0.”username”, u0.”email”, u0.”password_digest”, u0.”role_id”, u0.”inserted_at”, u0.”updated_at” FROM “users” AS u0 WHERE ((u0.”username” = $1) AND (u0.”email” = $2)) [“admin”, “admin@test.com”] OK query=0.8ms

Великолепно! Всё работает и работает довольно надёжно! Плюс никто не отменит того удовольствия, что мы получили от написания наших собственных полезных функций для Ecto!

Ошибки про дублирование администраторов в тестах


Теперь, если в какой-то момент, вы сбросите тестовую базу данных, то получите ошибку, гласящую «Пользователь уже существует». Предлагаю простой (и временный) способ это исправить. Откройте файл test/support/test_helper.ex и измените функцию create_user:

def create_user(role, %{email: email, username: username, password: password, password_confirmation: password_confirmation}) do
  if user = Repo.get_by(User, username: username) do
    Repo.delete(user)
  end
  role
  |> build_assoc(:users)
  |> User.changeset(%{email: email, username: username, password: password, password_confirmation: password_confirmation})
  |> Repo.insert
end


К чему мы пришли?


Сейчас у нас есть полностью зелёные тесты, а также пользователи, посты и роли. Мы реализовали работоспособные ограничения пользовательских регистраций, изменения пользователей и постов. И добавили несколько полезных вспомогательных функций. В дальнейших постах мы посвятим некоторое время добавлению новых классных возможностей к нашему блоговому движку!

Заключение от Вуншей


С каждой неделей нас становится всё больше, а это не может не радовать! Друзья, большое спасибо за интерес к сообществу и выраженное доверие. Стараемся его оправдывать и несколько раз в неделю выкладывать новый интересный материал. Поэтому, если вы всё ещё не подписались на русскоязычную рассылку об Elixir, то не теряйте времени. Подписывайтесь сейчас и уже завтра получите новую эксклюзивную статью! Работаем буквально ночи напролёт, специально для вас.

Ещё напоминаю, что мы проводим конкурс с новенькой хрустящей книгой Programming Elixir от Дейва Томаса в качестве приза. Принимайте участие, выиграть не так сложно!

Также не забывайте ставить плюсы и пересылать статью друзьям, если она вам понравилась. Либо если вам нравится наша деятельность. Ведь чем быстрее мы с вами соберём критическую массу пользователей, тем быстрее сможем запустить полноценную версию сайта, покрывающую большинство вопросов о прелестном языке Эликсире.

Другие части:

  1. Вступление
  2. Авторизация
  3. Добавление ролей
  4. Добавляем обработку ролей в контроллерах
  5. Скоро...

Успехов в изучении, оставайтесь с нами!
Поделиться с друзьями
-->

Комментарии (10)


  1. Fedcomp
    29.11.2016 08:42

    > Где мы остановились
    На добавлении не относящихся к статье хабов Ruby on Rails, Ruby. Давайте еще php сюда добавим, вдруг им тоже будет интересно.


    1. zviryatko
      29.11.2016 09:38
      +1

      Не ну а что, вот я пхпешник и зашел сюда потому что было интересно :)


      1. jarosluv
        29.11.2016 10:47

        Значит в следующий раз точно нужно добавлять PHP в список хабов! :)


    1. jarosluv
      29.11.2016 10:46

      Уважаемый, Александр! И я тоже рад вас видеть!

      С блогом всё получается? Может быть подсказать что-то?


    1. Desprit
      29.11.2016 14:58
      +1

      О, добрый вечер :)
      Перепись проходит на ура!


      1. jarosluv
        29.11.2016 16:10

        Согласен!

        Спасибо, что и вы, Алексей, остаётесь с нами! :)


  1. hats
    29.11.2016 23:47

    jarosluv спасибо огромное за ваш труд!


    1. hats
      29.11.2016 23:51

      Чем больше я узнаю об Erlang, тем больше во мне крепнет уверенность, что за Erlang и его VM будущее.
      Осталось только нарастить комьюнити как у других популярных технологий :)


      1. jarosluv
        30.11.2016 02:05

        Только всё же комьюнити нужно наращивать Эликсиру, а не Эрлангу, у которого ядро пользователей уже давно сформировано. :)


    1. jarosluv
      30.11.2016 02:03

      Спасибо за поддержку!