Introduce group basic federation, event new page and notifications

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-02-18 08:57:00 +01:00
parent 300ef8f245
commit 4144e9ffd0
416 changed files with 32220 additions and 16750 deletions

View File

@@ -9,7 +9,8 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Events.{Comment, Event, FeedToken}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User
@@ -67,7 +68,8 @@ defmodule Mobilizon.Actors.Actor do
:summary,
:manually_approves_followers,
:last_refreshed_at,
:user_id
:user_id,
:visibility
]
@attrs @required_attrs ++ @optional_attrs
@@ -92,15 +94,25 @@ defmodule Mobilizon.Actors.Actor do
:shared_inbox_url,
:following_url,
:followers_url,
:members_url,
:resources_url,
:name,
:summary,
:manually_approves_followers
:manually_approves_followers,
:visibility
]
@remote_actor_creation_attrs @remote_actor_creation_required_attrs ++
@remote_actor_creation_optional_attrs
@group_creation_required_attrs [:url, :outbox_url, :inbox_url, :type, :preferred_username]
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary]
@group_creation_required_attrs [
:url,
:outbox_url,
:inbox_url,
:type,
:preferred_username,
:members_url
]
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary, :visibility]
@group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs
schema "actors" do
@@ -110,6 +122,9 @@ defmodule Mobilizon.Actors.Actor do
field(:following_url, :string)
field(:followers_url, :string)
field(:shared_inbox_url, :string)
field(:members_url, :string)
field(:resources_url, :string)
field(:todos_url, :string)
field(:type, ActorType, default: :Person)
field(:name, :string)
field(:domain, :string, default: nil)
@@ -274,18 +289,13 @@ defmodule Mobilizon.Actors.Actor do
def group_creation_changeset(%__MODULE__{} = actor, params) do
actor
|> cast(params, @group_creation_attrs)
|> cast_embed(:avatar)
|> cast_embed(:banner)
|> build_urls(:Group)
|> common_changeset()
|> put_change(:domain, nil)
|> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|> put_change(:type, :Group)
|> unique_username_validator()
|> validate_required(@group_creation_required_attrs)
|> unique_constraint(:preferred_username,
name: :actors_preferred_username_domain_type_index
)
|> unique_constraint(:url, name: :actors_url_index)
|> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100)
end
@@ -309,13 +319,14 @@ defmodule Mobilizon.Actors.Actor do
@spec build_urls(Ecto.Changeset.t(), ActorType.t()) :: Ecto.Changeset.t()
defp build_urls(changeset, type \\ :Person)
defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, _type) do
defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, type) do
changeset
|> put_change(:outbox_url, build_url(username, :outbox))
|> put_change(:followers_url, build_url(username, :followers))
|> put_change(:following_url, build_url(username, :following))
|> put_change(:inbox_url, build_url(username, :inbox))
|> put_change(:shared_inbox_url, "#{Endpoint.url()}/inbox")
|> put_change(:members_url, if(type == :Group, do: build_url(username, :members), else: nil))
|> put_change(:url, build_url(username, :page))
end
@@ -333,14 +344,16 @@ 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, :page, args) do
def build_url(preferred_username, endpoint, args) when endpoint in [:page, :resources] do
endpoint = if endpoint == :page, do: :actor, else: endpoint
Endpoint
|> Routes.page_url(:actor, preferred_username, args)
|> Routes.page_url(endpoint, preferred_username, args)
|> URI.decode()
end
def build_url(preferred_username, endpoint, args)
when endpoint in [:outbox, :following, :followers] do
when endpoint in [:outbox, :following, :followers, :members, :todos] do
Endpoint
|> Routes.activity_pub_url(endpoint, preferred_username, args)
|> URI.decode()

View File

