Merge branch 'master' of gitlab.com:mythic-insight/legendary
This commit is contained in:
commit
3458632a6a
46 changed files with 1151 additions and 169 deletions
|
@ -1,6 +1,4 @@
|
|||
stages:
|
||||
- application_dependencies
|
||||
- asset_dependencies
|
||||
- test
|
||||
- deploy_tags
|
||||
- deploy
|
||||
|
@ -15,99 +13,41 @@ variables:
|
|||
# fetch & clean the repo rather than completely cloning (faster)
|
||||
GIT_STRATEGY: fetch
|
||||
|
||||
# Dependency stages-- fetch these first so that we can cache them and reuse them
|
||||
# across jobs and pipelines. Note that elixir deps need to go first, because
|
||||
# we need the phoenix and phoenix_html hex packages to install their JS.
|
||||
fetch_application_dependencies:
|
||||
stage: application_dependencies
|
||||
image: "elixir:1.10"
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- mix.lock
|
||||
paths:
|
||||
- _build/
|
||||
- deps/
|
||||
script:
|
||||
- mix local.hex --force
|
||||
- mix local.rebar --force
|
||||
- mix deps.get
|
||||
- mix deps.compile
|
||||
# Make results available to other jobs
|
||||
artifacts:
|
||||
paths:
|
||||
- deps/phoenix
|
||||
- deps/phoenix_html
|
||||
exclude:
|
||||
- deps/
|
||||
|
||||
fetch_asset_dependencies:
|
||||
stage: asset_dependencies
|
||||
image: "node:15.0"
|
||||
needs:
|
||||
- fetch_application_dependencies
|
||||
only:
|
||||
- master
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- apps/app/assets/package-lock.json
|
||||
paths:
|
||||
- apps/app/assets/node_modules/
|
||||
script:
|
||||
- cd apps/app/assets/ && npm install
|
||||
# Make results available to other jobs
|
||||
artifacts:
|
||||
paths:
|
||||
- apps/app/assets/node_modules
|
||||
exclude:
|
||||
- apps/app/assets/node_modules
|
||||
|
||||
# Test stage. Runs various tests and speculatively builds docker image in
|
||||
# parallel, in case the build passes.
|
||||
test:
|
||||
stage: test
|
||||
needs:
|
||||
- fetch_application_dependencies
|
||||
image: "elixir:1.10"
|
||||
services:
|
||||
- name: postgres:12
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- mix.lock
|
||||
paths:
|
||||
- _build/
|
||||
- deps/
|
||||
services:
|
||||
- name: postgres:12
|
||||
script: script/cibuild
|
||||
|
||||
build_image_for_commit:
|
||||
stage: test
|
||||
needs:
|
||||
- fetch_asset_dependencies
|
||||
- fetch_application_dependencies
|
||||
image: "docker:20.10"
|
||||
only:
|
||||
- master
|
||||
services:
|
||||
- name: docker:20.10-dind
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- mix.lock
|
||||
paths:
|
||||
- _build/
|
||||
- deps/
|
||||
services:
|
||||
- name: docker:20.10-dind
|
||||
script:
|
||||
- docker login "https://${CI_REGISTRY}" -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
# This enables fast parallel builds
|
||||
- export DOCKER_BUILDKIT=1
|
||||
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --build-arg BUILDKIT_INLINE_CACHE=1 .
|
||||
- mkdir -p _build/prod
|
||||
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
|
||||
# Push the commit SHA tagged version to registry. We will later choose to tag that as stable
|
||||
# if everything passes.
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
# This copies the elixir build artifacts for deps and app so that we can cache them
|
||||
# Pull out the built _build/prod directory so we can cache it!
|
||||
- docker cp `docker create $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA`:/root/app/_build/prod _build
|
||||
|
||||
# If tests pass, tag the commit and update package versions
|
||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -26,32 +26,36 @@ ADD ./apps/admin/mix.exs /root/app/apps/admin/
|
|||
ADD ./apps/app/mix.exs /root/app/apps/app/
|
||||
ADD ./apps/content/mix.exs /root/app/apps/content/
|
||||
ADD ./apps/core/mix.exs /root/app/apps/core/
|
||||
ADD ./deps /root/app/deps
|
||||
ADD ./_build /root/app/_build
|
||||
ADD ./_build/${MIX_ENV}/ /root/app/_build/${MIX_ENV}/
|
||||
ADD ./deps/ /root/app/deps/
|
||||
ADD ./script/ /root/app/script/
|
||||
RUN script/restore-timestamps
|
||||
RUN mix deps.get
|
||||
RUN mix deps.compile
|
||||
|
||||
ADD ./apps /root/app/apps
|
||||
|
||||
# Leave off here so that we can built assets and compile the elixir app in parallel
|
||||
|
||||
FROM node:15.0
|
||||
|
||||
# Build assets in a node container
|
||||
ADD ./apps/app/assets/ /root/app/apps/app/assets/
|
||||
|
||||
WORKDIR /root/app/apps/app/assets/
|
||||
ADD ./apps/app/assets/node_modules /root/app/apps/app/assets/node_modules
|
||||
COPY --from=0 /root/app/ /root/app/
|
||||
RUN npm install
|
||||
RUN npm run deploy
|
||||
|
||||
FROM elixir1
|
||||
|
||||
ADD ./apps /root/app/apps
|
||||
|
||||
# Resume compilation of the elixir app
|
||||
ADD ./script /root/app/script
|
||||
RUN MAKE=cmake mix compile
|
||||
|
||||
# Copy in the built assets & fingerprint them
|
||||
COPY --from=1 /root/app/apps/app/priv/static/ /root/app/apps/app/priv/static
|
||||
RUN mix phx.digest
|
||||
|
||||
RUN script/restore-timestamps
|
||||
|
||||
CMD ["mix", "phx.server"]
|
||||
|
|
|
@ -46,7 +46,7 @@ defmodule Legendary.Admin.MixProject do
|
|||
{:ecto_sql, "~> 3.4"},
|
||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||
{:kaffy, path: "kaffy"},
|
||||
{:phoenix, "~> 1.5.3"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
</li>
|
||||
<%= if has_role?(@conn, :admin) do %>
|
||||
<li class="mr-3">
|
||||
<a class="inline-block py-2 px-4 text-white no-underline" href="/admin">Legendary.Admin</a>
|
||||
<a class="inline-block py-2 px-4 text-white no-underline" href="/admin">Admin</a>
|
||||
</li>
|
||||
<% end %>
|
||||
<%= if Pow.Plug.current_user(@conn) do %>
|
||||
|
|
|
@ -1,39 +1,15 @@
|
|||
defmodule AppWeb.LayoutView do
|
||||
use AppWeb, :view
|
||||
|
||||
def title(view_module, template, assigns) do
|
||||
delegate_with_default(view_module, :title, [view_module, template, assigns], Legendary.I18n.t!("en", "site.title"))
|
||||
end
|
||||
def title(_, _, _) do
|
||||
Legendary.I18n.t!("en", "site.title")
|
||||
end
|
||||
|
||||
def excerpt(view_module, template, assigns) do
|
||||
delegate_with_default(view_module, :excerpt, [view_module, template, assigns], Legendary.I18n.t!("en", "site.excerpt"))
|
||||
end
|
||||
def excerpt(_, _, _) do
|
||||
Legendary.I18n.t!("en", "site.excerpt")
|
||||
end
|
||||
|
||||
def feed_tag(conn, view_module, view_template, assigns) do
|
||||
delegate_with_default(view_module, :feed_tag, [conn, view_module, view_template, assigns], nil)
|
||||
end
|
||||
|
||||
defp delegate_with_default(nil, _, _, default), do: default
|
||||
defp delegate_with_default(view_module, function_name, args, default) do
|
||||
sibling_layout = sibling_layout_view(view_module)
|
||||
if function_exported?(sibling_layout, function_name, args |> Enum.count()) do
|
||||
apply(sibling_layout, function_name, args)
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
|
||||
defp sibling_layout_view(view_module) do
|
||||
view_module
|
||||
|> parent_module()
|
||||
|> Module.concat("LayoutView")
|
||||
end
|
||||
|
||||
defp parent_module(mod) do
|
||||
[_|tail] = Module.split(mod) |> Enum.reverse()
|
||||
|
||||
tail
|
||||
|> Enum.reverse()
|
||||
|> Module.concat()
|
||||
end
|
||||
def feed_tag(_, _, _, _) do
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,7 +44,8 @@ defmodule App.MixProject do
|
|||
{:core, in_umbrella: true},
|
||||
{:ecto_sql, "~> 3.4"},
|
||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||
{:phoenix, "~> 1.5.3"},
|
||||
{:oban, "~> 2.1"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
|
|
|
@ -2,10 +2,19 @@
|
|||
describe "<%= schema.plural %>" do
|
||||
alias <%= inspect schema.module %>
|
||||
|
||||
import <%= inspect context.module %>Fixtures
|
||||
|
||||
@valid_attrs <%= inspect schema.params.create %>
|
||||
@update_attrs <%= inspect schema.params.update %>
|
||||
@invalid_attrs <%= inspect for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %>
|
||||
|
||||
def <%= schema.singular %>_fixture(attrs \\ %{}) do
|
||||
{:ok, <%= schema.singular %>} =
|
||||
attrs
|
||||
|> Enum.into(@valid_attrs)
|
||||
|> <%= inspect context.alias %>.create_<%= schema.singular %>()
|
||||
|
||||
<%= schema.singular %>
|
||||
end
|
||||
|
||||
test "list_<%= schema.plural %>/0 returns all <%= schema.plural %>" do
|
||||
<%= schema.singular %> = <%= schema.singular %>_fixture()
|
||||
assert <%= inspect context.alias %>.list_<%= schema.plural %>() == [<%= schema.singular %>]
|
||||
|
@ -17,9 +26,7 @@
|
|||
end
|
||||
|
||||
test "create_<%= schema.singular %>/1 with valid data creates a <%= schema.singular %>" do
|
||||
valid_attrs = <%= inspect schema.params.create %>
|
||||
|
||||
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(valid_attrs)<%= for {field, value} <- schema.params.create do %>
|
||||
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(@valid_attrs)<%= for {field, value} <- schema.params.create do %>
|
||||
assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %>
|
||||
end
|
||||
|
||||
|
@ -29,9 +36,7 @@
|
|||
|
||||
test "update_<%= schema.singular %>/2 with valid data updates the <%= schema.singular %>" do
|
||||
<%= schema.singular %> = <%= schema.singular %>_fixture()
|
||||
update_attrs = <%= inspect schema.params.update %>
|
||||
|
||||
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, update_attrs)<%= for {field, value} <- schema.params.update do %>
|
||||
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, @update_attrs)<%= for {field, value} <- schema.params.update do %>
|
||||
assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %>
|
||||
end
|
||||
|
||||
|
|
|
@ -1,40 +1,45 @@
|
|||
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do
|
||||
use <%= inspect context.web_module %>.ConnCase
|
||||
|
||||
import <%= inspect context.module %>Fixtures
|
||||
alias <%= inspect context.module %>
|
||||
|
||||
@create_attrs <%= inspect schema.params.create %>
|
||||
@update_attrs <%= inspect schema.params.update %>
|
||||
@invalid_attrs <%= inspect for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %>
|
||||
|
||||
def fixture(:<%= schema.singular %>) do
|
||||
{:ok, <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(@create_attrs)
|
||||
<%= schema.singular %>
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
test "lists all <%= schema.plural %>", %{conn: conn} do
|
||||
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :index))
|
||||
assert html_response(conn, 200) =~ "Listing <%= schema.human_plural %>"
|
||||
assert html_response(conn, 200) =~ "<%= schema.human_plural %>"
|
||||
end
|
||||
end
|
||||
|
||||
describe "new <%= schema.singular %>" do
|
||||
test "renders form", %{conn: conn} do
|
||||
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :new))
|
||||
assert html_response(conn, 200) =~ "New <%= schema.human_singular %>"
|
||||
assert html_response(conn, 200) =~ "Save"
|
||||
end
|
||||
end
|
||||
|
||||
describe "create <%= schema.singular %>" do
|
||||
test "redirects to show when data is valid", %{conn: conn} do
|
||||
conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @create_attrs)
|
||||
post_conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @create_attrs)
|
||||
|
||||
assert %{id: id} = redirected_params(conn)
|
||||
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :show, id)
|
||||
assert %{id: id} = redirected_params(post_conn)
|
||||
assert redirected_to(post_conn) == Routes.<%= schema.route_helper %>_path(post_conn, :show, id)
|
||||
|
||||
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, id))
|
||||
assert html_response(conn, 200) =~ "Show <%= schema.human_singular %>"
|
||||
get_conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, id))
|
||||
assert html_response(get_conn, 200) =~ "Edit"
|
||||
end
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn} do
|
||||
conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @invalid_attrs)
|
||||
assert html_response(conn, 200) =~ "New <%= schema.human_singular %>"
|
||||
assert html_response(conn, 200) =~ "Save"
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -51,12 +56,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
|
|||
setup [:create_<%= schema.singular %>]
|
||||
|
||||
test "redirects when data is valid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
|
||||
conn = put(conn, Routes.<%= schema.route_helper %>_path(conn, :update, <%= schema.singular %>), <%= schema.singular %>: @update_attrs)
|
||||
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>)
|
||||
put_conn = put(conn, Routes.<%= schema.route_helper %>_path(conn, :update, <%= schema.singular %>), <%= schema.singular %>: @update_attrs)
|
||||
assert redirected_to(put_conn) == Routes.<%= schema.route_helper %>_path(put_conn, :show, <%= schema.singular %>)
|
||||
|
||||
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))<%= if schema.string_attr do %>
|
||||
assert html_response(conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %>
|
||||
assert html_response(conn, 200)<% end %>
|
||||
get_conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))<%= if schema.string_attr do %>
|
||||
assert html_response(get_conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %>
|
||||
assert html_response(get_conn, 200)<% end %>
|
||||
end
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
|
||||
|
@ -69,8 +74,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
|
|||
setup [:create_<%= schema.singular %>]
|
||||
|
||||
test "deletes chosen <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
|
||||
conn = delete(conn, Routes.<%= schema.route_helper %>_path(conn, :delete, <%= schema.singular %>))
|
||||
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :index)
|
||||
delete_conn = delete(conn, Routes.<%= schema.route_helper %>_path(conn, :delete, <%= schema.singular %>))
|
||||
assert redirected_to(delete_conn) == Routes.<%= schema.route_helper %>_path(delete_conn, :index)
|
||||
assert_error_sent 404, fn ->
|
||||
get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))
|
||||
end
|
||||
|
@ -78,7 +83,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
|
|||
end
|
||||
|
||||
defp create_<%= schema.singular %>(_) do
|
||||
<%= schema.singular %> = <%= schema.singular %>_fixture()
|
||||
<%= schema.singular %> = fixture(:<%= schema.singular %>)
|
||||
%{<%= schema.singular %>: <%= schema.singular %>}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do
|
||||
use <%= inspect context.web_module %>.ConnCase
|
||||
|
||||
import <%= inspect context.module %>Fixtures
|
||||
|
||||
alias <%= inspect schema.module %>
|
||||
|
||||
@create_attrs %{
|
||||
|
|
|
@ -2,7 +2,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
|
|||
use <%= inspect context.web_module %>.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import <%= inspect context.module %>Fixtures
|
||||
|
||||
@create_attrs <%= inspect schema.params.create %>
|
||||
@update_attrs <%= inspect schema.params.update %>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
defmodule App.PageViewTest do
|
||||
use App.ConnCase, async: true
|
||||
end
|
0
apps/app/test/app_web/controllers/.keep
Normal file
0
apps/app/test/app_web/controllers/.keep
Normal file
|
@ -1,5 +1,5 @@
|
|||
defmodule AppWeb.ErrorViewTest do
|
||||
use App.ConnCase, async: true
|
||||
use AppWeb.ConnCase, async: true
|
||||
|
||||
# Bring render/3 and render_to_string/3 for testing custom views
|
||||
import Phoenix.View
|
|
@ -1,5 +1,5 @@
|
|||
defmodule App.LayoutViewTest do
|
||||
use App.ConnCase, async: true
|
||||
use AppWeb.ConnCase, async: true
|
||||
|
||||
import AppWeb.LayoutView
|
||||
|
3
apps/app/test/app_web/views/page_view_test.exs
Normal file
3
apps/app/test/app_web/views/page_view_test.exs
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule App.PageViewTest do
|
||||
use AppWeb.ConnCase, async: true
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
defmodule App.ConnCase do
|
||||
defmodule AppWeb.ConnCase do
|
||||
@moduledoc """
|
||||
This module defines the test case to be used by
|
||||
tests that require setting up a connection.
|
||||
|
@ -11,7 +11,7 @@ defmodule App.ConnCase do
|
|||
we enable the SQL sandbox, so changes done to the database
|
||||
are reverted at the end of every test. If you are using
|
||||
PostgreSQL, you can even run database tests asynchronously
|
||||
by setting `use App.ConnCase, async: true`, although
|
||||
by setting `use AppWeb.ConnCase, async: true`, although
|
||||
this option is not recommended for other databases.
|
||||
"""
|
||||
|
||||
|
@ -22,9 +22,9 @@ defmodule App.ConnCase do
|
|||
# Import conveniences for testing with connections
|
||||
import Plug.Conn
|
||||
import Phoenix.ConnTest
|
||||
import App.ConnCase
|
||||
import AppWeb.ConnCase
|
||||
|
||||
alias App.Router.Helpers, as: Routes
|
||||
alias AppWeb.Router.Helpers, as: Routes
|
||||
|
||||
# The default endpoint for testing
|
||||
@endpoint AppWeb.Endpoint
|
||||
|
|
55
apps/app/test/support/data_case.ex
Normal file
55
apps/app/test/support/data_case.ex
Normal file
|
@ -0,0 +1,55 @@
|
|||
defmodule App.DataCase do
|
||||
@moduledoc """
|
||||
This module defines the setup for tests requiring
|
||||
access to the application's data layer.
|
||||
|
||||
You may define functions here to be used as helpers in
|
||||
your tests.
|
||||
|
||||
Finally, if the test case interacts with the database,
|
||||
we enable the SQL sandbox, so changes done to the database
|
||||
are reverted at the end of every test. If you are using
|
||||
PostgreSQL, you can even run database tests asynchronously
|
||||
by setting `use App.DataCase, async: true`, although
|
||||
this option is not recommended for other databases.
|
||||
"""
|
||||
|
||||
use ExUnit.CaseTemplate
|
||||
|
||||
using do
|
||||
quote do
|
||||
alias App.Repo
|
||||
|
||||
import Ecto
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
import App.DataCase
|
||||
end
|
||||
end
|
||||
|
||||
setup tags do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(App.Repo)
|
||||
|
||||
unless tags[:async] do
|
||||
Ecto.Adapters.SQL.Sandbox.mode(App.Repo, {:shared, self()})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
A helper that transforms changeset errors into a map of messages.
|
||||
|
||||
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||
assert "password is too short" in errors_on(changeset).password
|
||||
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||
|
||||
"""
|
||||
def errors_on(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -51,7 +51,7 @@ defmodule Legendary.Content.MixProject do
|
|||
{:meck, "~> 0.8.13", only: :test},
|
||||
{:neotomex, "~> 0.1.7"},
|
||||
{:oban, "~> 2.1"},
|
||||
{:phoenix, "~> 1.5.3"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_html_sanitizer, "~> 1.0.0"},
|
||||
|
|
BIN
apps/core/guides/assets/admin.png
Normal file
BIN
apps/core/guides/assets/admin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 173 KiB |
BIN
apps/core/guides/assets/homepage.png
Normal file
BIN
apps/core/guides/assets/homepage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 133 KiB |
BIN
apps/core/guides/assets/item-new.png
Normal file
BIN
apps/core/guides/assets/item-new.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 147 KiB |
BIN
apps/core/guides/assets/item-show.png
Normal file
BIN
apps/core/guides/assets/item-show.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
BIN
apps/core/guides/assets/items-index-after.png
Normal file
BIN
apps/core/guides/assets/items-index-after.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 144 KiB |
BIN
apps/core/guides/assets/items-index.png
Normal file
BIN
apps/core/guides/assets/items-index.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
11
apps/core/guides/features/admin.md
Normal file
11
apps/core/guides/features/admin.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Admin
|
||||
|
||||
The admin interface is generated through [Kaffy](https://aesmail.github.io/kaffy/).
|
||||
You can find extensive documentation on the Kaffy site regarding how to use and
|
||||
customize your admin interface.
|
||||
|
||||
Legendary specific notes:
|
||||
|
||||
- The configuration for Kaffy in your Legendary app is located in config/admin.exs.
|
||||
- Many of the built-in schemas provide admin modules. You shouldn't generally
|
||||
need to change these, but you may want to do so if you are changing built-in schema modules.
|
71
apps/core/guides/features/auth.md
Normal file
71
apps/core/guides/features/auth.md
Normal file
|
@ -0,0 +1,71 @@
|
|||
# Authentication and Authorization
|
||||
|
||||
Legendary provides a set of authentication and authorization features out of the
|
||||
box.
|
||||
|
||||
# Authentication
|
||||
|
||||
Legendary comes with authentication powered by [Pow](https://powauth.com/) out
|
||||
of the box. The default configuration:
|
||||
|
||||
- supports sign in and registration with an email and password
|
||||
- allows password resets
|
||||
- requires users to confirm their email address before logging in
|
||||
- emails for email confirmation and password reset will be nicely styled using your app's
|
||||
email styles
|
||||
|
||||
> Tip: in development mode, emails your app sends will be visible at http://localhost:4000/sent_emails.
|
||||
|
||||
Your Pow configuration can be customized in config/config.exs.
|
||||
|
||||
By default, users can be administrated in the admin interface.
|
||||
|
||||
# Roles and Authorization
|
||||
|
||||
Users have an array of roles. By default, a user has no roles, but they can have
|
||||
as many as you need. Roles in Legendary are arbitrary strings that you tag a user
|
||||
with to give them certain privileges.
|
||||
|
||||
For example, here's a typical admin user created by the `mix legendary.create_admin` command:
|
||||
|
||||
```elixir
|
||||
%Legendary.Auth.User{
|
||||
email: "legendary@example.com",
|
||||
homepage_url: nil,
|
||||
id: 1,
|
||||
inserted_at: ~N[2021-02-25 22:14:40],
|
||||
# This user has one role-- admin!
|
||||
roles: ["admin"],
|
||||
updated_at: ~N[2021-02-25 22:14:40]
|
||||
}
|
||||
```
|
||||
|
||||
`admin` happens to be a role that the framework cares about-- via the `mix legendary.create_admin` command and the `:require_admin` pipeline that protects
|
||||
the admin interface. However, you can use any string you want as a role and check
|
||||
for it in your code. For example, your app might give some users a `paid_customer`
|
||||
role and use it to protect certain features. You don't have to declare that in advance with the framework.
|
||||
|
||||
In some cases, you may want "resourceful roles"-- a role that corresponds to a
|
||||
specific resource record in your app. We suggest the following convention for those
|
||||
role names: `:role_name/:resource_type/:id`. So that could be `owner/home/3` to
|
||||
indicate the user is the owner of the Home with the id of 3. An authorized guest
|
||||
to the same home might be `guest/home/3`.
|
||||
|
||||
You can check whether a user has a role by calling Legendary.Auth.Roles.has_role?/2:
|
||||
|
||||
```elixir
|
||||
Legendary.Auth.Roles.has_role?(user, "admin")
|
||||
```
|
||||
|
||||
And you can always access the `user.roles` field directly.
|
||||
|
||||
# Protected routes
|
||||
|
||||
## Signed-In Only Routes
|
||||
|
||||
You can require that a given route requires a user by piping through the `:require_auth` pipeline. See apps/app/lib/app_web/router.ex for examples.
|
||||
|
||||
## Admin Only Routes
|
||||
|
||||
You can lock down a route to the app to only admin users by using the `:require_admin` pipeline. For example, the /admin area of your app is protected
|
||||
that way. See apps/app/lib/app_web/router.ex for examples.
|
13
apps/core/guides/features/background-jobs.md
Normal file
13
apps/core/guides/features/background-jobs.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Background Jobs
|
||||
|
||||
Background jobs and periodic jobs in Legendary are powered by [Oban](https://github.com/sorentwo/oban). See the Oban documentation for extensive information
|
||||
on using Oban in your application, including:
|
||||
|
||||
- queue configuration
|
||||
- worker configuration
|
||||
- unique job constraints
|
||||
- periodic jobs
|
||||
|
||||
The framework itself uses Oban for recurring tasks such as generating sitemaps.
|
||||
|
||||
Your app's Oban configuration is available in config/config.exs.
|
70
apps/core/guides/features/content-management.md
Normal file
70
apps/core/guides/features/content-management.md
Normal file
|
@ -0,0 +1,70 @@
|
|||
# Content Management
|
||||
|
||||
Your app includes a basic content management system including a simple blog
|
||||
(including optional user comments), dynamic pages, and static pages.
|
||||
|
||||
Pages and blog posts can be managed from the admin interface. Posts and pages
|
||||
support content in [Markdown](https://www.markdownguide.org/).
|
||||
|
||||
# Blog Posts
|
||||
|
||||
Your app has a blog at /blog. Your can create and manage posts from "Content > Pages and Posts"
|
||||
in the /admin area. You can write your post body in Markdown.
|
||||
|
||||
By default, posts have a few fields:
|
||||
|
||||
- Type: for a blog post, this will be "Blog Post"
|
||||
- Slug: this is the url path of your post. For example, a post with slug "hello-world"
|
||||
would be available at /hello-world.
|
||||
- Title: the human-readable title of your post.
|
||||
- Content: this is the body of your post as Markdown. The admin provides a nice
|
||||
editor in case you don't know Markdown syntax yet or don't want to bother.
|
||||
- Status: this is Publish if you want your post to be visible to everyone, or draft
|
||||
if you aren't ready to share it with the world.
|
||||
- Author: this will normally be you, but we do allow admins to ghost-write for other
|
||||
users.
|
||||
- Excerpt: A short summary of your blog post that may show up in search engine results.
|
||||
- Sticky: sticky posts will always show up first on your blog. They are generally used
|
||||
for important announcements and community rules.
|
||||
- Comment status: "open" will allow comments on your post. "closed" hides comments and
|
||||
does not allow new comments to be entered.
|
||||
- Ping status: whether the post supports (pingbacks)[https://en.wikipedia.org/wiki/Pingback].
|
||||
**Coming soon:** we don't currently show pingbacks anywhere or notify anyone when a
|
||||
pingback is received, but we may in the future.
|
||||
- Menu order: **Coming soon:** the relative order this blog post will show up in
|
||||
dynamic menus. Menu management is currently in development. The lower this number,
|
||||
the higher the post will appear in the menu.
|
||||
|
||||
# Dynamic Pages
|
||||
|
||||
Dynamic pages are very similar to blog posts. The only differences are that their
|
||||
type is "Page" instead of "Blog Post" and they do not appear in your blog feed.
|
||||
|
||||
They are intended for simple pages that will be updated by your admins, but don't
|
||||
make sense as a blog post-- for example, terms of service or FAQ pages.
|
||||
|
||||
The fields of dynamic pages are the same as blog posts.
|
||||
|
||||
# Static Pages
|
||||
|
||||
Legendary also supports static pages. Static pages are not editable from the admin.
|
||||
However, they provide an easy way for developers on a Legendary app to create
|
||||
and serve a content page without defining custom controllers and routes. This is
|
||||
a good fit for pages that are more complex than what can be done with Markdown
|
||||
in dynamic pages.
|
||||
|
||||
Static pages are eex templates located in apps/content/lib/content_web/templates/posts/static_pages/.
|
||||
For example, the home page of your app is a static page called index.html.eex.
|
||||
The filename, less the .html.eex part, serves as the slug. In other words, a
|
||||
static page called pricing.html.eex would have the url path /pricing in your app.
|
||||
|
||||
> Note: if a static page and a dynamic page have the same slug, the dynamic page
|
||||
> will "win." This allows you to provide a default version of the page as a fallback
|
||||
> in code, while allowing admins to create an updated version of the same page.
|
||||
|
||||
# Comments
|
||||
|
||||
As mentioned above, blog posts can optionally have comments enabled. On these posts,
|
||||
there will be a feed of comments as well as a comment form at the bottom of the page.
|
||||
|
||||
Comments can be managed by admins in the admin interface under "Content > Comments."
|
55
apps/core/guides/features/devops-templates.md
Normal file
55
apps/core/guides/features/devops-templates.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# DevOps Templates
|
||||
|
||||
Legendary includes a full set of DevOps templates designed to make it easy to
|
||||
test, build, and deploy your app.
|
||||
|
||||
# Overview
|
||||
|
||||
The setup we provide is an opinionated setup based on years of experience building
|
||||
Phoenix applications and deploying them at scale. It's meant to be efficient and
|
||||
easy for small teams while scaling to big teams. It's meant to be lean enough
|
||||
for low-traffic apps while scaling quite well to apps receiving thousands of
|
||||
requests a second.
|
||||
|
||||
Here's the process overview:
|
||||
|
||||
1. You make commits using [conventional commit messages](https://www.conventionalcommits.org/).
|
||||
2. The CI runs tests and builds a Docker image unique to your latest commit.
|
||||
3. Should your tests pass, that Docker image is labeled with a [semantic version](https://semver.org/)
|
||||
driven by your commit messages. We also tag that commit with that version so that
|
||||
you can refer to it. You never need to manually tag Docker image or a commit, so
|
||||
long as you follow the commit message convention.
|
||||
4. You can deploy that Docker image to Kubernetes, or any other Docker-friendly hosting environment.
|
||||
- CI generates a Kubernetes manifest pointing at that new docker image. You can
|
||||
apply that manifest to your cluster manually, or use a tool like flux2 to
|
||||
automate that.
|
||||
- If you don't use Kubernetes, you can tell your Docker-ized host to pull the new image in the method provided by that host.
|
||||
|
||||
# CI Configuration
|
||||
|
||||
Legendary comes with GitLab CI settings which should work for you with minimal
|
||||
setup. This config is located in .gitlab-ci.yml.
|
||||
|
||||
The CI script will automatically tag successful builds. To do this, you will
|
||||
need to configure a [CI variable](https://docs.gitlab.com/ee/ci/variables/) named
|
||||
`GITLAB_TOKEN`. This token should be a
|
||||
[personal access token](https://gitlab.com/-/profile/personal_access_tokens) with
|
||||
`read_repository, write_repository` permissions.
|
||||
|
||||
This CI configuration provides a few nice features:
|
||||
|
||||
- Parallel build steps. The tests run while the Docker image builds, so you don't
|
||||
have to wait for one then the other.
|
||||
- Fast Docker build configuration. We use Docker BuildKit and a heavily tuned Dockerfile to reduce builld times from 15+ minutes to ~3 minutes.
|
||||
- Fast Elixir compile times. Out of the box, Elixir compilation can be quite
|
||||
slow in CI. We employ a few tricks to reduce the compilation time by over 75%
|
||||
over default CI configuration.
|
||||
- Automated semantic versioning. So long as you use conventional commit messages,
|
||||
we will automatically bump the version number appropriately.
|
||||
|
||||
# Kubernetes Manifests
|
||||
|
||||
We also automatically generate a Kubernetes manifest for your app on each successful build. The generate manifest is commited back to your repo at infrastructure/. You can use a tool like flux2 to automatically update the configuration in your Kubernetes cluster from there. Or you could manually apply
|
||||
it whenever you choose.
|
||||
|
||||
The template used to generate the manifest is located in infrastructure_templates. Feel free to customize it if your application needs different Kubernetes config.
|
80
apps/core/guides/features/email.md
Normal file
80
apps/core/guides/features/email.md
Normal file
|
@ -0,0 +1,80 @@
|
|||
# Email
|
||||
|
||||
# Fluid Email Templates
|
||||
|
||||
We provide an email template based on
|
||||
[Cerberus's Fluid Template](https://tedgoas.github.io/Cerberus/#fluid). This
|
||||
is a template well-suited for transactional email that has been well-tested on
|
||||
a wide variety of email clients. It should let you send nice looking email from
|
||||
your app without having to think about it a lot.
|
||||
|
||||
# Branding / Theming
|
||||
|
||||
Of course, you might want to customize the style of your emails to match your app's
|
||||
unique look or brand. The trick is that for emails to really work across a broad
|
||||
set of common clients, they need to _inline their CSS_. We take care of this for
|
||||
you.
|
||||
|
||||
You can customize the variables (colors, sizes, etc) in config/email_styles.exs
|
||||
and we'll apply them to your emails.
|
||||
|
||||
# Mailer
|
||||
|
||||
Of course, you may want to send your own emails. We provide two modules to help:
|
||||
|
||||
- Legendary.CoreEmail: responsible for generating emails to your specifications
|
||||
- Legendary.CoreMailer: responsible for sending emails per your configuration
|
||||
|
||||
Both are powered by [Bamboo](https://github.com/thoughtbot/bamboo) so you
|
||||
can follow the Bamboo documentation to learn more about customizing and using
|
||||
email in your app.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```elixir
|
||||
defmodule App.HelloEmail do
|
||||
import Bamboo.Email
|
||||
use Bamboo.Phoenix, view: AppWeb.EmailView
|
||||
|
||||
def send_hello_email(to) do
|
||||
to_address
|
||||
|> hello_email()
|
||||
|> Legendary.CoreMailer.deliver_later()
|
||||
end
|
||||
|
||||
def hello_email(to_address) do
|
||||
Legendary.CoreEmail.base_email()
|
||||
|> to(to_address)
|
||||
|> render(:hello, to_address: to_address)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
> Tip: in development mode, any email you send can be viewed at localhost:4000/sent_emails.
|
||||
|
||||
# Email Helpers
|
||||
|
||||
Fluid email templates don't do any good if the content of your HTML emails isn't also as fluid and well-tested. We provide email tag helpers so that you don't
|
||||
have to hand-craft email-friendly HTML. See `Legendary.CoreWeb.EmailHelpers`.
|
||||
|
||||
For example, your hello.html.eex might look something like this:
|
||||
|
||||
```eex
|
||||
<%= preview do %>
|
||||
Have you heard of our awesome app?
|
||||
<% end %>
|
||||
|
||||
<%= h1 do %>
|
||||
Hello, <%= to_address %>
|
||||
<% end %>
|
||||
<%= p do %>
|
||||
We hope you'll join us.
|
||||
<% end %>
|
||||
|
||||
<%= styled_button href: "http://example.com/" do %>
|
||||
Join us!
|
||||
<% end %>
|
||||
```
|
||||
|
||||
We'll handle generating all of the nested tags and inline CSS needed to make the
|
||||
email look good.
|
39
apps/core/guides/features/i18n.md
Normal file
39
apps/core/guides/features/i18n.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Strings File and I18n
|
||||
|
||||
It's a good idea to extract any human-readable strings in your application out
|
||||
into a configuration file. The reason is two-fold:
|
||||
|
||||
1. It makes it easier for developers to update "[copy](https://en.wikipedia.org/wiki/Copy_(written))" in the application and even
|
||||
allows non-developers on a team to make copy changes.
|
||||
2. When your application supports multiple languages, it is easy for translators
|
||||
to provide translations for all of your copy at once.
|
||||
|
||||
In Legendary, we provide a set of tools for doing this via [linguist](https://github.com/change/linguist).
|
||||
|
||||
- (English) strings are stored in config/i18n/en.yml.
|
||||
- You can call `Legendary.I18n.t!/2` to get a string by its key. For example: `Legendary.I18n.t! "en", "site.title"` retrieves the english version of the
|
||||
string labeled "title" under the section "site" on en.yml.
|
||||
|
||||
> Tip: if you use t! a lot (good job!), you can import it in your view module
|
||||
> to save some typing like `import Legendary.I18n, only: [t!: 2]` and then use it like `<%= t! "en", "site.title" %>` in your templates.
|
||||
|
||||
Note that the first argument is a two-letter language code. In order to support
|
||||
other languages, you can provide more yml files in config/i18n (example, config/i18n/fr.yml for French) and call t!/2 with that language code instead.
|
||||
|
||||
Linguist also supports templated translations. If you have a section in en.yml like this:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
hello_message: Hello, %{name}!
|
||||
```
|
||||
|
||||
then you could call `t!` substitutions like this:
|
||||
|
||||
```elixir
|
||||
t! "en", "app.hello_message", name: "Legend"
|
||||
```
|
||||
|
||||
to get the string "Hello, legend!"
|
||||
|
||||
**On the roadmap:** in the future, we intend to provide a mechanism for detecting
|
||||
and managing each visitor's language and providing those strings if available.
|
22
apps/core/guides/features/tasks-and-scripts.md
Normal file
22
apps/core/guides/features/tasks-and-scripts.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Tasks and Scripts
|
||||
|
||||
Legendary follows the [scripts to rule them all pattern](https://github.com/github/scripts-to-rule-them-all). This allows any developers familiar with the pattern,
|
||||
either from other Legendary projects, or from other projects that use the pattern,
|
||||
to immediately pick up a project and get it running.
|
||||
|
||||
Here's a summary of the scripts you'll mostly use:
|
||||
|
||||
1. **bootstrap** installs all the dependencies needed to run the project.
|
||||
2. **update** is used to update dependencies.
|
||||
3. **server** runs the server.
|
||||
4. **console** runs the interactive console.
|
||||
5. **test** runs the test suite.
|
||||
|
||||
When you run server, console, or test, the script will make sure all the right
|
||||
dependencies are in place (by running bootstrap or update). This means you can
|
||||
go straight to running script/server and it should just work.
|
||||
|
||||
We encourage you to customize these scripts to the needs of your project as it grows. A developer should only _ever_ have to run script/server to run the server,
|
||||
and should not need to remember anything beyond that. script/bootstrap should always install everything you need to set up the project from scratch. If you
|
||||
find yourself updating setup steps in your project's README.md, consider how you
|
||||
might automate away that setup in your scripts.
|
|
@ -23,6 +23,13 @@ development better.
|
|||
|
||||
## Up and Running
|
||||
|
||||
Since Legendary is both a template and a framework, you can simply clone the repo
|
||||
to start using it. It's a fully functional Phoenix app as-is. To start a new project:
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.com/mythic-insight/legendary.git <project_name>
|
||||
```
|
||||
|
||||
In order to start the server, run `script/server`. Any dependencies required
|
||||
will be installed automatically using [brew](https://brew.sh/),
|
||||
[asdf](https://asdf-vm.com/#/), and [hex](https://hex.pm/).
|
||||
|
@ -31,27 +38,39 @@ Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
|||
|
||||
## Development
|
||||
|
||||
Your main app lives in apps/app/ and that is where you will do most of your
|
||||
Check out [the tutorial](tutorial.md) to learn how to build your first app with
|
||||
Legendary.
|
||||
|
||||
Your main app lives in apps/app/ and you will do most of your
|
||||
development there. This is a normal Phoenix application and you can develop it
|
||||
as such. Any resources which apply to developing Phoenix applications will apply
|
||||
inside of the app. See the [Phoenix Guides](https://hexdocs.pm/phoenix/overview.html)
|
||||
for a good starting resource in Phoenix development.
|
||||
|
||||
You should not generally need to change code in the other applications which
|
||||
are part of the framework-- admin, content, core. We encourage you to avoid
|
||||
changing those as much as possible, because doing so will make it more difficult
|
||||
to upgrade Legendary to newer versions. However, they are available to you if
|
||||
you find that there are no other ways to accomplish the changes you want to
|
||||
accomplish. If you find yourself adding functionality to admin, content, or core
|
||||
are part of the framework-- apps/admin, apps/content, apps/core. We encourage you
|
||||
to avoid changing those as much as possible, because doing so will make it more
|
||||
difficult to upgrade Legendary to newer versions. However, they are available to
|
||||
you if you find that there are no other ways to accomplish the changes that you want.
|
||||
If you find yourself adding functionality to admin, content, or core
|
||||
that you feel would be beneficial to all Legendary apps, consider making a
|
||||
code contribution back to the framework!
|
||||
|
||||
## CI Configuration
|
||||
|
||||
Legendary comes with gitlab CI settings which should work for you with minimal
|
||||
Legendary comes with GitLab CI settings which should work for you with minimal
|
||||
setup.
|
||||
|
||||
The CI script will automatically tag successful builds. To do this, you will
|
||||
need to configure a [CI variable](-/settings/ci_cd) named `GITLAB_TOKEN`. This
|
||||
token should be a [personal access token](/-/profile/personal_access_tokens) with
|
||||
need to configure a [CI variable](https://docs.gitlab.com/ee/ci/variables/) named
|
||||
`GITLAB_TOKEN`. This token should be a
|
||||
[personal access token](https://gitlab.com/-/profile/personal_access_tokens) with
|
||||
`read_repository, write_repository` permissions.
|
||||
|
||||
## DevOps
|
||||
|
||||
The preconfigured CI pipeline generates semantically versioned docker images that
|
||||
you can deploy in your choice of dockerized hosting. We also provide a manifest
|
||||
for Kubernetes that is automatically updated with each version (see infrastructure/
|
||||
for the generated result and infrastructure_templates/ for the templates used to
|
||||
generate the manifest).
|
||||
|
|
418
apps/core/guides/tutorial.md
Normal file
418
apps/core/guides/tutorial.md
Normal file
|
@ -0,0 +1,418 @@
|
|||
# Tutorial
|
||||
|
||||
This tutorial will teach by example you to make a simple application with Legendary.
|
||||
|
||||
We'll make a simple home inventory app. Our app will let users keep track of their possessions with some key characteristics like name, location, and value. We'll have two types of users— normal users and admins. We'll have a few features:
|
||||
|
||||
Normal User Features
|
||||
|
||||
- Adding items to their inventory
|
||||
- Viewing a list of their items
|
||||
- Updating their items
|
||||
- Deleting their items
|
||||
|
||||
Admin Features
|
||||
|
||||
- Adding, viewing, updating, and deleting anyone's items
|
||||
- Adding product categories and locations
|
||||
|
||||
We'll assume you have some general familiarity with programming— that you know a little bit about how to use the shell, a code editor, and git.
|
||||
|
||||
Let's get started.
|
||||
|
||||
# Prerequisites
|
||||
|
||||
While not strictly necessary, we recommend a couple of tools to make installing Legendary's dependencies (such as Node, Erlang, and Elixir):
|
||||
|
||||
- brew
|
||||
- asdf
|
||||
|
||||
If you use brew and asdf, installing Legendary will automatically install the correct versions of Node, Erlang, and Elixir. Otherwise, you can manually install:
|
||||
|
||||
- erlang 23.0.2 or later
|
||||
- elixir 1.10.3-otp-23 or later
|
||||
- nodejs 14.5.0 or later
|
||||
|
||||
# Making a New Application
|
||||
|
||||
The first step is to clone the Legendary application template to a new project directory. We'll call it home_inventory:
|
||||
|
||||
```bash
|
||||
$ git clone git@gitlab.com:mythic-insight/legendary.git home_inventory
|
||||
$ cd home_inventory
|
||||
```
|
||||
|
||||
This directory contains the skeleton of your app, plus a copy of Legendary to support it.
|
||||
|
||||
Let's start our server to check things out:
|
||||
|
||||
```bash
|
||||
$ script/server
|
||||
```
|
||||
|
||||
The first time you run the server, it will automatically install everything your project needs to run. This will take some time. The next time you run the server, everything will already be installed, so the process will be much faster.
|
||||
|
||||
You should now be able to visit [http://localhost:4000/](http://localhost:4000/) and see a home page. Congrats!
|
||||
|
||||

|
||||
|
||||
# Tour of the Code
|
||||
|
||||
# Generating Our First Resource
|
||||
|
||||
A resource is a chunk of related code that represents something your application does. In our example application, for example, we'll have an Item resource that represents one thing in a user's home inventory.
|
||||
|
||||
A few tasks go into having Items and having our app do useful things with them:
|
||||
|
||||
- We have a group of related things that a user can do, for example, creating an item, updating it, retrieving it to view later, deleting it, or even viewing whole lists of items. This group is called a context module.
|
||||
- We need to describe what an Item is like, and how Items are stored. This is called a schema.
|
||||
- We need to accept requests from the browser, perform some operations, then return a response. This is done by a controller.
|
||||
- We need to generate markup that the browser can understand. This is done with views and templates.
|
||||
|
||||
It would be really tedious to have to create all these things by hand. Luckily, we have generators that do the boring part for us. There are a few different included generators, but we'll use phx.gen.html which generates all the parts of a resource. Let's generate our Item resource:
|
||||
|
||||
```bash
|
||||
$ cd apps/app
|
||||
$ mix phx.gen.html Inventory Item items name:string description:text purchase_date:date value:decimal
|
||||
```
|
||||
|
||||
The generate command has quite a few parts, so let's walk through them one by one.
|
||||
|
||||
- mix is the elixir utility for running command line tasks
|
||||
- phx.gen.html is the name of the generator. phx is for Phoenix (our parent framework), gen is for generators. html is because we're making a resource reachable as an html page.
|
||||
- Inventory is the context where we are adding our resource. Right now it will just include Item and the Item-related features, but later it could include multiple related resources.
|
||||
- items is the name of the database table to use to store Items.
|
||||
- name:string, description:text, etc is a series of fields that each Item will have. The first part is the name of the field (e.g. description) and the second part is the type of the field (e.g. text).
|
||||
|
||||
You'll see that the generator made a few files:
|
||||
|
||||
```bash
|
||||
* creating lib/app_web/controllers/item_controller.ex
|
||||
* creating lib/app_web/templates/item/edit.html.eex
|
||||
* creating lib/app_web/templates/item/form.html.eex
|
||||
* creating lib/app_web/templates/item/index.html.eex
|
||||
* creating lib/app_web/templates/item/new.html.eex
|
||||
* creating lib/app_web/templates/item/show.html.eex
|
||||
* creating lib/app_web/views/item_view.ex
|
||||
* creating test/app_web/controllers/item_controller_test.exs
|
||||
* creating lib/app/inventory/item.ex
|
||||
* creating priv/repo/migrations/20210409172913_create_items.exs
|
||||
* creating lib/app/inventory.ex
|
||||
* injecting lib/app/inventory.ex
|
||||
* creating test/app/inventory_test.exs
|
||||
* injecting test/app/inventory_test.exs
|
||||
```
|
||||
|
||||
In order to start doing things with Items, we need to do two small additional things:
|
||||
|
||||
- Migrating our database
|
||||
- Setting up a route for the Item resource
|
||||
|
||||
# Migrating Our Database
|
||||
|
||||
Database migrations are how the application knows to change your database as you add new features (e.g. adding or deleting tables or columns). To run our pending migration, do this:
|
||||
|
||||
```bash
|
||||
$ mix ecto.migrate
|
||||
```
|
||||
|
||||
# Setting Up A Route
|
||||
|
||||
A route is what defines the URL structure of your application. It tells your application which URLs should be mapped to which controller actions. Let's add a simple route for Items.
|
||||
|
||||
In apps/app/lib/app_web/router.ex, you'll find a part that looks like :
|
||||
|
||||
```bash
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
pow_routes()
|
||||
pow_extension_routes()
|
||||
end
|
||||
```
|
||||
|
||||
We want to add
|
||||
|
||||
```bash
|
||||
resources "/items", ItemController
|
||||
```
|
||||
|
||||
within the scope. The finished code will look like this:
|
||||
|
||||
```bash
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
pow_routes()
|
||||
pow_extension_routes()
|
||||
resources "/items", AppWeb.ItemController
|
||||
end
|
||||
```
|
||||
|
||||
This will add some new URLs to our application:
|
||||
|
||||
- /items for viewing a list of items
|
||||
- /items/:id for viewing a single item. :id stands in for a unique ID of the item
|
||||
- It also maps the create, update, and delete actions for items.
|
||||
|
||||
Let's check it out by going to https://localhost:4000/items
|
||||
|
||||

|
||||
|
||||
You can see that we get an empty table of Items and a button that says "New Item." Let's see what happens when we click New Item. We get a form that allows us to create a new item, complete with all the fields that we set up.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
If we save that, we're presented with a detail view for the Item. Note that it has all the things we filled in. If we click "Back", we go back to the table, but now we have an Item!
|
||||
|
||||

|
||||
|
||||
If you click on the ... menu in the table row. Try editing the Item. Then try deleting the Item. Pretty cool, right?
|
||||
|
||||
Congrats, you've made your first resource!
|
||||
|
||||
# Admin Site
|
||||
|
||||
All Legendary projects come with a built in administration tool. Let's check it out. First, we need to make an admin user:
|
||||
|
||||
```bash
|
||||
$ mix legendary.create_admin
|
||||
```
|
||||
|
||||
Give your email address and a password. Then, go to [localhost:4000](http://localhost:4000) and log in. Click Admin in the menu.
|
||||
|
||||
You'll see something like this:
|
||||
|
||||

|
||||
|
||||
You can see that some of the built-in resources, such as Users, Posts, and Comments are already in the admin. But Item isn't there. How do we add it?
|
||||
|
||||
We need to add Item to config/admin.exs:
|
||||
|
||||
```elixir
|
||||
config :admin, Legendary.Admin,
|
||||
resources: [
|
||||
# Default admin config
|
||||
auth: [
|
||||
name: "Auth",
|
||||
resources: [
|
||||
user: [schema: Legendary.Auth.User, admin: Legendary.Auth.UserAdmin],
|
||||
]
|
||||
],
|
||||
content: [
|
||||
name: "Content",
|
||||
resources: [
|
||||
post: [schema: Legendary.Content.Post, admin: Legendary.Content.PostAdmin, label: "Posts and Pages", id_column: :name],
|
||||
comment: [schema: Legendary.Content.Comment, admin: Legendary.Content.CommentAdmin],
|
||||
]
|
||||
],
|
||||
# our new code
|
||||
inventory: [
|
||||
name: "Inventory",
|
||||
resources: [
|
||||
item: [schema: App.Inventory.Item],
|
||||
]
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
Restart your server. For most changes, you don't need to restart, however, configuration changes like this one require a restart.
|
||||
|
||||
You'll see that admin users can now create, update, and delete Items.
|
||||
|
||||
# Making Things More Interesting
|
||||
|
||||
Right now, we have one list of items shared between all users. What if we want to limit each user's list of items to only ones that they created. We can extend our code a bit to accomplish this.
|
||||
|
||||
First, let's create a column in our database to store the item owner:
|
||||
|
||||
```bash
|
||||
mix ecto.gen.migration add_owner_id_to_items
|
||||
```
|
||||
|
||||
And use it to add an owner_id column and index for the owner_id column:
|
||||
|
||||
```elixir
|
||||
defmodule App.Repo.Migrations.AddOwnerIdToItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table("items") do
|
||||
add :owner_id, :integer
|
||||
end
|
||||
|
||||
create index("items", [:owner_id])
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
And run the migration:
|
||||
|
||||
```bash
|
||||
mix ecto.migrate
|
||||
```
|
||||
|
||||
In item.ex:
|
||||
|
||||
```elixir
|
||||
schema "items" do
|
||||
field :description, :string
|
||||
field :name, :string
|
||||
field :purchase_date, :date
|
||||
field :value, :decimal
|
||||
field :owner_id, :integer # new!
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(item, attrs) do
|
||||
item
|
||||
# Add owner_id
|
||||
|> cast(attrs, [:name, :description, :purchase_date, :value, :owner_id])
|
||||
# Add owner_id
|
||||
|> validate_required([:name, :description, :purchase_date, :value, :owner_id])
|
||||
end
|
||||
```
|
||||
|
||||
Now that we have the column, how do we use it? We need to take the id of the current user and pass it along when we create the item. The best place to do this is in the controller:
|
||||
|
||||
```elixir
|
||||
def create(conn, %{"item" => item_params}) do
|
||||
%{id: current_user_id} = Pow.Plug.current_user(conn)
|
||||
|
||||
case Inventory.create_item(Map.merge(item_params, %{"owner_id" => current_user_id})) do
|
||||
{:ok, item} ->
|
||||
conn
|
||||
|> put_flash(:info, "Item created successfully.")
|
||||
|> redirect(to: Routes.item_path(conn, :show, item))
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, "new.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Now every item we create will have a defined owner.
|
||||
|
||||
We also need to filter the items shown based on the current user. In inventory.ex:
|
||||
|
||||
```elixir
|
||||
def list_items(user_id) do
|
||||
Item
|
||||
|> where(owner_id: user_id)
|
||||
|> Repo.all()
|
||||
end
|
||||
```
|
||||
|
||||
and items_controller.ex:
|
||||
|
||||
```elixir
|
||||
def index(conn, _params) do
|
||||
%{id: current_user_id} = Pow.Plug.current_user(conn)
|
||||
|
||||
items = Inventory.list_items(current_user_id)
|
||||
render(conn, "index.html", items: items)
|
||||
end
|
||||
```
|
||||
|
||||
We also need to prevent any sneaky people from viewing, editing, or deleting an item that doesn't belong to them:
|
||||
|
||||
```elixir
|
||||
plug :authorize when action in [:show, :edit, :update, :delete]
|
||||
|
||||
# ...
|
||||
|
||||
defp authorize(%{params: %{"id" => id}} = conn, _options) do
|
||||
item = Inventory.get_item!(id)
|
||||
%{id: current_user_id} = Pow.Plug.current_user(conn)
|
||||
|
||||
if item.owner_id != current_user_id do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> text("Forbidden")
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This adds a plug that will be run before the show, edit, update, and delete actions. It loads the item in question, and checks it against the current user. If they don't match, we give an error message and stop the request. If they match, we continue on to the action.
|
||||
|
||||
There's one problem— if a user tries to visit /items and they aren't logged in, they receive an ugly error message instead of being helpfully redirected to sign in. It's a pretty bad experience. Let's fix that. In router.ex, change it so it looks like this:
|
||||
|
||||
```elixir
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
pow_routes()
|
||||
pow_extension_routes()
|
||||
end
|
||||
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
pipe_through :require_auth
|
||||
|
||||
resources "/items", AppWeb.ItemController
|
||||
end
|
||||
```
|
||||
|
||||
`pipe_through :require_auth` tells the framework that those routes are only for signed in users! Give it a try. Notice that if you visit /items while you are logged out, you will be taken to the sign in page. And once you sign in, you'll be taken automatically back to items. Pretty cool!
|
||||
|
||||
# Testing
|
||||
|
||||
When you generate a new resource, Legendary will also generate a set of unit tests that correspond with it. But we just added new, untested functionality.
|
||||
|
||||
First, we need to make sure that our tests are run with a user. In item_controller_test.exs:
|
||||
|
||||
```elixir
|
||||
# Add owner_id
|
||||
@create_attrs %{description: "some description", name: "some name", purchase_date: ~D[2010-04-17], value: "120.5", owner_id: 123}
|
||||
# Add owner_id
|
||||
@update_attrs %{description: "some updated description", name: "some updated name", purchase_date: ~D[2011-05-18], value: "456.7", owner_id: 123}
|
||||
@invalid_attrs %{description: nil, name: nil, purchase_date: nil, value: nil}
|
||||
|
||||
def fixture(:item) do
|
||||
{:ok, item} = Inventory.create_item(@create_attrs)
|
||||
item
|
||||
end
|
||||
|
||||
setup %{conn: conn} do
|
||||
user = %Legendary.Auth.User{
|
||||
id: 123
|
||||
}
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Pow.Plug.put_config(current_user_assigns_key: :current_user)
|
||||
|> Pow.Plug.assign_current_user(user, [])
|
||||
|
||||
%{
|
||||
conn: conn
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
And in inventory_test.exs, we need to tell our tests that valid items have an owner_id:
|
||||
|
||||
```elixir
|
||||
@valid_attrs %{
|
||||
description: "test item description",
|
||||
name: "item description",
|
||||
purchase_date: ~D[2021-04-22],
|
||||
value: 200.00,
|
||||
owner_id: 123
|
||||
}
|
||||
```
|
||||
|
||||
# What's Next?
|
||||
|
||||
Legendary apps are just Phoenix apps with some bells and whistles included by default. The more you learn about Phoenix development, the more you'll know about building your app with Legendary. Here are some resources that we recommend:
|
||||
|
||||
- The Official Phoenix Framework Guides: [https://hexdocs.pm/phoenix/overview.html](https://hexdocs.pm/phoenix/overview.html)
|
||||
- Programming Phoenix: [https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/](https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/)
|
||||
- Phoenix LiveView Course by The Pragmatic Studio: [https://pragmaticstudio.com/courses/phoenix-liveview](https://pragmaticstudio.com/courses/phoenix-liveview)
|
||||
|
||||
To learn more about Legendary specifically, [check out the guides.](https://hexdocs.pm/legendary_core/)
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Legendary.CoreWeb.Helpers do
|
||||
@moduledoc """
|
||||
HTML helpers for our styled (Fomantic UI) forms.
|
||||
HTML helpers for our styled (Tailwind) forms.
|
||||
"""
|
||||
use Phoenix.HTML
|
||||
|
||||
|
|
|
@ -26,8 +26,11 @@ defmodule Legendary.Core.MixProject do
|
|||
homepage_url: "https://legendaryframework.org/",
|
||||
docs: [
|
||||
main: "overview",
|
||||
extra_section: "GUIDES",
|
||||
extras: extras()
|
||||
extra_section: "Getting Started",
|
||||
extras: extras(),
|
||||
groups_for_extras: groups_for_extras(),
|
||||
groups_for_modules: groups_for_modules(),
|
||||
assets: "guides/assets",
|
||||
],
|
||||
|
||||
# Hex
|
||||
|
@ -45,7 +48,70 @@ defmodule Legendary.Core.MixProject do
|
|||
end
|
||||
|
||||
defp extras do
|
||||
Path.wildcard("guides/**/*.md")
|
||||
[
|
||||
"guides/overview.md",
|
||||
"guides/tutorial.md",
|
||||
# "guides/tutorial.md": [filename: "tutorial", title: "Tutorial"],
|
||||
"guides/features/admin.md",
|
||||
"guides/features/auth.md",
|
||||
"guides/features/background-jobs.md",
|
||||
"guides/features/content-management.md",
|
||||
"guides/features/devops-templates.md",
|
||||
"guides/features/email.md",
|
||||
"guides/features/i18n.md",
|
||||
"guides/features/tasks-and-scripts.md",
|
||||
]
|
||||
end
|
||||
|
||||
defp groups_for_extras do
|
||||
[
|
||||
Guides: ~r{guides/[^\.]+.md},
|
||||
]
|
||||
end
|
||||
|
||||
defp groups_for_modules do
|
||||
[
|
||||
"Auth": [
|
||||
Legendary.Auth,
|
||||
Legendary.AuthWeb,
|
||||
~r{Legendary\.Auth(Web)?\..+},
|
||||
Legendary.CoreWeb.Router.PowExtensionRouter
|
||||
],
|
||||
"Email": [
|
||||
Legendary.CoreEmail,
|
||||
Legendary.CoreMailer,
|
||||
Legendary.CoreWeb.EmailHelpers,
|
||||
Legendary.CoreWeb.CoreEmailView,
|
||||
],
|
||||
"Internationalization": [
|
||||
Legendary.I18n
|
||||
],
|
||||
"Mix Tasks": [
|
||||
Legendary.Mix,
|
||||
],
|
||||
"View Helpers": [
|
||||
Legendary.CoreWeb.ErrorHelpers,
|
||||
Legendary.CoreWeb.Helpers,
|
||||
],
|
||||
"Core Other": [
|
||||
Legendary.Core,
|
||||
Legendary.Core.MapUtils,
|
||||
Legendary.Core.Repo,
|
||||
Legendary.Core.SharedDBConnectionPool,
|
||||
Mix.Legendary,
|
||||
],
|
||||
"Web Other": [
|
||||
Legendary.CoreWeb,
|
||||
Legendary.CoreWeb.Endpoint,
|
||||
Legendary.CoreWeb.ErrorView,
|
||||
Legendary.CoreWeb.Gettext,
|
||||
Legendary.CoreWeb.LayoutView,
|
||||
Legendary.CoreWeb.Router,
|
||||
Legendary.CoreWeb.Router.Helpers,
|
||||
Legendary.CoreWeb.Telemetry,
|
||||
Legendary.CoreWeb.UserSocket,
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
# Configuration for the OTP application.
|
||||
|
@ -72,7 +138,7 @@ defmodule Legendary.Core.MixProject do
|
|||
{:ex_cldr, "~> 2.13.0"},
|
||||
{:ex_doc, "~> 0.24", only: :dev, runtime: false},
|
||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||
{:phoenix, "~> 1.5.3"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.1"},
|
||||
{:ecto_sql, "~> 3.4"},
|
||||
{:ex_prompt, "~> 0.1.5"},
|
||||
|
|
|
@ -12,13 +12,13 @@ config :kaffy,
|
|||
config :admin, Legendary.Admin,
|
||||
resources: [
|
||||
auth: [
|
||||
name: "Legendary.Auth",
|
||||
name: "Auth",
|
||||
resources: [
|
||||
user: [schema: Legendary.Auth.User, admin: Legendary.Auth.UserAdmin],
|
||||
]
|
||||
],
|
||||
content: [
|
||||
name: "Legendary.Content",
|
||||
name: "Content",
|
||||
resources: [
|
||||
post: [schema: Legendary.Content.Post, admin: Legendary.Content.PostAdmin, label: "Posts and Pages", id_column: :name],
|
||||
comment: [schema: Legendary.Content.Comment, admin: Legendary.Content.CommentAdmin],
|
||||
|
|
|
@ -66,6 +66,13 @@ config :content,
|
|||
{"0 * * * *", Legendary.Content.Sitemaps},
|
||||
]
|
||||
|
||||
config :app,
|
||||
Oban,
|
||||
repo: App.Repo,
|
||||
queues: [default: 10],
|
||||
crontab: [
|
||||
]
|
||||
|
||||
import_config "email_styles.exs"
|
||||
import_config "admin.exs"
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ use Mix.Config
|
|||
# before starting your production server.
|
||||
|
||||
secret_key_base = System.get_env("SECRET_KEY_BASE")
|
||||
signing_salt = System.get_env("LIVE_VIEW_SIGNING_SALT")
|
||||
|
||||
[
|
||||
{:admin, Legendary.Admin, false},
|
||||
|
@ -35,7 +36,7 @@ secret_key_base = System.get_env("SECRET_KEY_BASE")
|
|||
],
|
||||
secret_key_base: secret_key_base,
|
||||
pubsub_server: App.PubSub,
|
||||
live_view: [signing_salt: "g5ltUbnQ"],
|
||||
live_view: [signing_salt: signing_salt],
|
||||
server: start_server
|
||||
] ++ extra_opts
|
||||
end)
|
||||
|
@ -64,6 +65,20 @@ database_url = System.get_env("DATABASE_URL")
|
|||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
||||
end)
|
||||
|
||||
config :core, Legendary.CoreMailer,
|
||||
adapter: Bamboo.SMTPAdapter,
|
||||
server: {:system, "SMTP_HOST"},
|
||||
hostname: {:system, "HOSTNAME"},
|
||||
port: 25,
|
||||
username: {:system, "SMTP_USERNAME"},
|
||||
password: {:system, "SMTP_PASSWORD"},
|
||||
tls: :if_available,
|
||||
allowed_tls_versions: [:"tlsv1", :"tlsv1.1", :"tlsv1.2"],
|
||||
ssl: false,
|
||||
retries: 1,
|
||||
no_mx_lookups: false,
|
||||
auth: :always
|
||||
|
||||
# ## Using releases (Elixir v1.9+)
|
||||
#
|
||||
# If you are doing OTP releases, you need to instruct Phoenix
|
||||
|
|
|
@ -34,6 +34,11 @@ spec:
|
|||
secretKeyRef:
|
||||
name: legendary-doc-site
|
||||
key: secret-key-base
|
||||
- name: LIVE_VIEW_SIGNING_SALT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: legendary-doc-site
|
||||
key: live-view-signing-salt
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
|
@ -24,6 +24,8 @@ spec:
|
|||
ports:
|
||||
- containerPort: 4000
|
||||
env:
|
||||
- name: HOSTNAME
|
||||
value: legendaryframework.org
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
@ -34,6 +36,26 @@ spec:
|
|||
secretKeyRef:
|
||||
name: legendary-doc-site
|
||||
key: secret-key-base
|
||||
- name: LIVE_VIEW_SIGNING_SALT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: legendary
|
||||
key: live-view-signing-salt
|
||||
- name: SMTP_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: legendary
|
||||
key: smtp-host
|
||||
- name: SMTP_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: legendary
|
||||
key: smtp-username
|
||||
- name: SMTP_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: legendary
|
||||
key: smtp-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
6
mix.lock
6
mix.lock
|
@ -43,7 +43,7 @@
|
|||
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
|
||||
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
|
||||
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
|
||||
"mochiweb": {:hex, :mochiweb, "2.12.2", "80804ad342afa3d7f3524040d4eed66ce74b17a555de454ac85b07c479928e46", [:make, :rebar], [], "hexpm", "d3e681d4054b74a96cf2efcd09e94157ab83a5f55ddc4ce69f90b8144673bd7a"},
|
||||
"mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"},
|
||||
|
@ -61,7 +61,7 @@
|
|||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
|
||||
"php_serializer": {:hex, :php_serializer, "0.9.2", "59c5fd6bd3096671fd89358fb8229341ac7423b50ad8d45a15213b02ea2edab2", [:mix], [], "hexpm", "34eb835a460944f7fc216773b363c02e7dcf8ac0390c9e9ccdbd92b31a7ca59a"},
|
||||
"plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.5.0", "51c998f788c4e68fc9f947a5eba8c215fbb1d63a520f7604134cab0270ea6513", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5b2c8925a5e2587446f33810a58c01e66b3c345652eeec809b76ba007acde71a"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
|
||||
"postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"},
|
||||
"pow": {:hex, :pow, "1.0.20", "b99993811af5233681bfc521e81ca706d25a56f2be54bad6424db327ce840ab9", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 3.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b6bd271399ccb353abbdbdc316199fe7fd7ae36bbf47059d53e366831c34fc8"},
|
||||
|
@ -71,7 +71,7 @@
|
|||
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
|
||||
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
|
||||
"timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"},
|
||||
|
|
|
@ -12,7 +12,7 @@ else
|
|||
export KERL_CONFIGURE_OPTIONS="--disable-hipe --with-ssl=$(brew --prefix openssl)"
|
||||
fi
|
||||
|
||||
if ! asdf -v &> /dev/null
|
||||
if ! which asdf &> /dev/null
|
||||
then
|
||||
echo "WARNING: No asdf. Skipping erlang, elixir, node installation."
|
||||
else
|
||||
|
@ -27,4 +27,4 @@ else
|
|||
asdf install
|
||||
fi
|
||||
|
||||
mix local.hex --force
|
||||
mix local.hex --force
|
||||
|
|
|
@ -6,8 +6,12 @@ set -e
|
|||
mix local.hex --force
|
||||
mix local.rebar --force
|
||||
|
||||
script/restore-timestamps
|
||||
|
||||
mix deps.get
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
|
||||
mix test
|
||||
|
||||
script/restore-timestamps
|
||||
|
|
82
script/restore-timestamps
Executable file
82
script/restore-timestamps
Executable file
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env elixir
|
||||
|
||||
defmodule TimestampRestorer do
|
||||
@environment System.get_env("MIX_ENV", "dev")
|
||||
@db_path "_build/#{@environment}/timestamp-database"
|
||||
|
||||
def sha_all(opts \\ []) do
|
||||
timestamp_database = load_timestamp_database()
|
||||
|
||||
"**/*.{ex,exs,beam}"
|
||||
|> Path.wildcard()
|
||||
|> Enum.reduce(%{}, fn filename, acc ->
|
||||
{sha, timestamp} = process(filename, timestamp_database, opts)
|
||||
Map.put(acc, sha, timestamp)
|
||||
end)
|
||||
|> write_timestamp_database()
|
||||
end
|
||||
|
||||
defp load_timestamp_database() do
|
||||
if File.exists?(@db_path) do
|
||||
@db_path
|
||||
|> File.read!()
|
||||
|> String.split("\n")
|
||||
|> Enum.reduce(%{}, fn line, acc ->
|
||||
[sha, timestamp_string] = String.split(line, ":")
|
||||
{timestamp, ""} = Integer.parse(timestamp_string)
|
||||
Map.put(acc, sha, timestamp)
|
||||
end)
|
||||
else
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp write_timestamp_database(database) do
|
||||
File.mkdir_p!(Path.dirname(@db_path))
|
||||
|
||||
database
|
||||
|> Enum.map(fn {key, value} -> "#{key}:#{value}" end)
|
||||
|> Enum.join("\n")
|
||||
|> (& File.write!(@db_path, &1)).()
|
||||
end
|
||||
|
||||
defp process(filename, timestamp_database, opts) do
|
||||
{verbose, _opts} = Keyword.pop(opts, :verbose, false)
|
||||
sha = sha(filename)
|
||||
{:ok, %{mtime: new_timestamp}} = File.lstat(filename, time: :posix)
|
||||
|
||||
case Map.get(timestamp_database, sha) do
|
||||
nil ->
|
||||
log("[NEW SHA ] #{filename}: #{new_timestamp}", verbose)
|
||||
timestamp when timestamp < new_timestamp ->
|
||||
log("[RESTORED ] #{filename}: #{timestamp}", verbose)
|
||||
File.touch(filename, timestamp)
|
||||
timestamp when timestamp >= new_timestamp ->
|
||||
log("[UNCHANGED] #{filename}: #{timestamp}", verbose)
|
||||
end
|
||||
|
||||
{sha, new_timestamp}
|
||||
end
|
||||
|
||||
defp log(_message, false), do: :ok
|
||||
defp log(message, true), do: :logger.debug(message)
|
||||
|
||||
defp sha(filename) do
|
||||
hash_ref = :crypto.hash_init(:sha)
|
||||
|
||||
File.stream!(filename)
|
||||
|> Enum.reduce(hash_ref, fn chunk, prev_ref->
|
||||
new_ref = :crypto.hash_update(prev_ref, chunk)
|
||||
new_ref
|
||||
end)
|
||||
|> :crypto.hash_final()
|
||||
|> Base.encode16()
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
|
||||
{opts, _args, _errors} = OptionParser.parse(System.argv(), switches: [verbose: :boolean])
|
||||
|
||||
{time, _result} = :timer.tc(TimestampRestorer, :sha_all, [opts])
|
||||
|
||||
:logger.info("Restored timestamps in #{time / 1_000_000}s")
|
Loading…
Reference in a new issue