From bc628db4177727ff9a59dffe7e89393b03e5d029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20F=C3=B6hring?= Date: Sun, 8 Apr 2018 11:53:36 +0200 Subject: [PATCH] Add moderation tools --- .gitignore | 1 + config/prod.secret.example.exs | 7 ++ lib/elixir_status/publisher.ex | 71 ++++++++++++++++--- lib/elixir_status/publisher/guard.ex | 12 ++++ lib/elixir_status_moderation_sample.ex | 7 ++ ...80408074127_add_moderation_to_postings.exs | 11 +++ web/controllers/posting_controller.ex | 34 +++++++++ web/models/posting.ex | 5 +- web/persistence/posting.ex | 41 +++++++++++ web/router.ex | 4 ++ web/static/css/app/component.post.scss | 29 ++++++++ web/templates/posting/moderate.html.eex | 28 ++++++++ web/templates/posting/posting.html.eex | 32 ++++++++- web/views/view_helper.ex | 9 ++- 14 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 lib/elixir_status_moderation_sample.ex create mode 100644 priv/repo/migrations/20180408074127_add_moderation_to_postings.exs create mode 100644 web/templates/posting/moderate.html.eex diff --git a/.gitignore b/.gitignore index 687c0d6..3ccf7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ erl_crash.dump /config/logger.extension.exs .envrc .tool-versions-e +lib/elixir_status_moderation.ex diff --git a/config/prod.secret.example.exs b/config/prod.secret.example.exs index 0e82be0..e7eef1f 100644 --- a/config/prod.secret.example.exs +++ b/config/prod.secret.example.exs @@ -31,3 +31,10 @@ config :appsignal, :config, push_api_key: "your-hex-appsignal-key", env: :prod, revision: Mix.Project.config()[:version] + +# This function receives the posting and author as arguments and determines +# reasons why it requires moderation +# Return an empty list if no moderation is necessary +config :elixir_status, + :publisher_moderation_reasons, + &ElixirStatusModerationSample.moderation_reasons/2 diff --git a/lib/elixir_status/publisher.ex b/lib/elixir_status/publisher.ex index c7cf7dd..8e97612 100644 --- a/lib/elixir_status/publisher.ex +++ b/lib/elixir_status/publisher.ex @@ -23,7 +23,11 @@ defmodule ElixirStatus.Publisher do if Guard.blocked?(new_posting, author) do after_create_blocked(new_posting, author) else - after_create_valid(new_posting, author) + if Guard.moderation_required?(new_posting, author) do + after_create_moderation_required(new_posting, author) + else + after_create_valid(new_posting, author) + end end end @@ -35,26 +39,43 @@ defmodule ElixirStatus.Publisher do Posting.unpublish(new_posting) end + defp after_create_moderation_required(new_posting, author) do + new_posting + |> create_all_short_links + |> send_direct_message_moderation_required + + reasons = Guard.moderation_reasons(new_posting, author) + + Posting.require_moderation(new_posting, reasons) + end + defp after_create_valid(new_posting, author) do new_posting |> create_all_short_links |> send_direct_message_valid - tweet_uid = post_to_twitter(new_posting, author.twitter_handle) - Posting.update_published_tweet_uid(new_posting, tweet_uid) + tweet_about_posting!(new_posting, author) + end + + @doc """ + Called when a posting is published during moderation by PostingController. + """ + def after_publish_moderated(posting, author) do + tweet_about_posting!(posting, author) end @doc """ Called when a posting is unpublished by PostingController. """ def after_unpublish(posting) do - if posting.published_tweet_uid do - spawn(fn -> - ExTwitter.destroy_status(posting.published_tweet_uid) - end) + remove_tweet!(posting) + end - Posting.update_published_tweet_uid(posting, nil) - end + @doc """ + Called when a posting is marked as spam during moderation by PostingController. + """ + def after_mark_as_spam(posting) do + remove_tweet!(posting) end @doc """ @@ -89,6 +110,21 @@ defmodule ElixirStatus.Publisher do "#{uid}-#{permatitle}" end + def tweet_about_posting!(posting, author) do + tweet_uid = post_to_twitter(posting, author.twitter_handle) + Posting.update_published_tweet_uid(posting, tweet_uid) + end + + defp remove_tweet!(posting) do + if posting.published_tweet_uid do + spawn(fn -> + ExTwitter.destroy_status(posting.published_tweet_uid) + end) + + Posting.update_published_tweet_uid(posting, nil) + end + end + defp create_all_short_links(posting) do posting |> SharedUrls.for_posting() @@ -104,6 +140,18 @@ defmodule ElixirStatus.Publisher do send_on_twitter(text, Mix.env()) end + def send_direct_message_moderation_required(posting) do + text = """ + ***MODERATE*** + + #{short_title(posting.title)} + + #{moderation_url(posting)} + """ + + send_on_twitter(text, Mix.env()) + end + defp send_direct_message_valid(%ElixirStatus.Posting{title: title, permalink: permalink}) do text = "#{short_title(title)} #{short_url(permalink)}" @@ -214,4 +262,9 @@ defmodule ElixirStatus.Publisher do ElixirStatus.URL.from_path("/=#{uid}") end + + defp moderation_url(posting) do + "/moderate/#{posting.moderation_key}" + |> ElixirStatus.URL.from_path() + end end diff --git a/lib/elixir_status/publisher/guard.ex b/lib/elixir_status/publisher/guard.ex index 81f9232..90e138c 100644 --- a/lib/elixir_status/publisher/guard.ex +++ b/lib/elixir_status/publisher/guard.ex @@ -1,6 +1,7 @@ defmodule ElixirStatus.Publisher.Guard do @publisher_blocked_urls Application.get_env(:elixir_status, :publisher_blocked_urls) @publisher_blocked_user_names Application.get_env(:elixir_status, :publisher_blocked_user_names) + @publisher_moderation_reasons Application.get_env(:elixir_status, :publisher_moderation_reasons) alias ElixirStatus.Publisher.SharedUrls @@ -8,6 +9,17 @@ defmodule ElixirStatus.Publisher.Guard do blocked_author?(author) or blocked_posting?(posting) end + def moderation_required?(posting, author) do + moderation_reasons(posting, author) + |> List.wrap() + |> Enum.any?() + end + + def moderation_reasons(posting, author) do + @publisher_moderation_reasons.(posting, author) + |> Enum.reject(&is_nil/1) + end + def blocked_author?(author) do blocked_user_names = all_blocked_user_names() diff --git a/lib/elixir_status_moderation_sample.ex b/lib/elixir_status_moderation_sample.ex new file mode 100644 index 0000000..26565b8 --- /dev/null +++ b/lib/elixir_status_moderation_sample.ex @@ -0,0 +1,7 @@ +defmodule ElixirStatusModerationSample do + def moderation_reasons(posting, author) do + [ + String.starts_with?(posting.title, "!!! ") && "Title starts with three exclamation marks" + ] + end +end diff --git a/priv/repo/migrations/20180408074127_add_moderation_to_postings.exs b/priv/repo/migrations/20180408074127_add_moderation_to_postings.exs new file mode 100644 index 0000000..1fbf071 --- /dev/null +++ b/priv/repo/migrations/20180408074127_add_moderation_to_postings.exs @@ -0,0 +1,11 @@ +defmodule ElixirStatus.Repo.Migrations.AddModerationToPostings do + use Ecto.Migration + + def change do + alter table(:postings) do + add(:moderation_key, :string) + add(:awaiting_moderation, :boolean, default: false) + add(:metadata, :map) + end + end +end diff --git a/web/controllers/posting_controller.ex b/web/controllers/posting_controller.ex index 8a434bc..6d989cb 100644 --- a/web/controllers/posting_controller.ex +++ b/web/controllers/posting_controller.ex @@ -192,6 +192,37 @@ defmodule ElixirStatus.PostingController do |> redirect(to: posting_path(conn, :index)) end + def moderate(conn, %{"moderation_key" => moderation_key}) do + assigns = [ + moderation_key: moderation_key, + posting: ElixirStatus.Persistence.Posting.find_by_moderation_key(moderation_key) + ] + + render(conn, "moderate.html", assigns) + end + + def moderate_mark_as_spam(conn, %{"moderation_key" => moderation_key}) do + posting = ElixirStatus.Persistence.Posting.find_by_moderation_key(moderation_key) + + ElixirStatus.Persistence.Posting.mark_as_spam(posting) + Publisher.after_mark_as_spam(posting) + + conn + |> put_flash(:info, "Posting was not published.") + |> redirect(to: posting_path(conn, :index)) + end + + def moderate_publish(conn, %{"moderation_key" => moderation_key}) do + posting = ElixirStatus.Persistence.Posting.find_by_moderation_key(moderation_key) + + ElixirStatus.Persistence.Posting.publish_moderated(posting) + Publisher.after_publish_moderated(posting, posting.user) + + conn + |> put_flash(:info, "Posting was successfully published.") + |> redirect(to: posting_path(conn, :index)) + end + defp load_posting(conn, _) do posting = case conn do @@ -301,7 +332,10 @@ defmodule ElixirStatus.PostingController do title: params["title"], scheduled_at: params["scheduled_at"], published_at: Ecto.DateTime.utc(), + metadata: %{}, + awaiting_moderation: false, public: true, + moderation_key: Ecto.UUID.generate(), type: PostingTypifier.run(tmp_post)["choice"] |> to_string, referenced_urls: PostingUrlFinder.run(tmp_post) |> Poison.encode!() } diff --git a/web/models/posting.ex b/web/models/posting.ex index e7998b6..9dfe640 100644 --- a/web/models/posting.ex +++ b/web/models/posting.ex @@ -10,6 +10,9 @@ defmodule ElixirStatus.Posting do field(:published_at, Ecto.DateTime) field(:published_tweet_uid, :string) field(:public, :boolean, default: false) + field(:moderation_key, :string) + field(:awaiting_moderation, :boolean, default: false) + field(:metadata, :map) field(:type, :string) field(:referenced_urls, :string) @@ -20,7 +23,7 @@ defmodule ElixirStatus.Posting do end @required_fields ~w(user_id uid permalink title text published_at public) - @optional_fields ~w(published_tweet_uid scheduled_at type referenced_urls) + @optional_fields ~w(published_tweet_uid scheduled_at type referenced_urls moderation_key awaiting_moderation metadata) @doc """ Creates a changeset based on the `model` and `params`. diff --git a/web/persistence/posting.ex b/web/persistence/posting.ex index 63406b4..05abc11 100644 --- a/web/persistence/posting.ex +++ b/web/persistence/posting.ex @@ -14,6 +14,14 @@ defmodule ElixirStatus.Persistence.Posting do |> Repo.one() end + def find_by_moderation_key(moderation_key) do + query = from(p in Posting, where: p.moderation_key == ^moderation_key) + + query + |> Ecto.Query.preload(:user) + |> Repo.one() + end + def find_by_permalink(permalink) do String.split(permalink, "-") |> Enum.at(0) |> find_by_uid end @@ -115,9 +123,42 @@ defmodule ElixirStatus.Persistence.Posting do |> Repo.update!() end + def mark_as_spam(posting) do + metadata = posting.metadata || %{} + metadata = Map.put(metadata, "marked_as_spam_at", Ecto.DateTime.utc()) + + attributes = %{public: false, awaiting_moderation: false, metadata: metadata} + + posting + |> ElixirStatus.Posting.changeset(attributes) + |> Repo.update!() + end + def republish(posting) do posting |> ElixirStatus.Posting.changeset(%{public: true}) |> Repo.update!() end + + def publish_moderated(posting) do + metadata = posting.metadata || %{} + metadata = Map.put(metadata, "moderated_at", Ecto.DateTime.utc()) + + attributes = %{public: true, awaiting_moderation: false, metadata: metadata} + + posting + |> ElixirStatus.Posting.changeset(attributes) + |> Repo.update!() + end + + def require_moderation(posting, reasons) do + metadata = posting.metadata || %{} + metadata = Map.put(metadata, "moderation_reasons", reasons) + + attributes = %{public: false, awaiting_moderation: true, metadata: metadata} + + posting + |> ElixirStatus.Posting.changeset(attributes) + |> Repo.update!() + end end diff --git a/web/router.ex b/web/router.ex index ecb7294..307573b 100644 --- a/web/router.ex +++ b/web/router.ex @@ -54,6 +54,10 @@ defmodule ElixirStatus.Router do get("/p/:permalink", PostingController, :show, as: :permalink_posting) get("/p/edit/:permalink", PostingController, :edit) + get("/moderate/:moderation_key", PostingController, :moderate) + post("/moderate/:moderation_key/publish", PostingController, :moderate_publish) + post("/moderate/:moderation_key/mark_as_spam", PostingController, :moderate_mark_as_spam) + get("/edit_profile", UserController, :edit, as: :edit_user) put("/reset_twitter_handle", UserController, :reset_twitter_handle, as: :reset_twitter_handle) diff --git a/web/static/css/app/component.post.scss b/web/static/css/app/component.post.scss index f116708..e47404d 100644 --- a/web/static/css/app/component.post.scss +++ b/web/static/css/app/component.post.scss @@ -23,6 +23,9 @@ .post--unpublished { opacity: 0.4; } +.post--awaiting-moderation { + border: 1px solid #b066ff; +} .post--preview { margin-bottom: -2rem; @@ -187,6 +190,32 @@ select.posting-type-selector { @include clearAfter } +.post__moderation-hint { + color: #b066ff; + border: 1px solid #b066ff; + padding: 1rem 1rem 0 1rem; + margin-bottom: 1rem; + + h3 { + color: #fff; + background-color: #b066ff; + padding: 1rem; + margin: -1rem -1rem 1rem -1rem; + } +} + +.post__not-public-hint { + color: #e62117; + border: 1px solid #e62117; + padding: 1rem 1rem 0 1rem; + + h3 { + color: #fff; + background-color: #e62117; + padding: 1rem; + margin: -1rem -1rem 1rem -1rem; + } +} .post__body { a { diff --git a/web/templates/posting/moderate.html.eex b/web/templates/posting/moderate.html.eex new file mode 100644 index 0000000..3291e30 --- /dev/null +++ b/web/templates/posting/moderate.html.eex @@ -0,0 +1,28 @@ +<%= if @posting.metadata["moderated_at"] do %> +