@@ -43,11 +43,13 @@ defmodule Mobilizon.Actors do
])
defenum(MemberRole, :member_role, [
:invited,
:not_approved,
:member,
:moderator,
:administrator,
:creator
:creator,
:rejected
])
@public_visibility [:public, :unlisted]
@@ -341,9 +343,19 @@ defmodule Mobilizon.Actors do
"""
@spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_group(attrs \\ %{}) do
%Actor{}
|> Actor.group_creation_changeset(attrs)
|> Repo.insert()
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
end
@doc """
@@ -354,11 +366,10 @@ defmodule Mobilizon.Actors do
@doc """
Lists the groups.
"""
@spec list_groups(integer | nil, integer | nil) :: [Actor.t()]
@spec list_groups(integer | nil, integer | nil) :: Page.t()
def list_groups(page \\ nil, limit \\ nil) do
groups_query()
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -371,6 +382,16 @@ defmodule Mobilizon.Actors do
|> Repo.all()
end
@doc """
Gets a single member.
"""
@spec get_member(integer | String.t()) :: Member.t() | nil
def get_member(id) do
Member
|> Repo.get(id)
|> Repo.preload([:actor, :parent, :invited_by])
end
@doc """
Gets a single member.
Raises `Ecto.NoResultsError` if the member does not exist.
@@ -393,6 +414,44 @@ defmodule Mobilizon.Actors do
end
end
@spec get_member(integer | String.t(), integer | String.t(), list()) ::
{:ok, Member.t()} | {:error, :member_not_found}
def get_member(actor_id, parent_id, roles) do
case Member
|> where([m], m.actor_id == ^actor_id and m.parent_id == ^parent_id and m.role in ^roles)
|> Repo.one() do
nil ->
{:error, :member_not_found}
member ->
{:ok, member}
end
end
@spec is_member?(integer | String.t(), integer | String.t()) :: boolean()
def is_member?(actor_id, parent_id) do
match?(
{:ok, %Member{}},
get_member(actor_id, parent_id, [
:member,
:moderator,
:administrator,
:creator
])
)
end
@doc """
Gets a single member of an actor (for example a group).
"""
@spec get_member_by_url(String.t()) :: Member.t() | nil
def get_member_by_url(url) do
Member
|> where(url: ^url)
|> preload([:actor, :parent, :invited_by])
|> Repo.one()
end
@doc """
Creates a member.
"""
@@ -402,7 +461,7 @@ defmodule Mobilizon.Actors do
%Member{}
|> Member.changeset(attrs)
|> Repo.insert() do
{:ok, Repo.preload(member, [:actor, :parent])}
{:ok, Repo.preload(member, [:actor, :parent, :invited_by])}
end
end
@@ -422,36 +481,69 @@ defmodule Mobilizon.Actors do
@spec delete_member(Member.t()) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()}
def delete_member(%Member{} = member), do: Repo.delete(member)
@doc """
Returns the list of memberships for an user.
Default behaviour is to not return :not_approved memberships
## Examples
iex> list_event_participations_for_user(5)
%Page{total: 3, elements: [%Participant{}, ...]}
"""
@spec list_memberships_for_user(
integer,
integer | nil,
integer | nil
) :: Page.t()
def list_memberships_for_user(user_id, page, limit) do
user_id
|> list_members_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for an actor.
"""
@spec list_members_for_actor(Actor.t()) :: [Member.t()]
def list_members_for_actor(%Actor{id: actor_id}) do
@spec list_members_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> members_for_actor_query()
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for a group.
"""
@spec list_members_for_group(Actor.t()) :: [Member.t()]
def list_members_for_group(%Actor{id: group_id, type: :Group}) do
@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()
|> Repo.all()
|> 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(
%Actor{id: group_id, type: :Group},
page \\ nil,
limit \\ nil
) do
group_id
|> members_for_group_query()
|> filter_external()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of administrator members for a group.
"""
@spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) ::
[Member.t()]
Page.t()
def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do
id
|> administrator_members_for_group_query()
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -909,6 +1001,17 @@ defmodule Mobilizon.Actors do
)
end
@spec list_members_for_user_query(integer()) :: Ecto.Query.t()
defp list_members_for_user_query(user_id) do
from(
m in Member,
join: a in Actor,
on: m.actor_id == a.id,
where: a.user_id == ^user_id and m.role != ^:not_approved,
preload: [:parent, :actor, :invited_by]
)
end
@spec members_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp members_for_actor_query(actor_id) do
from(

View File

@@ -8,6 +8,7 @@ defmodule Mobilizon.Actors.Member do
import Ecto.Changeset
alias Mobilizon.Actors.{Actor, MemberRole}
alias Mobilizon.Web.Endpoint
@type t :: %__MODULE__{
role: MemberRole.t(),
@@ -15,13 +16,21 @@ defmodule Mobilizon.Actors.Member do
actor: Actor.t()
}
@required_attrs [:parent_id, :actor_id]
@optional_attrs [:role]
@required_attrs [:parent_id, :actor_id, :url]
@optional_attrs [:role, :invited_by_id]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs []
@primary_key {:id, :binary_id, autogenerate: true}
schema "members" do
field(:role, MemberRole, default: :member)
field(:url, :string)
embeds_one :metadata, Metadata, on_replace: :delete do
# TODO : Use this space to put notes when someone is invited / requested to join
end
belongs_to(:invited_by, Actor)
belongs_to(:parent, Actor)
belongs_to(:actor, Actor)
@@ -44,16 +53,49 @@ defmodule Mobilizon.Actors.Member do
@doc """
Checks whether the member is an administrator (admin or creator) of the group.
"""
def is_administrator(%__MODULE__{role: :administrator}), do: {:is_admin, true}
def is_administrator(%__MODULE__{role: :creator}), do: {:is_admin, true}
def is_administrator(%__MODULE__{}), do: {:is_admin, false}
def is_administrator(%__MODULE__{role: :administrator}), do: true
def is_administrator(%__MODULE__{role: :creator}), do: true
def is_administrator(%__MODULE__{}), do: false
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = member, attrs) do
member
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url()
|> validate_required(@required_attrs)
# On both parent_id and actor_id
|> unique_constraint(:parent_id, name: :members_actor_parent_unique_index)
|> unique_constraint(:url, name: :members_url_index)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, @metadata_attrs)
end
# If there's a blank URL that's because we're doing the first insert
@spec ensure_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp ensure_url(%Ecto.Changeset{data: %__MODULE__{url: nil}} = changeset) do
case fetch_change(changeset, :url) do
{:ok, _url} ->
changeset
:error ->
generate_url(changeset)
end
end
# Most time just go with the given URL
defp ensure_url(%Ecto.Changeset{} = changeset), do: changeset
@spec generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp generate_url(%Ecto.Changeset{} = changeset) do
uuid = Ecto.UUID.generate()
changeset
|> put_change(:id, uuid)
|> put_change(:url, "#{Endpoint.url()}/member/#{uuid}")
end
end

View File

