defmodule Mobilizon.Posts.Post.TitleSlug do @moduledoc """ Module to generate the slug for posts """ use EctoAutoslugField.Slug, from: [:title, :id], to: :slug @spec build_slug([String.t()], any()) :: String.t() | nil def build_slug([title, id], _changeset) do [title, ShortUUID.encode!(id)] |> Enum.join("-") |> Slugger.slugify() end def build_slug(_, _), do: nil end defmodule Mobilizon.Posts.Post do @moduledoc """ Module that represent Posts published by groups """ use Ecto.Schema import Ecto.Changeset alias Ecto.Changeset alias Mobilizon.Actors.Actor alias Mobilizon.{Events, Medias} alias Mobilizon.Events.Tag alias Mobilizon.Medias.Media alias Mobilizon.Posts.Post.TitleSlug alias Mobilizon.Posts.PostVisibility use Mobilizon.Web, :verified_routes import Mobilizon.Web.Gettext @type t :: %__MODULE__{ id: String.t(), url: String.t(), local: boolean, slug: String.t(), body: String.t(), title: String.t(), draft: boolean, visibility: atom(), publish_at: DateTime.t(), author: Actor.t(), attributed_to: Actor.t(), picture: Media.t(), media: [Media.t()], tags: [Tag.t()], language: String.t() } @primary_key {:id, Ecto.UUID, autogenerate: true} schema "posts" do field(:body, :string) field(:draft, :boolean, default: false) field(:local, :boolean, default: true) field(:slug, TitleSlug.Type) field(:title, :string) field(:url, :string) field(:publish_at, :utc_datetime) field(:visibility, PostVisibility, default: :public) field(:language, :string, default: "und") belongs_to(:author, Actor) belongs_to(:attributed_to, Actor) belongs_to(:picture, Media, on_replace: :update) many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete) many_to_many(:media, Media, join_through: "posts_medias", on_replace: :delete) timestamps(type: :utc_datetime) end @required_attrs [ :id, :title, :body, :draft, :slug, :url, :author_id, :attributed_to_id ] @optional_attrs [:picture_id, :local, :publish_at, :visibility, :language] @attrs @required_attrs ++ @optional_attrs @doc false @spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t() def changeset(%__MODULE__{} = post, attrs) do post |> cast(attrs, @attrs) |> maybe_generate_id() |> put_assoc(:media, Map.get(attrs, :media, [])) |> put_tags(attrs) |> maybe_put_publish_date() |> put_picture(attrs) # Validate ID and title here because they're needed for slug |> validate_required(:id) |> validate_required(:title, message: gettext("A title is required for the post")) |> validate_required(:body, message: gettext("A text is required for the post")) |> TitleSlug.maybe_generate_slug() |> TitleSlug.unique_constraint() |> maybe_generate_url() |> validate_required(@required_attrs -- [:slug, :url]) |> unique_constraint(:url) end defp maybe_generate_id(%Ecto.Changeset{} = changeset) do case fetch_field(changeset, :id) do res when res in [:error, {:data, nil}] -> put_change(changeset, :id, Ecto.UUID.generate()) _ -> changeset end end @spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp maybe_generate_url(%Ecto.Changeset{} = changeset) do with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url), {changes, id_and_slug} when changes in [:changes, :data] <- fetch_field(changeset, :slug), url when is_binary(url) <- generate_url(id_and_slug) do put_change(changeset, :url, url) else _ -> changeset end end @spec generate_url(String.t()) :: String.t() defp generate_url(id_and_slug) when is_binary(id_and_slug), do: url(~p"/p/#{id_and_slug}") defp generate_url(_), do: nil @spec put_tags(Ecto.Changeset.t(), map) :: Ecto.Changeset.t() defp put_tags(changeset, %{"tags" => tags}), do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1)) defp put_tags(changeset, %{tags: tags}), do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1)) defp put_tags(changeset, _), do: changeset @spec process_tag(map() | Tag.t()) :: Tag.t() | Ecto.Changeset.t() # We need a changeset instead of a raw struct because of slug which is generated in changeset defp process_tag(%{id: id} = _tag) do Events.get_tag(id) end defp process_tag(tag), do: Tag.changeset(%Tag{}, tag) defp maybe_put_publish_date(%Changeset{} = changeset) do default_publish_at = if get_field(changeset, :draft, true) == false, do: DateTime.utc_now() |> DateTime.truncate(:second), else: nil publish_at = get_change(changeset, :publish_at, default_publish_at) put_change(changeset, :publish_at, publish_at) end # In case the provided picture is an existing one @spec put_picture(Changeset.t(), map) :: Changeset.t() defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do %Media{} = picture = Medias.get_media!(id) put_assoc(changeset, :picture, picture) end # In case it's a new picture defp put_picture(%Changeset{} = changeset, _attrs) do cast_assoc(changeset, :picture) end @doc """ Whether we can show the post. Returns false if the organizer actor or group is suspended """ @spec show?(t) :: boolean() def show?(%__MODULE__{attributed_to: %Actor{suspended: true}}), do: false def show?(%__MODULE__{author: %Actor{suspended: true}}), do: false def show?(%__MODULE__{}), do: true end