Introduce group posts

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-07-09 17:24:28 +02:00
parent bec1c69d4b
commit 9c9f1385fb
249 changed files with 11886 additions and 5023 deletions

View File

@@ -9,7 +9,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias Mobilizon.Reports.{Note, Report}
@@ -27,6 +27,9 @@ defmodule Mobilizon.Actors.Actor do
following_url: String.t(),
followers_url: String.t(),
shared_inbox_url: String.t(),
resources_url: String.t(),
posts_url: String.t(),
events_url: String.t(),
type: ActorType.t(),
name: String.t(),
domain: String.t(),
@@ -62,6 +65,10 @@ defmodule Mobilizon.Actors.Actor do
:shared_inbox_url,
:following_url,
:followers_url,
:posts_url,
:events_url,
:todos_url,
:discussions_url,
:type,
:name,
:domain,
@@ -96,6 +103,10 @@ defmodule Mobilizon.Actors.Actor do
:followers_url,
:members_url,
:resources_url,
:posts_url,
:todos_url,
:events_url,
:discussions_url,
:name,
:summary,
:manually_approves_followers,
@@ -117,6 +128,7 @@ defmodule Mobilizon.Actors.Actor do
schema "actors" do
field(:url, :string)
field(:outbox_url, :string)
field(:inbox_url, :string)
field(:following_url, :string)
@@ -124,7 +136,11 @@ defmodule Mobilizon.Actors.Actor do
field(:shared_inbox_url, :string)
field(:members_url, :string)
field(:resources_url, :string)
field(:posts_url, :string)
field(:events_url, :string)
field(:todos_url, :string)
field(:discussions_url, :string)
field(:type, ActorType, default: :Person)
field(:name, :string)
field(:domain, :string, default: nil)
@@ -344,7 +360,8 @@ defmodule Mobilizon.Actors.Actor do
def build_url("relay", :page, _args),
do: Endpoint |> Routes.activity_pub_url(:relay) |> URI.decode()
def build_url(preferred_username, endpoint, args) when endpoint in [:page, :resources] do
def build_url(preferred_username, endpoint, args)
when endpoint in [:page, :resources, :posts, :discussions, :events, :todos] do
endpoint = if endpoint == :page, do: :actor, else: endpoint
Endpoint
@@ -353,7 +370,7 @@ defmodule Mobilizon.Actors.Actor do
end
def build_url(preferred_username, endpoint, args)
when endpoint in [:outbox, :following, :followers, :members, :todos] do
when endpoint in [:outbox, :following, :followers, :members] do
Endpoint
|> Routes.activity_pub_url(endpoint, preferred_username, args)
|> URI.decode()

View File