@@ -139,6 +139,29 @@ defmodule Mobilizon.Config do
:enabled
]
def instance_resource_providers do
types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types])
providers =
get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:providers])
providers_map = :maps.filter(fn key, _value -> key in Keyword.values(types) end, providers)
case Enum.count(providers_map) do
0 ->
[]
_ ->
Enum.map(providers_map, fn {key, value} ->
%{
type: key,
software: types |> Enum.find(fn {_key, val} -> val == key end) |> elem(0),
endpoint: value
}
end)
end
end
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
def relay_actor_id, do: get_cached_value(:relay_actor_id)

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.Events.Comment do
defmodule Mobilizon.Conversations.Comment do
@moduledoc """
Represents an actor comment (for instance on an event or on a group).
"""
@@ -8,7 +8,8 @@ defmodule Mobilizon.Events.Comment do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, CommentVisibility, Event, Tag}
alias Mobilizon.Conversations.{Comment, CommentVisibility, Conversation}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Mention
alias Mobilizon.Web.Endpoint
@@ -40,7 +41,8 @@ defmodule Mobilizon.Events.Comment do
:origin_comment_id,
:attributed_to_id,
:deleted_at,
:local
:local,
:conversation_id
]
@attrs @required_attrs ++ @optional_attrs
@@ -58,6 +60,7 @@ defmodule Mobilizon.Events.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)
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)
@@ -81,6 +84,14 @@ defmodule Mobilizon.Events.Comment do
|> validate_required(@creation_required_attrs)
end
def update_changeset(%__MODULE__{} = comment, attrs) do
comment
|> changeset(attrs)
# TODO handle comment edits
# |> put_change(:edits, comment.edits + 1)
end
@spec delete_changeset(t) :: Ecto.Changeset.t()
def delete_changeset(%__MODULE__{} = comment) do
comment

View File

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,385 @@
defmodule Mobilizon.Conversations do
@moduledoc """
The conversations context
"""
import EctoEnum
import Ecto.Query
alias Ecto.Changeset
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, Conversation}
alias Mobilizon.Storage.{Page, Repo}
defenum(
CommentVisibility,
:comment_visibility,
[
:public,
:unlisted,
:private,
:moderated,
:invite
]
)
defenum(
CommentModeration,
:comment_moderation,
[
:allow_all,
:moderated,
:closed
]
)
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
:replies,
:tags,
:mentions
]
@conversation_preloads [
:last_comment,
:comments,
:creator,
:actor
]
@public_visibility [:public, :unlisted]
def data do
Dataloader.Ecto.new(Repo, query: &query/2)
end
@doc """
Query for comment dataloader
We only get first comment of thread, and count replies.
Read: https://hexdocs.pm/absinthe/ecto.html#dataloader
"""
def query(Comment, _params) do
Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
end
def query(queryable, _) do
queryable
end
@doc """
Gets a single comment.
"""
@spec get_comment(integer | String.t()) :: Comment.t() | nil
def get_comment(nil), do: nil
def get_comment(id), do: Repo.get(Comment, id)
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment!(integer | String.t()) :: Comment.t()
def get_comment!(id), do: Repo.get!(Comment, id)
def get_comment_with_preload(nil), do: nil
def get_comment_with_preload(id) do
Comment
|> where(id: ^id)
|> preload_for_comment()
|> Repo.one()
end
@doc """
Gets a comment by its URL.
"""
@spec get_comment_from_url(String.t()) :: Comment.t() | nil
def get_comment_from_url(url), do: Repo.get_by(Comment, url: url)
@doc """
Gets a comment by its URL.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url!(String.t()) :: Comment.t()
def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url)
@doc """
Gets a comment by its URL, with all associations loaded.
"""
@spec get_comment_from_url_with_preload(String.t()) ::
{:ok, Comment.t()} | {:error, :comment_not_found}
def get_comment_from_url_with_preload(url) do
query = from(c in Comment, where: c.url == ^url)
comment =
query
|> preload_for_comment()
|> Repo.one()
case comment do
%Comment{} = comment ->
{:ok, comment}
nil ->
{:error, :comment_not_found}
end
end
@doc """
Gets a comment by its URL, with all associations loaded.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url_with_preload(String.t()) :: Comment.t()
def get_comment_from_url_with_preload!(url) do
Comment
|> Repo.get_by!(url: url)
|> Repo.preload(@comment_preloads)
end
@doc """
Gets a comment by its UUID, with all associations loaded.
"""
@spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t()
def get_comment_from_uuid_with_preload(uuid) do
Comment
|> Repo.get_by(uuid: uuid)
|> Repo.preload(@comment_preloads)
end
def get_threads(event_id) do
Comment
|> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id))
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
|> Repo.all()
end
@doc """
Gets paginated replies for root comment
"""
@spec get_thread_replies(integer()) :: [Comment.t()]
def get_thread_replies(parent_id) do
parent_id
|> public_replies_for_thread_query()
|> Repo.all()
end
def get_or_create_comment(%{"url" => url} = attrs) do
case Repo.get_by(Comment, url: url) do
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
nil -> create_comment(attrs)
end
end
@doc """
Creates a comment.
"""
@spec create_comment(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_comment(attrs \\ %{}) do
with {:ok, %Comment{} = comment} <-
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end
@doc """
Updates a comment.
"""
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.update_changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a comment
But actually just empty the fields so that threads are not broken.
"""
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
comment
|> Comment.delete_changeset()
|> Repo.update()
end
@doc """
Returns the list of public comments.
"""
@spec list_comments :: [Comment.t()]
def list_comments do
Repo.all(from(c in Comment, where: c.visibility == ^:public))
end
@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}
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}
end
@doc """
Returns the list of comments by an actor and a list of ids.
"""
@spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) ::
[Comment.t()]
def list_comments_by_actor_and_ids(actor_id, comment_ids \\ [])
def list_comments_by_actor_and_ids(_actor_id, []), do: []
def list_comments_by_actor_and_ids(actor_id, comment_ids) do
Comment
|> where([c], c.id in ^comment_ids)
|> where([c], c.actor_id == ^actor_id)
|> 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
Comment
|> where([c], c.conversation_id == ^conversation_id)
|> order_by(asc: :inserted_at)
|> Page.build_page(page, limit)
end
@doc """
Counts local comments.
"""
@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)
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
|> where([c], c.actor_id == ^actor_id)
|> preload(^@conversation_preloads)
|> Page.build_page(page, limit)
end
@doc """
Creates a conversation.
"""
@spec create_conversation(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_conversation(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{actor_id: attrs.creator_id}))
)
|> Multi.insert(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
%Conversation{},
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})
end)
|> Repo.transaction() do
{:ok, conversation}
end
end
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{conversation_id: conversation_id}))
)
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
conversation,
%{last_comment_id: comment_id}
)
end)
|> Repo.transaction() do
# For some reason conversation is not updated
{:ok, Map.put(conversation, :last_comment, comment)}
end
end
@doc """
Update a conversation. 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)
|> Repo.update()
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
|> order_by([c], desc: :id)
|> preload_for_comment()
end
defp public_replies_for_thread_query(comment_id) do
Comment
|> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility)
|> group_by([c], [c.in_reply_to_comment_id, c.id])
|> 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(
c in Comment,
select: count(c.id),
where: c.local == ^true and c.visibility in ^@public_visibility
)
end
@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)
end

