От переводчика: «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 от Дейва Томаса в качестве приза. Принимайте участие, выиграть не так сложно!
Также не забывайте ставить плюсы и пересылать статью друзьям, если она вам понравилась. Либо если вам нравится наша деятельность. Ведь чем быстрее мы с вами соберём критическую массу пользователей, тем быстрее сможем запустить полноценную версию сайта, покрывающую большинство вопросов о прелестном языке Эликсире.
Другие части:
- Вступление
- Авторизация
- Добавление ролей
- Добавляем обработку ролей в контроллерах
- Скоро...
Успехов в изучении, оставайтесь с нами!
Поделиться с друзьями
Комментарии (10)
hats
29.11.2016 23:47jarosluv спасибо огромное за ваш труд!
hats
29.11.2016 23:51Чем больше я узнаю об Erlang, тем больше во мне крепнет уверенность, что за Erlang и его VM будущее.
Осталось только нарастить комьюнити как у других популярных технологий :)jarosluv
30.11.2016 02:05Только всё же комьюнити нужно наращивать Эликсиру, а не Эрлангу, у которого ядро пользователей уже давно сформировано. :)
Fedcomp
> Где мы остановились
На добавлении не относящихся к статье хабов Ruby on Rails, Ruby. Давайте еще php сюда добавим, вдруг им тоже будет интересно.
zviryatko
Не ну а что, вот я пхпешник и зашел сюда потому что было интересно :)
jarosluv
Значит в следующий раз точно нужно добавлять PHP в список хабов! :)
jarosluv
Уважаемый, Александр! И я тоже рад вас видеть!
С блогом всё получается? Может быть подсказать что-то?
Desprit
О, добрый вечер :)
Перепись проходит на ура!
jarosluv
Согласен!
Спасибо, что и вы, Алексей, остаётесь с нами! :)