@@ -55,6 +55,8 @@ defmodule Mobilizon.Actors do
@public_visibility [:public, :unlisted]
@administrator_roles [:creator, :administrator]
@moderator_roles [:moderator] ++ @administrator_roles
@member_roles [:member] ++ @moderator_roles
@actor_preloads [:user, :organized_events, :comments]
@doc """
@@ -118,6 +120,17 @@ defmodule Mobilizon.Actors do
end
end
@doc """
New function to replace `Mobilizon.Actors.get_actor_by_url/1` with
better signature
"""
@spec get_actor_by_url_2(String.t(), boolean) :: Actor.t() | nil
def get_actor_by_url_2(url, preload \\ false) do
Actor
|> Repo.get_by(url: url)
|> preload_followers(preload)
end
@doc """
Gets an actor by its URL (ActivityPub ID). The `:preload` option allows to
preload the followers relation.
@@ -181,9 +194,17 @@ defmodule Mobilizon.Actors do
"""
@spec create_actor(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_actor(attrs \\ %{}) do
%Actor{}
|> Actor.changeset(attrs)
|> Repo.insert()
type = Map.get(attrs, :type, :Person)
case type do
:Person ->
%Actor{}
|> Actor.changeset(attrs)
|> Repo.insert()
:Group ->
create_group(attrs)
end
end
@doc """
@@ -238,7 +259,8 @@ defmodule Mobilizon.Actors do
name: name,
summary: summary,
avatar: transform_media_file(avatar),
banner: transform_media_file(banner)
banner: transform_media_file(banner),
last_refreshed_at: DateTime.utc_now()
]
],
conflict_target: [:url]
@@ -285,6 +307,7 @@ defmodule Mobilizon.Actors do
"""
@spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def perform(:delete_actor, %Actor{} = actor, options \\ @delete_actor_default_options) do
Logger.info("Going to delete actor #{actor.url}")
actor = Repo.preload(actor, @actor_preloads)
delete_actor_options = Keyword.merge(@delete_actor_default_options, options)
@@ -306,10 +329,18 @@ defmodule Mobilizon.Actors do
case Repo.transaction(multi) do
{:ok, %{actor: %Actor{} = actor}} ->
{:ok, true} = Cachex.del(:activity_pub, "actor_#{actor.preferred_username}")
Logger.info("Deleted actor #{actor.url}")
{:ok, actor}
{:error, remove, error, _} when remove in [:remove_banner, :remove_avatar] ->
Logger.error("Error while deleting actor's banner or avatar")
Logger.error(inspect(error, pretty: true))
{:error, error}
err ->
Logger.error("Unknown error while deleting actor")
Logger.error(inspect(err, pretty: true))
{:error, err}
end
end
@@ -438,23 +469,47 @@ defmodule Mobilizon.Actors do
end
end
@spec get_local_group_by_url(String.t()) :: Actor.t()
def get_local_group_by_url(group_url) do
group_query()
|> where([q], q.url == ^group_url and is_nil(q.domain))
|> Repo.one()
end
@spec get_group_by_members_url(String.t()) :: Actor.t()
def get_group_by_members_url(members_url) do
group_query()
|> where([q], q.members_url == ^members_url)
|> Repo.one()
end
@doc """
Creates a group.
If the group is local, creates an admin actor as well from `creator_actor_id`.
"""
@spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_group(attrs \\ %{}) do
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
Multi.new()
|> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs))
|> Multi.insert(:add_admin_member, fn %{insert_group: group} ->
Member.changeset(%Member{}, %{
parent_id: group.id,
actor_id: attrs.creator_actor_id,
role: :administrator
})
end)
|> Repo.transaction() do
{:ok, group}
local = Map.get(attrs, :local, true)
if local do
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
Multi.new()
|> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs))
|> Multi.insert(:add_admin_member, fn %{insert_group: group} ->
Member.changeset(%Member{}, %{
parent_id: group.id,
actor_id: attrs.creator_actor_id,
role: :administrator
})
end)
|> Repo.transaction() do
{:ok, group}
end
else
%Actor{}
|> Actor.group_creation_changeset(attrs)
|> Repo.insert()
end
end
@@ -532,12 +587,7 @@ defmodule Mobilizon.Actors do
def is_member?(actor_id, parent_id) do
match?(
{:ok, %Member{}},
get_member(actor_id, parent_id, [
:member,
:moderator,
:administrator,
:creator
])
get_member(actor_id, parent_id, @member_roles)
)
end
@@ -552,6 +602,20 @@ defmodule Mobilizon.Actors do
|> Repo.one()
end
@spec get_single_group_member_actor(integer() | String.t()) :: Actor.t() | nil
def get_single_group_member_actor(group_id) do
Member
|> where(
[m],
m.parent_id == ^group_id and m.role in [^:member, ^:moderator, ^:administrator, ^:creator]
)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], is_nil(a.domain))
|> limit(1)
|> select([_m, a], a)
|> Repo.one()
end
@doc """
Creates a member.
"""
@@ -616,25 +680,26 @@ defmodule Mobilizon.Actors do
@doc """
Returns the list of members for a group.
"""
@spec list_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
group_id
|> members_for_group_query()
|> Page.build_page(page, limit)
end
@spec list_external_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_external_members_for_group(
@spec list_members_for_group(Actor.t(), list(atom()), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(
%Actor{id: group_id, type: :Group},
roles \\ [],
page \\ nil,
limit \\ nil
) do
group_id
|> members_for_group_query()
|> filter_external()
|> filter_member_role(roles)
|> Page.build_page(page, limit)
end
@spec list_external_actors_members_for_group(Actor.t()) :: list(Actor.t())
def list_external_actors_members_for_group(%Actor{id: group_id, type: :Group}) do
group_id
|> group_external_member_actor_query()
|> Repo.all()
end
@doc """
Returns the list of administrator members for a group.
"""
@@ -1141,6 +1206,26 @@ defmodule Mobilizon.Actors do
)
end
@spec group_external_member_actor_query(integer()) :: Ecto.Query.t()
defp group_external_member_actor_query(group_id) do
Member
|> where([m], m.parent_id == ^group_id)
|> join(:inner, [m], a in Actor, on: m.actor_id == a.id)
|> where([_m, a], not is_nil(a.domain))
|> select([_m, a], a)
end
@spec filter_member_role(Ecto.Query.t(), list(atom()) | atom()) :: Ecto.Query.t()
def filter_member_role(query, []), do: query
def filter_member_role(query, roles) when is_list(roles) do
where(query, [m], m.role in ^roles)
end
def filter_member_role(query, role) when is_atom(role) do
from(m in query, where: m.role == ^role)
end
@spec administrator_members_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp administrator_members_for_group_query(group_id) do
from(
@@ -1296,13 +1381,22 @@ defmodule Mobilizon.Actors do
defp preload_followers(actor, true), do: Repo.preload(actor, [:followers])
defp preload_followers(actor, false), do: actor
defp delete_actor_organized_events(%Actor{organized_events: organized_events}) do
defp delete_actor_organized_events(%Actor{organized_events: organized_events} = actor) do
res =
Enum.map(organized_events, fn event ->
event =
Repo.preload(event, [:organizer_actor, :participants, :picture, :mentions, :comments])
Repo.preload(event, [
:organizer_actor,
:participants,
:picture,
:mentions,
:comments,
:attributed_to,
:tags,
:physical_address
])
ActivityPub.delete(event, false)
ActivityPub.delete(event, actor, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do
@@ -1312,13 +1406,21 @@ defmodule Mobilizon.Actors do
end
end
defp delete_actor_empty_comments(%Actor{comments: comments}) do
defp delete_actor_empty_comments(%Actor{comments: comments} = actor) do
res =
Enum.map(comments, fn comment ->
comment =
Repo.preload(comment, [:actor, :mentions, :event, :in_reply_to_comment, :origin_comment])
Repo.preload(comment, [
:actor,
:mentions,
:event,
:in_reply_to_comment,
:origin_comment,
:attributed_to,
:tags
])
ActivityPub.delete(comment, false)
ActivityPub.delete(comment, actor, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do

View File

@@ -119,7 +119,7 @@ defmodule Mobilizon.Config do
@spec instance_user_agent :: String.t()
def instance_user_agent,
do: "#{instance_name()} #{instance_hostname()} - Mobilizon #{instance_version()}"
do: "#{instance_hostname()} - Mobilizon #{instance_version()}"
@spec instance_federating :: String.t()
def instance_federating, do: instance_config()[:federating]

View File

@@ -1,53 +0,0 @@
defmodule Mobilizon.Conversations.Conversation.TitleSlug do
@moduledoc """
Module to generate the slug for conversations
"""
use EctoAutoslugField.Slug, from: :title, to: :slug
end
defmodule Mobilizon.Conversations.Conversation do
@moduledoc """
Represents a conversation
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Conversations.Conversation.TitleSlug
@type t :: %__MODULE__{
creator: Actor.t(),
actor: Actor.t(),
title: String.t(),
slug: String.t(),
last_comment: Comment.t(),
comments: list(Comment.t())
}
@required_attrs [:actor_id, :creator_id, :title, :last_comment_id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
schema "conversations" do
field(:title, :string)
field(:slug, TitleSlug.Type)
belongs_to(:creator, Actor)
belongs_to(:actor, Actor)
belongs_to(:last_comment, Comment)
has_many(:comments, Comment, foreign_key: :conversation_id)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = conversation, attrs) do
conversation
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> TitleSlug.maybe_generate_slug()
end
end

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.Conversations.Comment do
defmodule Mobilizon.Discussions.Comment do
@moduledoc """
Represents an actor comment (for instance on an event or on a group).
"""
@@ -8,7 +8,7 @@ defmodule Mobilizon.Conversations.Comment do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, CommentVisibility, Conversation}
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Mention
@@ -42,7 +42,7 @@ defmodule Mobilizon.Conversations.Comment do
:attributed_to_id,
:deleted_at,
:local,
:conversation_id
:discussion_id
]
@attrs @required_attrs ++ @optional_attrs
@@ -60,7 +60,7 @@ defmodule Mobilizon.Conversations.Comment do
belongs_to(:event, Event, foreign_key: :event_id)
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
belongs_to(:conversation, Conversation)
belongs_to(:discussion, Discussion, type: :binary_id)
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention)
@@ -69,7 +69,7 @@ defmodule Mobilizon.Conversations.Comment do
end
@doc """
Returns the id of the first comment in the conversation.
Returns the id of the first comment in the discussion.
"""
@spec get_thread_id(t) :: integer
def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do
@@ -98,6 +98,7 @@ defmodule Mobilizon.Conversations.Comment do
|> change()
|> put_change(:text, nil)
|> put_change(:actor_id, nil)
|> put_change(:discussion_id, nil)
|> put_change(:deleted_at, DateTime.utc_now() |> DateTime.truncate(:second))
end