View File

@@ -13,8 +13,9 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.{Addresses, Events, Media, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{
Comment,
EventOptions,
EventParticipantStats,
EventStatus,
@@ -78,7 +79,8 @@ defmodule Mobilizon.Events.Event do
:online_address,
:phone_address,
:picture_id,
:physical_address_id
:physical_address_id,
:attributed_to_id
]
@attrs @required_attrs ++ @optional_attrs

View File

@@ -7,8 +7,9 @@ defmodule Mobilizon.Events.EventOptions do
import Ecto.Changeset
alias Mobilizon.Conversations.CommentModeration
alias Mobilizon.Events.{
CommentModeration,
EventOffer,
EventParticipationCondition
}
@@ -25,7 +26,8 @@ defmodule Mobilizon.Events.EventOptions do
offers: [EventOffer.t()],
participation_condition: [EventParticipationCondition.t()],
show_start_time: boolean,
show_end_time: boolean
show_end_time: boolean,
hide_organizer_when_group_event: boolean
}
@attrs [
@@ -38,7 +40,8 @@ defmodule Mobilizon.Events.EventOptions do
:comment_moderation,
:show_participation_price,
:show_start_time,
:show_end_time
:show_end_time,
:hide_organizer_when_group_event
]
@primary_key false
@@ -54,6 +57,7 @@ defmodule Mobilizon.Events.EventOptions do
field(:show_participation_price, :boolean)
field(:show_start_time, :boolean, default: true)
field(:show_end_time, :boolean, default: true)
field(:hide_organizer_when_group_event, :boolean, default: false)
embeds_many(:offers, EventOffer)
embeds_many(:participation_condition, EventParticipationCondition)

View File