+ This post has already been moderated at <%= @posting.metadata["moderated_at"] |> human_readable_date(false) %>. +

+<% end %> +<%= if @posting.metadata["marked_as_spam_at"] do %> +

+ This post has been marked as spam and unpublished at <%= @posting.metadata["marked_as_spam_at"] |> human_readable_date(false) %>. +

+<% end %> + +

+ <%= link "Publish", to: posting_path(@conn, :moderate_publish, @posting.moderation_key), class: "btn", method: :post %> +

+

+ <%= link "Do not publish", to: posting_path(@conn, :moderate_mark_as_spam, @posting.moderation_key), class: "btn btn--danger", method: :post %> +

+ +
<%= inspect @posting.metadata["moderation_reasons"], pretty: true %>
+ + +<%= render ElixirStatus.PostingView, "posting.html", conn: @conn, + posting: @posting, + belongs_to_me?: false, + just_created_by_me?: false + %> + +
<%= inspect @posting, pretty: true %>
diff --git a/web/templates/posting/posting.html.eex b/web/templates/posting/posting.html.eex index 6b0a529..6287f7b 100644 --- a/web/templates/posting/posting.html.eex +++ b/web/templates/posting/posting.html.eex @@ -1,4 +1,4 @@ -
" data-posting-uid="<%= raw @posting.uid %>"> +
<%= if @posting.awaiting_moderation, do: "post--awaiting-moderation" %>" data-posting-uid="<%= raw @posting.uid %>">
@@ -49,6 +49,36 @@ + <%= if admin?(@conn) && !@posting.public do %> +
+