View File

@@ -0,0 +1,102 @@
defmodule Mobilizon.Discussions.Discussion.TitleSlug do
@moduledoc """
Module to generate the slug for discussions
"""
use EctoAutoslugField.Slug, from: [:title, :id], to: :slug
def build_slug([title, id], %Ecto.Changeset{valid?: true}) do
[title, ShortUUID.encode!(id)]
|> Enum.join("-")
|> Slugger.slugify()
end
def build_slug(_sources, %Ecto.Changeset{valid?: false}), do: ""
end
defmodule Mobilizon.Discussions.Discussion do
@moduledoc """
Represents a discussion
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Discussions.Discussion.TitleSlug
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@type t :: %__MODULE__{
creator: Actor.t(),
actor: Actor.t(),
title: String.t(),
url: String.t(),
slug: String.t(),
last_comment: Comment.t(),
comments: list(Comment.t())
}
@required_attrs [:actor_id, :creator_id, :title, :last_comment_id, :url, :id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
@primary_key {:id, Ecto.UUID, autogenerate: true}
schema "discussions" do
field(:title, :string)
field(:slug, TitleSlug.Type)
field(:url, :string)
belongs_to(:creator, Actor)
belongs_to(:actor, Actor)
belongs_to(:last_comment, Comment)
has_many(:comments, Comment, foreign_key: :discussion_id)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = discussion, attrs) do
discussion
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> validate_required([:title, :id])
|> TitleSlug.maybe_generate_slug()
|> TitleSlug.unique_constraint()
|> maybe_generate_url()
|> validate_required(@required_attrs)
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, slug} when changes in [:changes, :data] <-
fetch_field(changeset, :slug),
{_changes, actor_id} <-
fetch_field(changeset, :actor_id),
%Actor{preferred_username: preferred_username} <-
Actors.get_actor(actor_id),
url <- generate_url(preferred_username, slug) do
put_change(changeset, :url, url)
else
_ -> changeset
end
end
@spec generate_url(String.t(), String.t()) :: String.t()
defp generate_url(preferred_username, slug),
do: Routes.page_url(Endpoint, :discussion, preferred_username, slug)
end

View File

@@ -1,6 +1,6 @@
defmodule Mobilizon.Conversations do
defmodule Mobilizon.Discussions do
@moduledoc """
The conversations context
The discussions context
"""
import EctoEnum
@@ -9,7 +9,7 @@ defmodule Mobilizon.Conversations do
alias Ecto.Changeset
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, Conversation}
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Storage.{Page, Repo}
defenum(
@@ -42,10 +42,11 @@ defmodule Mobilizon.Conversations do
:origin_comment,
:replies,
:tags,
:mentions
:mentions,
:discussion
]
@conversation_preloads [
@discussion_preloads [
:last_comment,
:comments,
:creator,
@@ -231,21 +232,11 @@ defmodule Mobilizon.Conversations do
@doc """
Returns the list of public comments for the actor.
"""
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Comment.t()], integer}
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
comments =
actor_id
|> public_comments_for_actor_query()
|> Page.paginate(page, limit)
|> Repo.all()
count_comments =
actor_id
|> count_comments_query()
|> Repo.one()
{:ok, comments, count_comments}
actor_id
|> public_comments_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
@@ -263,10 +254,10 @@ defmodule Mobilizon.Conversations do
|> Repo.all()
end
@spec get_comments_for_conversation(integer, integer | nil, integer | nil) :: Page.t()
def get_comments_for_conversation(conversation_id, page \\ nil, limit \\ nil) do
@spec get_comments_for_discussion(integer, integer | nil, integer | nil) :: Page.t()
def get_comments_for_discussion(discussion_id, page \\ nil, limit \\ nil) do
Comment
|> where([c], c.conversation_id == ^conversation_id)
|> where([c], c.discussion_id == ^discussion_id)
|> order_by(asc: :inserted_at)
|> Page.build_page(page, limit)
end
@@ -277,80 +268,114 @@ defmodule Mobilizon.Conversations do
@spec count_local_comments :: integer
def count_local_comments, do: Repo.one(count_local_comments_query())
def get_conversation(conversation_id) do
Conversation
|> Repo.get(conversation_id)
|> Repo.preload(@conversation_preloads)
def get_discussion(discussion_id) do
Discussion
|> Repo.get(discussion_id)
|> Repo.preload(@discussion_preloads)
end
@spec find_conversations_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_conversations_for_actor(actor_id, page \\ nil, limit \\ nil) do
Conversation
@spec get_discussion_by_url(String.t() | nil) :: Discussion.t() | nil
def get_discussion_by_url(nil), do: nil
def get_discussion_by_url(discussion_url) do
Discussion
|> Repo.get_by(url: discussion_url)
|> Repo.preload(@discussion_preloads)
end
def get_discussion_by_slug(discussion_slug) do
Discussion
|> Repo.get_by(slug: discussion_slug)
|> Repo.preload(@discussion_preloads)
end
@spec find_discussions_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_discussions_for_actor(actor_id, page \\ nil, limit \\ nil) do
Discussion
|> where([c], c.actor_id == ^actor_id)
|> preload(^@conversation_preloads)
|> preload(^@discussion_preloads)
|> Page.build_page(page, limit)
end
@doc """
Creates a conversation.
Creates a discussion.
"""
@spec create_conversation(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_conversation(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
@spec create_discussion(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_discussion(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, discussion: %Discussion{} = discussion}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{actor_id: attrs.creator_id}))
Comment.changeset(
%Comment{},
Map.merge(attrs, %{actor_id: attrs.creator_id, attributed_to_id: attrs.actor_id})
)
)
|> Multi.insert(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
%Conversation{},
|> Multi.insert(:discussion, fn %{comment: %Comment{id: comment_id}} ->
Discussion.changeset(
%Discussion{},
Map.merge(attrs, %{last_comment_id: comment_id})
)
end)
|> Multi.update(:comment_conversation, fn %{
comment: %Comment{} = comment,
conversation: %Conversation{
id: conversation_id
}
} ->
Changeset.change(comment, %{conversation_id: conversation_id})
|> Multi.update(:comment_discussion, fn %{
comment: %Comment{} = comment,
discussion: %Discussion{
id: discussion_id,
url: discussion_url
}
} ->
Changeset.change(comment, %{discussion_id: discussion_id, url: discussion_url})
end)
|> Repo.transaction() do
{:ok, conversation}
{:ok, discussion}
end
end
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
def reply_to_discussion(%Discussion{id: discussion_id} = discussion, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, discussion: %Discussion{} = discussion}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{conversation_id: conversation_id}))
Comment.changeset(
%Comment{},
Map.merge(attrs, %{
discussion_id: discussion_id,
actor_id: Map.get(attrs, :creator_id, attrs.actor_id)
})
)
)
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
conversation,
|> Multi.update(:discussion, fn %{comment: %Comment{id: comment_id}} ->
Discussion.changeset(
discussion,
%{last_comment_id: comment_id}
)
end)
|> Repo.transaction() do
# For some reason conversation is not updated
{:ok, Map.put(conversation, :last_comment, comment)}
# Discussion is not updated
{:ok, Map.put(discussion, :last_comment, comment)}
end
end
@doc """
Update a conversation. Only their title for now.
Update a discussion. Only their title for now.
"""
@spec update_conversation(Conversation.t(), map()) ::
{:ok, Conversation.t()} | {:error, Changeset.t()}
def update_conversation(%Conversation{} = conversation, attrs \\ %{}) do
conversation
|> Conversation.changeset(attrs)
@spec update_discussion(Discussion.t(), map()) ::
{:ok, Discussion.t()} | {:error, Changeset.t()}
def update_discussion(%Discussion{} = discussion, attrs \\ %{}) do
discussion
|> Discussion.changeset(attrs)
|> Repo.update()
end
@doc """
Delete a discussion.
"""
@spec delete_discussion(Discussion.t()) :: {:ok, Discussion.t()} | {:error, Changeset.t()}
def delete_discussion(%Discussion{} = discussion) do
discussion
|> Repo.delete()
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
@@ -365,11 +390,6 @@ defmodule Mobilizon.Conversations do
|> preload_for_comment()
end
@spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
end
@spec count_local_comments_query :: Ecto.Query.t()
defp count_local_comments_query do
from(
@@ -382,6 +402,6 @@ defmodule Mobilizon.Conversations do
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
# @spec preload_for_conversation(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_conversation(query), do: preload(query, ^@conversation_preloads)
# @spec preload_for_discussion(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_discussion(query), do: preload(query, ^@discussion_preloads)
end

View File

@@ -13,7 +13,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.{Addresses, Events, Media, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{
EventOptions,

View File

@@ -7,7 +7,7 @@ defmodule Mobilizon.Events.EventOptions do
import Ecto.Changeset
alias Mobilizon.Conversations.CommentModeration
alias Mobilizon.Discussions.CommentModeration
alias Mobilizon.Events.{
EventOffer,

View File

@@ -380,24 +380,19 @@ defmodule Mobilizon.Events do
@doc """
Lists public events for the actor, with all associations loaded.
"""
@spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Event.t()], integer}
def list_public_events_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
events =
actor_id
|> event_for_actor_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Page.paginate(page, limit)
|> Repo.all()
@spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_public_events_for_actor(actor, page \\ nil, limit \\ nil)
events_count =
actor_id
|> count_events_for_actor_query()
|> Repo.one()
def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit),
do: list_organized_events_for_group(group, page, limit)
{:ok, events, events_count}
def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do
actor_id
|> event_for_actor_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Page.build_page(page, limit)
end
@spec list_organized_events_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
@@ -1321,15 +1316,6 @@ defmodule Mobilizon.Events do
)
end
@spec count_events_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp count_events_for_actor_query(actor_id) do
from(
e in Event,
select: count(e.id),
where: e.organizer_actor_id == ^actor_id
)
end
@spec count_local_events_query :: Ecto.Query.t()
defp count_local_events_query do
from(e in Event, select: count(e.id), where: e.local == ^true)

View File

@@ -19,7 +19,7 @@ defmodule Mobilizon.Events.Participant do
url: String.t(),
event: Event.t(),
actor: Actor.t(),
metadata: Map.t()
metadata: map()
}
@required_attrs [:url, :role, :event_id, :actor_id]

View File

@@ -6,7 +6,7 @@ defmodule Mobilizon.Mention do
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Storage.Repo

137
lib/mobilizon/posts/post.ex Normal file
View File

@@ -0,0 +1,137 @@
defmodule Mobilizon.Posts.Post.TitleSlug do
@moduledoc """
Module to generate the slug for posts
"""
use EctoAutoslugField.Slug, from: [:title, :id], to: :slug
def build_slug([title, id], %Ecto.Changeset{valid?: true}) do
[title, ShortUUID.encode!(id)]
|> Enum.join("-")
|> Slugger.slugify()
end
def build_slug(_sources, %Ecto.Changeset{valid?: false}), do: ""
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.Tag
alias Mobilizon.Media.Picture
alias Mobilizon.Posts.Post.TitleSlug
alias Mobilizon.Posts.PostVisibility
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@type t :: %__MODULE__{
url: String.t(),
local: boolean,
slug: String.t(),
body: String.t(),
title: String.t(),
draft: boolean,
visibility: PostVisibility.t(),
publish_at: DateTime.t(),
author: Actor.t(),
attributed_to: Actor.t(),
picture: Picture.t(),
tags: [Tag.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_value: :public)
belongs_to(:author, Actor)
belongs_to(:attributed_to, Actor)
belongs_to(:picture, Picture, on_replace: :update)
many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete)
timestamps()
end
@required_attrs [
:id,
:title,
:body,
:draft,
:slug,
:url,
:author_id,
:attributed_to_id
]
@optional_attrs [:picture_id, :local, :publish_at, :visibility]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(%__MODULE__{} = post, attrs) do
post
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> put_tags(attrs)
|> maybe_put_publish_date()
# Validate ID and title here because they're needed for slug
|> validate_required([:id, :title])
|> TitleSlug.maybe_generate_slug()
|> TitleSlug.unique_constraint()
|> maybe_generate_url()
|> validate_required(@required_attrs)
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 <- 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), do: Routes.page_url(Endpoint, :post, id_and_slug)
@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
defp process_tag(tag), do: Tag.changeset(%Tag{}, tag)
defp maybe_put_publish_date(%Changeset{} = changeset) do
publish_at =
if get_field(changeset, :draft, true) == false,
do: DateTime.utc_now() |> DateTime.truncate(:second),
else: nil
put_change(changeset, :publish_at, publish_at)
end
end

View File

@@ -0,0 +1,135 @@
defmodule Mobilizon.Posts do
@moduledoc """
The Posts context.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag
alias Mobilizon.Posts.Post
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
require Logger
@post_preloads [:author, :attributed_to, :picture]
import EctoEnum
defenum(PostVisibility, :post_visibility, [
:public,
:unlisted,
:restricted,
:private
])
@doc """
Returns the list of recent posts for a group
"""
@spec get_posts_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_posts_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> do_get_posts_for_group()
|> Page.build_page(page, limit)
end
@spec get_public_posts_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_public_posts_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> do_get_posts_for_group()
|> where([p], p.visibility == ^:public and not p.draft)
|> Page.build_page(page, limit)
end
def do_get_posts_for_group(group_id) do
Post
|> where(attributed_to_id: ^group_id)
|> order_by(desc: :inserted_at)
|> preload([p], [:author, :attributed_to, :picture])
end
@doc """
Get a post by it's ID
"""
@spec get_post(integer | String.t()) :: Post.t() | nil
def get_post(nil), do: nil
def get_post(id), do: Repo.get(Post, id)
@spec get_post_with_preloads(integer | String.t()) :: Post.t() | nil
def get_post_with_preloads(id) do
Post
|> Repo.get(id)
|> Repo.preload(@post_preloads)
end
@spec get_post_by_slug(String.t()) :: Post.t() | nil
def get_post_by_slug(nil), do: nil
def get_post_by_slug(slug), do: Repo.get_by(Post, slug: slug)
@spec get_post_by_slug_with_preloads(String.t()) :: Post.t() | nil
def get_post_by_slug_with_preloads(slug) do
Post
|> Repo.get_by(slug: slug)
|> Repo.preload(@post_preloads)
end
@doc """
Get a post by it's URL
"""
@spec get_post_by_url(String.t()) :: Post.t() | nil
def get_post_by_url(url), do: Repo.get_by(Post, url: url)
@spec get_post_by_url_with_preloads(String.t()) :: Post.t() | nil
def get_post_by_url_with_preloads(url) do
Post
|> Repo.get_by(url: url)
|> Repo.preload(@post_preloads)
end
@doc """
Creates a post.
"""
@spec create_post(map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a post.
"""
@spec update_post(Post.t(), map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def update_post(%Post{} = post, attrs) do
post
|> Repo.preload(:tags)
|> Post.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a post
"""
@spec delete_post(Post.t()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def delete_post(%Post{} = post), do: Repo.delete(post)
@doc """
Returns the list of tags for the post.
"""
@spec list_tags_for_post(integer | String.t()) :: [Tag.t()]
def list_tags_for_post(post_id) do
{:ok, uuid} = Ecto.UUID.dump(post_id)
uuid
|> tags_for_post_query()
|> Repo.all()
end
@spec tags_for_post_query(integer) :: Ecto.Query.t()
defp tags_for_post_query(post_id) do
from(
t in Tag,
join: p in "posts_tags",
on: t.id == p.tag_id,
where: p.post_id == ^post_id
)
end
end

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.Reports.Report do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, ReportStatus}

View File

@@ -23,6 +23,7 @@ defmodule Mobilizon.Resources do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end
@@ -55,6 +56,7 @@ defmodule Mobilizon.Resources do
Resource
|> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end

View File

@@ -27,6 +27,7 @@ defmodule Mobilizon.Todos do
TodoList
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> preload([:actor])
|> Page.build_page(page, limit)
end

View File

@@ -7,6 +7,15 @@ defmodule Mobilizon.Users.Setting do
import Ecto.Changeset
alias Mobilizon.Users.{NotificationPendingNotificationDelay, User}
@type t :: %__MODULE__{
timezone: String.t(),
notification_on_day: boolean,
notification_each_week: boolean,
notification_before_event: boolean,
notification_pending_participation: NotificationPendingNotificationDelay.t(),
user: User.t()
}
@required_attrs [:user_id]
@optional_attrs [