@@ -16,7 +16,6 @@ defmodule Mobilizon.Events do
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.{
Comment,
Event,
EventParticipantStats,
FeedToken,
@@ -61,20 +60,6 @@ defmodule Mobilizon.Events do
:meeting
])
defenum(CommentVisibility, :comment_visibility, [
:public,
:unlisted,
:private,
:moderated,
:invite
])
defenum(CommentModeration, :comment_moderation, [
:allow_all,
:moderated,
:closed
])
defenum(ParticipantRole, :participant_role, [
:not_approved,
:not_confirmed,
@@ -100,17 +85,6 @@ defmodule Mobilizon.Events do
:picture
]
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
:replies,
:tags,
:mentions
]
@doc """
Gets a single event.
"""
@@ -427,6 +401,14 @@ defmodule Mobilizon.Events do
{:ok, events, events_count}
end
@spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> event_for_group_query()
|> preload_for_event()
|> Page.build_page(page, limit)
end
@spec list_drafts_for_user(integer, integer | nil, integer | nil) :: [Event.t()]
def list_drafts_for_user(user_id, page \\ nil, limit \\ nil) do
Event
@@ -796,13 +778,12 @@ defmodule Mobilizon.Events do
DateTime.t() | nil,
integer | nil,
integer | nil
) :: list(Participant.t())
) :: Page.t()
def list_participations_for_user(user_id, after_datetime, before_datetime, page, limit) do
user_id
|> list_participations_for_user_query()
|> participation_filter_begins_on(after_datetime, before_datetime)
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -1127,219 +1108,6 @@ defmodule Mobilizon.Events do
|> Repo.all()
end
def data do
Dataloader.Ecto.new(Repo, query: &query/2)
end
@doc """
Query for comment dataloader
We only get first comment of thread, and count replies.
Read: https://hexdocs.pm/absinthe/ecto.html#dataloader
"""
def query(Comment, _params) do
Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
end
def query(queryable, _) do
queryable
end
@doc """
Gets a single comment.
"""
@spec get_comment(integer | String.t()) :: Comment.t()
def get_comment(nil), do: nil
def get_comment(id), do: Repo.get(Comment, id)
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment!(integer | String.t()) :: Comment.t()
def get_comment!(id), do: Repo.get!(Comment, id)
def get_comment_with_preload(nil), do: nil
def get_comment_with_preload(id) do
Comment
|> where(id: ^id)
|> preload_for_comment()
|> Repo.one()
end
@doc """
Gets a comment by its URL.
"""
@spec get_comment_from_url(String.t()) :: Comment.t() | nil
def get_comment_from_url(url), do: Repo.get_by(Comment, url: url)
@doc """
Gets a comment by its URL.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url!(String.t()) :: Comment.t()
def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url)
@doc """
Gets a comment by its URL, with all associations loaded.
"""
@spec get_comment_from_url_with_preload(String.t()) ::
{:ok, Comment.t()} | {:error, :comment_not_found}
def get_comment_from_url_with_preload(url) do
query = from(c in Comment, where: c.url == ^url)
comment =
query
|> preload_for_comment()
|> Repo.one()
case comment do
%Comment{} = comment ->
{:ok, comment}
nil ->
{:error, :comment_not_found}
end
end
@doc """
Gets a comment by its URL, with all associations loaded.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url_with_preload(String.t()) :: Comment.t()
def get_comment_from_url_with_preload!(url) do
Comment
|> Repo.get_by!(url: url)
|> Repo.preload(@comment_preloads)
end
@doc """
Gets a comment by its UUID, with all associations loaded.
"""
@spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t()
def get_comment_from_uuid_with_preload(uuid) do
Comment
|> Repo.get_by(uuid: uuid)
|> Repo.preload(@comment_preloads)
end
def get_threads(event_id) do
Comment
|> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id))
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
|> Repo.all()
end
@doc """
Gets paginated replies for root comment
"""
@spec get_thread_replies(integer()) :: [Comment.t()]
def get_thread_replies(parent_id) do
parent_id
|> public_replies_for_thread_query()
|> Repo.all()
end
def get_or_create_comment(%{"url" => url} = attrs) do
case Repo.get_by(Comment, url: url) do
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
nil -> create_comment(attrs)
end
end
@doc """
Creates a comment.
"""
@spec create_comment(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_comment(attrs \\ %{}) do
with {:ok, %Comment{} = comment} <-
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end
@doc """
Updates a comment.
"""
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a comment
But actually just empty the fields so that threads are not broken.
"""
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
comment
|> Comment.delete_changeset()
|> Repo.update()
end
@doc """
Returns the list of public comments.
"""
@spec list_comments :: [Comment.t()]
def list_comments do
Repo.all(from(c in Comment, where: c.visibility == ^:public))
end
@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}
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}
end
@doc """
Returns the list of comments by an actor and a list of ids.
"""
@spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) ::
[Comment.t()]
def list_comments_by_actor_and_ids(actor_id, comment_ids \\ [])
def list_comments_by_actor_and_ids(_actor_id, []), do: []
def list_comments_by_actor_and_ids(actor_id, comment_ids) do
Comment
|> where([c], c.id in ^comment_ids)
|> where([c], c.actor_id == ^actor_id)
|> Repo.all()
end
@doc """
Counts local comments.
"""
@spec count_local_comments :: integer
def count_local_comments, do: Repo.one(count_local_comments_query())
@doc """
Gets a single feed token.
"""
@@ -1429,6 +1197,15 @@ defmodule Mobilizon.Events do
)
end
@spec event_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp event_for_group_query(group_id) do
from(
e in Event,
where: e.attributed_to_id == ^group_id,
order_by: [desc: :id]
)
end
@spec upcoming_public_event_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp upcoming_public_event_for_actor_query(actor_id) do
from(
@@ -1656,20 +1433,6 @@ defmodule Mobilizon.Events do
from(s in Session, where: s.track_id == ^track_id)
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
|> order_by([c], desc: :id)
|> preload_for_comment()
end
defp public_replies_for_thread_query(comment_id) do
Comment
|> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility)
|> group_by([c], [c.in_reply_to_comment_id, c.id])
|> preload_for_comment()
end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participants_for_event_query(event_id) do
from(
@@ -1711,20 +1474,6 @@ defmodule Mobilizon.Events do
)
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(
c in Comment,
select: count(c.id),
where: c.local == ^true and c.visibility in ^@public_visibility
)
end
@spec feed_token_query(String.t()) :: Ecto.Query.t()
defp feed_token_query(token) do
from(ftk in FeedToken, where: ftk.token == ^token, preload: [:actor, :user])
@@ -1825,6 +1574,17 @@ defmodule Mobilizon.Events do
|> participation_order_begins_on_desc()
end
defp participation_filter_begins_on(
query,
%DateTime{} = after_datetime,
%DateTime{} = before_datetime
) do
query
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> where([_p, e, _a], e.begins_on > ^after_datetime)
|> participation_order_begins_on_asc()
end
defp participation_order_begins_on_asc(query),
do: order_by(query, [_p, e, _a], asc: e.begins_on)
@@ -1833,7 +1593,4 @@ defmodule Mobilizon.Events do
@spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_event(query), do: preload(query, ^@event_preloads)
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
end

View File

@@ -6,7 +6,8 @@ defmodule Mobilizon.Mention do
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Storage.Repo
@type t :: %__MODULE__{

View File

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

View File

@@ -0,0 +1,104 @@
defmodule Mobilizon.Resources.Resource do
@moduledoc """
Represents a web resource
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import EctoEnum
defenum(TypeEnum, folder: 0, link: 1, picture: 20, pad: 30, calc: 40, visio: 50)
alias Mobilizon.Actors.Actor
@type t :: %__MODULE__{
title: String.t(),
summary: String.t(),
url: String.t(),
resource_url: String.t(),
type: atom(),
metadata: Mobilizon.Resources.Resource.Metadata.t(),
children: list(__MODULE__),
parent: __MODULE__,
actor: Actor.t(),
creator: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "resource" do
field(:summary, :string)
field(:title, :string)
field(:url, :string)
field(:resource_url, :string)
field(:type, TypeEnum)
field(:path, :string)
field(:local, :boolean, default: true)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:type, :string)
field(:title, :string)
field(:description, :string)
field(:image_remote_url, :string)
field(:width, :integer)
field(:height, :integer)
field(:author_name, :string)
field(:author_url, :string)
field(:provider_name, :string)
field(:provider_url, :string)
field(:html, :string)
field(:favicon_url, :string)
end
has_many(:children, __MODULE__, foreign_key: :parent_id)
belongs_to(:parent, __MODULE__, type: :binary_id)
belongs_to(:actor, Actor)
belongs_to(:creator, Actor)
timestamps()
end
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path]
@optional_attrs [:summary, :parent_id, :resource_url, :local]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs [
:type,
:title,
:description,
:image_remote_url,
:width,
:height,
:author_name,
:author_url,
:provider_name,
:provider_url,
:html,
:favicon_url
]
@doc false
def changeset(resource, attrs) do
resource
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url(:resource)
|> validate_resource_or_folder()
|> validate_required(@required_attrs)
|> unique_constraint(:url, name: :resource_url_index)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, @metadata_attrs)
end
@spec validate_resource_or_folder(Changeset.t()) :: Changeset.t()
defp validate_resource_or_folder(%Changeset{} = changeset) do
with {status, type} when status in [:changes, :data] <- fetch_field(changeset, :type),
true <- type != :folder do
validate_required(changeset, [:resource_url])
else
_ -> changeset
end
end
end