This post is not public.

+ +

+ <%= link "Re-Publish", to: posting_path(@conn, :republish, @posting), method: :post %> +

+
+ <% end %> + <%= if @posting.awaiting_moderation do %> +
+

This post awaits moderation.

+ +

+ There is a growing number of posts plugging services not related to Elixir. + To keep the quality of this community, some posts have to moderatored. +

+

+ Sorry for this inconvenience! +

+ + <%= if admin?(@conn) do %> +

+ <%= link "Moderate", to: posting_path(@conn, :moderate, @posting.moderation_key) %> +

+ <% end %> + +
+ <% end %> +
<%= sanitized_markdown @posting.text %>
diff --git a/web/views/view_helper.ex b/web/views/view_helper.ex index 487df17..4e94c6a 100644 --- a/web/views/view_helper.ex +++ b/web/views/view_helper.ex @@ -37,7 +37,14 @@ defmodule ViewHelper do def admin?(conn), do: ElixirStatus.Auth.admin?(conn) @doc "Returns a date formatted for humans." - def human_readable_date(date, use_abbrevs? \\ true) do + def human_readable_date(date, use_abbrevs? \\ true) + + def human_readable_date(date, use_abbrevs?) when is_binary(date) do + Ecto.DateTime.cast!(date) + |> human_readable_date(use_abbrevs?) + end + + def human_readable_date(date, use_abbrevs?) do if use_abbrevs? && this_year?(date) do cond do today?(date) ->