View File

@@ -0,0 +1,227 @@
defmodule Mobilizon.Resources do
@moduledoc """
The Resources context.
"""
alias Ecto.Adapters.SQL
alias Ecto.Multi
alias Ecto.UUID
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Resources.Resource
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
require Logger
@resource_preloads [:actor, :creator, :children, :parent]
@doc """
Returns the list of recent resources for a group
"""
@spec get_resources_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of top-level resources for a group
"""
def get_top_level_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
get_resources_for_folder(%Resource{id: "root_something", actor_id: group_id}, page, limit)
end
@doc """
Returns the list of resources for a resource folder.
"""
@spec get_resources_for_folder(Resource.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_folder(resource, page \\ nil, limit \\ nil)
def get_resources_for_folder(
%Resource{id: "root_" <> _group_id, actor_id: group_id},
page,
limit
) do
Resource
|> where([r], r.actor_id == ^group_id and is_nil(r.parent_id))
|> order_by(asc: :type)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end
def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do
Resource
|> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type)
|> Page.build_page(page, limit)
end
@doc """
Get a resource by it's ID
"""
@spec get_resource(integer | String.t()) :: Resource.t() | nil
def get_resource(nil), do: nil
def get_resource(id), do: Repo.get(Resource, id)
@spec get_resource_with_preloads(integer | String.t()) :: Resource.t() | nil
def get_resource_with_preloads(id) do
Resource
|> Repo.get(id)
|> Repo.preload(@resource_preloads)
end
@spec get_resource_by_group_and_path_with_preloads(String.t() | integer, String.t()) ::
Resource.t() | nil
def get_resource_by_group_and_path_with_preloads(group_id, "/") do
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id) do
%Resource{
actor_id: group_id,
id: "root_#{group_id}",
actor: group,
path: "/",
title: "Root"
}
end
end
def get_resource_by_group_and_path_with_preloads(group_id, path) do
Resource
|> Repo.get_by(actor_id: group_id, path: path)
|> Repo.preload(@resource_preloads)
end
@doc """
Get a resource by it's URL
"""
@spec get_resource_by_url(String.t()) :: Resource.t() | nil
def get_resource_by_url(url), do: Repo.get_by(Resource, url: url)
@spec get_resource_by_url_with_preloads(String.t()) :: Resource.t() | nil
def get_resource_by_url_with_preloads(url) do
Resource
|> Repo.get_by(url: url)
|> Repo.preload(@resource_preloads)
end
@doc """
Creates a resource.
"""
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def create_resource(attrs \\ %{}) do
Multi.new()
|> do_find_parent_path(Map.get(attrs, :parent_id))
|> Multi.insert(:insert, fn %{find_parent_path: path} ->
Resource.changeset(%Resource{}, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
end)
|> Repo.transaction()
|> case do
{:ok, %{insert: %Resource{} = resource}} ->
{:ok, resource}
{:error, operation, reason, _changes} ->
{:error, "Error while inserting resource when #{operation} because of #{inspect(reason)}"}
end
end
@doc """
Updates a resource.
Since a resource can be a folder and hold children, we do the following in a transaction:
* Get the parent path so that we can reconstruct the path for current resource (if moved or simply renamed)
* Update all children with the new parent path
* Update the resource path itself
"""
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def update_resource(%Resource{title: old_title} = resource, attrs) do
Multi.new()
|> find_parent_path(resource, attrs)
|> update_children(resource, attrs)
|> Multi.update(:update, fn %{find_parent_path: path} ->
title = Map.get(attrs, :title, old_title)
Resource.changeset(resource, Map.put(attrs, :path, "#{path}/#{title}"))
end)
|> Repo.transaction()
|> case do
{:ok,
%{
find_parent_path: _parent_path,
update: %Resource{} = resource,
update_children: children
}} ->
resource = Map.put(resource, :children, children)
{:ok, resource}
# collect errors into record changesets
{:error, operation, reason, _changes} ->
{:error, "Error while updating resource when #{operation} because of #{inspect(reason)}"}
end
end
@spec find_parent_path(Multi.t(), Resource.t(), map()) :: Multi.t()
defp find_parent_path(
%Multi{} = multi,
%Resource{parent_id: old_parent_id} = _resource,
attrs
) do
updated_parent_id = Map.get(attrs, :parent_id, old_parent_id)
Logger.debug("Finding parent path for updated_parent_id #{inspect(updated_parent_id)}")
do_find_parent_path(multi, updated_parent_id)
end
@spec do_find_parent_path(Multi.t(), String.t() | nil) :: Multi.t()
defp do_find_parent_path(%Multi{} = multi, nil),
do: Multi.run(multi, :find_parent_path, fn _, _ -> {:ok, ""} end)
defp do_find_parent_path(%Multi{} = multi, parent_id) do
Multi.run(multi, :find_parent_path, fn _repo, _changes ->
case get_resource(parent_id) do
%Resource{path: path} = _resource -> {:ok, path}
_ -> {:error, :not_found}
end
end)
end
@spec update_children(Multi.t(), Resource.t(), map()) :: Multi.t()
defp update_children(
%Multi{} = multi,
%Resource{
id: id,
type: :folder,
title: old_title,
actor_id: actor_id
},
attrs
) do
title = Map.get(attrs, :title, old_title)
Multi.run(multi, :update_children, fn repo, %{find_parent_path: path} ->
{:ok, uuid} = UUID.dump(id)
{query, params} =
{"UPDATE resource SET path = CONCAT($1::text, title) WHERE actor_id = $2 AND parent_id = $3::uuid",
["#{path}/#{title}/", actor_id, uuid]}
{:ok, _} =
SQL.query(
repo,
query,
params
)
children = repo.all(from(r in Resource, where: r.parent_id == ^id))
{:ok, children}
end)
end
defp update_children(multi, _, _),
do: Multi.run(multi, :update_children, fn _, _ -> {:ok, ""} end)
@doc """
Deletes a resource
"""
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
end

View File

@@ -4,6 +4,9 @@ defmodule Mobilizon.Storage.Ecto do
"""
import Ecto.Query, warn: false
import Ecto.Changeset, only: [fetch_change: 2, put_change: 3]
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@doc """
Adds sort to the query.
@@ -12,4 +15,35 @@ defmodule Mobilizon.Storage.Ecto do
def sort(query, sort, direction) do
from(query, order_by: [{^direction, ^sort}])
end
@doc """
Ensure changeset contains an URL
If there's a blank URL that's because we're doing the first insert.
Most of the time just go with the given URL.
"""
@spec ensure_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
def ensure_url(%Ecto.Changeset{data: %{url: nil}} = changeset, route) do
case fetch_change(changeset, :url) do
{:ok, _url} ->
changeset
:error ->
generate_url(changeset, route)
end
end
def ensure_url(%Ecto.Changeset{} = changeset, _route), do: changeset
@spec generate_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
defp generate_url(%Ecto.Changeset{} = changeset, route) do
uuid = Ecto.UUID.generate()
changeset
|> put_change(:id, uuid)
|> put_change(
:url,
apply(Routes, String.to_existing_atom("#{to_string(route)}_url"), [Endpoint, route, uuid])
)
end
end

View File

@@ -0,0 +1,47 @@
defmodule Mobilizon.Todos.Todo do
@moduledoc """
Represents a todo, or task
"""
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.TodoList
@type t :: %__MODULE__{
status: boolean(),
title: String.t(),
due_date: DateTime.t(),
todo_list: TodoList.t(),
creator: Actor.t(),
assigned_to: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "todos" do
field(:status, :boolean, default: false)
field(:title, :string)
field(:url, :string)
field(:due_date, :utc_datetime)
field(:local, :boolean, default: true)
belongs_to(:todo_list, TodoList, type: :binary_id)
belongs_to(:creator, Actor)
belongs_to(:assigned_to, Actor)
timestamps()
end
@required_attrs [:title, :creator_id, :url, :todo_list_id]
@optional_attrs [:status, :due_date, :assigned_to_id, :local]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(todo, attrs) do
todo
|> cast(attrs, @attrs)
|> ensure_url(:todo)
|> validate_required(@required_attrs)
end
end

View File

@@ -0,0 +1,42 @@
defmodule Mobilizon.Todos.TodoList do
@moduledoc """
Represents a todo list, or task list
"""
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.Todo
@type t :: %__MODULE__{
title: String.t(),
todos: [Todo.t()],
actor: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "todo_lists" do
field(:title, :string)
field(:url, :string)
field(:local, :boolean, default: true)
belongs_to(:actor, Actor)
has_many(:todos, Todo)
timestamps()
end
@required_attrs [:title, :url, :actor_id]
@optional_attrs [:local]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(todo_list, attrs) do
todo_list
|> cast(attrs, @attrs)
|> ensure_url(:todo_list)
|> validate_required(@required_attrs)
end
end

View File

@@ -0,0 +1,109 @@
defmodule Mobilizon.Todos do
@moduledoc """
The Todos context.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Todos.{Todo, TodoList}
import Ecto.Query
@doc """
Get a todo list by it's ID
"""
@spec get_todo_list(integer | String.t()) :: TodoList.t() | nil
def get_todo_list(id), do: Repo.get(TodoList, id)
@doc """
Get a todo list by it's URL
"""
@spec get_todo_list_by_url(String.t()) :: TodoList.t() | nil
def get_todo_list_by_url(url), do: Repo.get_by(TodoList, url: url)
@doc """
Returns the list of todo lists for a group.
"""
@spec get_todo_lists_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_todo_lists_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
TodoList
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of todos for a group.
"""
@spec get_todos_for_todo_list(TodoList.t(), integer | nil, integer | nil) :: Page.t()
def get_todos_for_todo_list(%TodoList{id: todo_list_id}, page \\ nil, limit \\ nil) do
Todo
|> where(todo_list_id: ^todo_list_id)
|> order_by(asc: :status)
# |> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Creates a todo list.
"""
@spec create_todo_list(map) :: {:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def create_todo_list(attrs \\ %{}) do
%TodoList{}
|> TodoList.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a todo list.
"""
@spec update_todo_list(TodoList.t(), map) ::
{:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def update_todo_list(%TodoList{} = todo_list, attrs) do
todo_list
|> TodoList.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a todo list
"""
@spec delete_todo_list(TodoList.t()) :: {:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def delete_todo_list(%TodoList{} = todo_list), do: Repo.delete(todo_list)
@doc """
Get a todo by it's ID
"""
@spec get_todo(integer | String.t()) :: Todo.t() | nil
def get_todo(id), do: Repo.get(Todo, id)
@doc """
Get a todo by it's URL
"""
@spec get_todo_by_url(String.t()) :: Todo.t() | nil
def get_todo_by_url(url), do: Repo.get_by(Todo, url: url)
@doc """
Creates a todo.
"""
@spec create_todo(map) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def create_todo(attrs \\ %{}) do
%Todo{}
|> Todo.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a todo.
"""
@spec update_todo(Todo.t(), map) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def update_todo(%Todo{} = todo, attrs) do
todo
|> Todo.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a todo
"""
@spec delete_todo(Todo.t()) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def delete_todo(%Todo{} = todo), do: Repo.delete(todo)
end

View File

@@ -0,0 +1,38 @@
defmodule Mobilizon.Users.Setting do
@moduledoc """
Module to manage users settings
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Users.User
@required_attrs [:user_id]
@optional_attrs [
:timezone,
:notification_on_day,
:notification_each_week,
:notification_before_event
]
@attrs @required_attrs ++ @optional_attrs
@primary_key {:user_id, :id, autogenerate: false}
schema "user_settings" do
field(:timezone, :string)
field(:notification_on_day, :boolean)
field(:notification_each_week, :boolean)
field(:notification_before_event, :boolean)
belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false)
timestamps()
end
@doc false
def changeset(setting, attrs) do
setting
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View File

@@ -10,7 +10,7 @@ defmodule Mobilizon.Users.User do
alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto
alias Mobilizon.Events.FeedToken
alias Mobilizon.Users.UserRole
alias Mobilizon.Users.{Setting, UserRole}
alias Mobilizon.Web.Email.Checker
@type t :: %__MODULE__{
@@ -68,6 +68,7 @@ defmodule Mobilizon.Users.User do
belongs_to(:default_actor, Actor)
has_many(:actors, Actor)
has_many(:feed_tokens, FeedToken, foreign_key: :user_id)
has_one(:settings, Setting)
timestamps()
end

View File

@@ -11,7 +11,7 @@ defmodule Mobilizon.Users do
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Users.{Setting, User}
alias Mobilizon.Web.Auth
@@ -44,6 +44,15 @@ defmodule Mobilizon.Users do
@spec get_user!(integer | String.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@spec get_user(integer | String.t()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
def get_user_with_settings!(id) do
User
|> Repo.get(id)
|> Repo.preload([:settings])
end
@doc """
Gets an user by its email.
"""
@@ -265,6 +274,96 @@ defmodule Mobilizon.Users do
end
end
@doc """
Gets a settings for an user.
Raises `Ecto.NoResultsError` if the Setting does not exist.
## Examples
iex> get_setting!(123)
%Setting{}
iex> get_setting!(456)
** (Ecto.NoResultsError)
"""
def get_setting!(user_id), do: Repo.get!(Setting, user_id)
@spec get_setting(User.t()) :: Setting.t()
def get_setting(%User{id: user_id}), do: get_setting(user_id)
@spec get_setting(String.t() | integer()) :: Setting.t()
def get_setting(user_id), do: Repo.get(Setting, user_id)
@doc """
Creates a setting.
## Examples
iex> create_setting(%{field: value})
{:ok, %Setting{}}
iex> create_setting(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_setting(attrs \\ %{}) do
%Setting{}
|> Setting.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:user_id, :inserted_at]},
conflict_target: :user_id
)
end
@doc """
Updates a setting.
## Examples
iex> update_setting(setting, %{field: new_value})
{:ok, %Setting{}}
iex> update_setting(setting, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_setting(%Setting{} = setting, attrs) do
setting
|> Setting.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a setting.
## Examples
iex> delete_setting(setting)
{:ok, %Setting{}}
iex> delete_setting(setting)
{:error, %Ecto.Changeset{}}
"""
def delete_setting(%Setting{} = setting) do
Repo.delete(setting)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking setting changes.
## Examples
iex> change_setting(setting)
%Ecto.Changeset{source: %Setting{}}
"""
def change_setting(%Setting{} = setting) do
Setting.changeset(setting, %{})
end
@spec user_by_email_query(String.t(), boolean | nil) :: Ecto.Query.t()
defp user_by_email_query(email, nil) do
from(u in User,