galleries

This commit is contained in:
byte[] 2019-12-04 23:12:49 -05:00
parent c918e4f7ab
commit 885488d40e
22 changed files with 430 additions and 77 deletions

View file

@ -4,9 +4,13 @@ defmodule Philomena.Galleries do
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Philomena.Repo
alias Philomena.Galleries.Gallery
alias Philomena.Galleries.Interaction
alias Philomena.Notifications
alias Philomena.Images
@doc """
Returns the list of galleries.
@ -104,7 +108,7 @@ defmodule Philomena.Galleries do
def reindex_gallery(%Gallery{} = gallery) do
spawn fn ->
gallery
Gallery
|> preload(^indexing_preloads())
|> where(id: ^gallery.id)
|> Repo.one()
@ -126,36 +130,144 @@ defmodule Philomena.Galleries do
[:subscribers, :creator, :interactions]
end
alias Philomena.Galleries.Subscription
def add_image_to_gallery(gallery, image) do
Multi.new()
|> Multi.run(:interaction, fn repo, %{} ->
position = (last_position(gallery.id) || -1) + 1
@doc """
Returns the list of gallery_subscriptions.
%Interaction{gallery_id: gallery.id}
|> Interaction.changeset(%{"image_id" => image.id, "position" => position})
|> repo.insert()
end)
|> Multi.run(:gallery, fn repo, %{} ->
now = DateTime.utc_now()
## Examples
{count, nil} =
Gallery
|> where(id: ^gallery.id)
|> repo.update_all(inc: [image_count: 1], set: [updated_at: now])
iex> list_gallery_subscriptions()
[%Subscription{}, ...]
"""
def list_gallery_subscriptions do
Repo.all(Subscription)
{:ok, count}
end)
|> Repo.isolated_transaction(:serializable)
end
@doc """
Gets a single subscription.
def remove_image_from_gallery(gallery, image) do
Multi.new()
|> Multi.run(:interaction, fn repo, %{} ->
%Interaction{gallery_id: gallery.id, image_id: image.id}
|> repo.delete()
end)
|> Multi.run(:gallery, fn repo, %{} ->
now = DateTime.utc_now()
Raises `Ecto.NoResultsError` if the Subscription does not exist.
{count, nil} =
Gallery
|> where(id: ^gallery.id)
|> repo.update_all(inc: [image_count: -1], set: [updated_at: now])
## Examples
{:ok, count}
end)
|> Repo.isolated_transaction(:serializable)
end
iex> get_subscription!(123)
%Subscription{}
defp last_position(gallery_id) do
Interaction
|> where(gallery_id: ^gallery_id)
|> Repo.aggregate(:max, :position)
end
iex> get_subscription!(456)
** (Ecto.NoResultsError)
def notify_gallery(gallery) do
spawn fn ->
subscriptions =
gallery
|> Repo.preload(:subscriptions)
|> Map.fetch!(:subscriptions)
"""
def get_subscription!(id), do: Repo.get!(Subscription, id)
Notifications.notify(
gallery,
subscriptions,
%{
actor_id: gallery.id,
actor_type: "Gallery",
actor_child_id: nil,
actor_child_type: nil,
action: "added images to"
}
)
end
gallery
end
def reorder_gallery(gallery, image_ids) do
spawn fn ->
interactions =
Interaction
|> where([gi], gi.image_id in ^image_ids)
|> order_by(^position_order(gallery))
|> Repo.all()
interaction_positions =
interactions
|> Enum.with_index()
|> Map.new(fn {interaction, index} -> {index, interaction.position} end)
images_present = Map.new(interactions, &{&1.image_id, true})
requested =
image_ids
|> Enum.filter(&images_present[&1])
|> Enum.with_index()
|> Map.new()
changes =
interactions
|> Enum.with_index()
|> Enum.flat_map(fn {interaction, current_index} ->
new_index = requested[interaction.image_id]
case new_index == current_index do
true ->
[]
false ->
[
[
id: interaction.id,
gallery_id: interaction.gallery_id,
image_id: interaction.image_id,
position: interaction_positions[new_index]
]
]
end
end)
# Do the update in a single statement
Repo.insert_all(
Interaction,
changes,
on_conflict: :replace_all_except_primary_key,
conflict_target: [:id]
)
# Now update all the associated images
Images.reindex_images(Map.keys(requested))
end
gallery
end
defp position_order(%{order_position_asc: true}), do: [asc: :position]
defp position_order(_gallery), do: [desc: :position]
alias Philomena.Galleries.Subscription
def subscribed?(_gallery, nil), do: false
def subscribed?(gallery, user) do
Subscription
|> where(gallery_id: ^gallery.id, user_id: ^user.id)
|> Repo.exists?()
end
@doc """
Creates a subscription.
@ -169,28 +281,10 @@ defmodule Philomena.Galleries do
{:error, %Ecto.Changeset{}}
"""
def create_subscription(attrs \\ %{}) do
%Subscription{}
|> Subscription.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a subscription.
## Examples
iex> update_subscription(subscription, %{field: new_value})
{:ok, %Subscription{}}
iex> update_subscription(subscription, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_subscription(%Subscription{} = subscription, attrs) do
subscription
|> Subscription.changeset(attrs)
|> Repo.update()
def create_subscription(gallery, user) do
%Subscription{gallery_id: gallery.id, user_id: user.id}
|> Subscription.changeset(%{})
|> Repo.insert(on_conflict: :nothing)
end
@doc """
@ -205,20 +299,13 @@ defmodule Philomena.Galleries do
{:error, %Ecto.Changeset{}}
"""
def delete_subscription(%Subscription{} = subscription) do
Repo.delete(subscription)
def delete_subscription(gallery, user) do
%Subscription{gallery_id: gallery.id, user_id: user.id}
|> Repo.delete()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking subscription changes.
## Examples
iex> change_subscription(subscription)
%Ecto.Changeset{source: %Subscription{}}
"""
def change_subscription(%Subscription{} = subscription) do
Subscription.changeset(subscription, %{})
def clear_notification(_gallery, nil), do: nil
def clear_notification(gallery, user) do
Notifications.delete_unread_notification("Gallery", gallery.id, user)
end
end

View file

@ -10,11 +10,14 @@ defmodule Philomena.Galleries.Gallery do
alias Philomena.Images.Image
alias Philomena.Users.User
alias Philomena.Galleries.Interaction
alias Philomena.Galleries.Subscription
schema "galleries" do
belongs_to :thumbnail, Image, source: :thumbnail_id
belongs_to :creator, User, source: :creator_id
has_many :interactions, Interaction
has_many :subscriptions, Subscription
has_many :subscribers, through: [:subscriptions, :user]
field :title, :string
field :spoiler_warning, :string

View file

@ -5,11 +5,10 @@ defmodule Philomena.Galleries.Interaction do
alias Philomena.Galleries.Gallery
alias Philomena.Images.Image
@primary_key false
# fixme: unique-key this off (gallery_id, image_id)
schema "gallery_interactions" do
belongs_to :gallery, Gallery, primary_key: true
belongs_to :image, Image, primary_key: true
belongs_to :gallery, Gallery
belongs_to :image, Image
field :position, :integer
end

View file

@ -228,15 +228,20 @@ defmodule Philomena.Images do
end
def reindex_image(%Image{} = image) do
reindex_images([image.id])
image
end
def reindex_images(image_ids) do
spawn fn ->
Image
|> preload(^indexing_preloads())
|> where(id: ^image.id)
|> Repo.one()
|> Image.index_document()
|> where([i], i.id in ^image_ids)
|> Image.reindex()
end
image
image_ids
end
def indexing_preloads do

View file

@ -43,6 +43,7 @@ defmodule Philomena.Images.Image do
has_many :hiders, through: [:hides, :user]
many_to_many :tags, Tag, join_through: "image_taggings", on_replace: :delete
has_one :intensity, ImageIntensity
has_many :galleries, through: [:gallery_interactions, :image]
field :image, :string
field :image_name, :string

View file

@ -0,0 +1,37 @@
defmodule PhilomenaWeb.Gallery.ImageController do
use PhilomenaWeb, :controller
alias Philomena.Images.Image
alias Philomena.Galleries.Gallery
alias Philomena.Images
alias Philomena.Galleries
plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit
plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true
plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show
plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true
def create(conn, _params) do
gallery = conn.assigns.gallery
image = conn.assigns.image
{:ok, _gallery} = Galleries.add_image_to_gallery(gallery, image)
Galleries.notify_gallery(gallery)
Galleries.reindex_gallery(gallery)
Images.reindex_image(image)
json(conn, %{})
end
def delete(conn, _params) do
gallery = conn.assigns.gallery
image = conn.assigns.image
{:ok, _gallery} = Galleries.remove_image_from_gallery(gallery, image)
Galleries.reindex_gallery(gallery)
Images.reindex_image(image)
json(conn, %{})
end
end

View file

@ -0,0 +1,17 @@
defmodule PhilomenaWeb.Gallery.OrderController do
use PhilomenaWeb, :controller
alias Philomena.Galleries.Gallery
alias Philomena.Galleries
plug PhilomenaWeb.CanaryMapPlug, update: :edit
plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true
def update(conn, %{"image_ids" => image_ids}) when is_list(image_ids) do
gallery = conn.assigns.gallery
Galleries.reorder_gallery(gallery, image_ids)
json(conn, %{})
end
end

View file

@ -0,0 +1,18 @@
defmodule PhilomenaWeb.Gallery.ReadController do
import Plug.Conn
use PhilomenaWeb, :controller
alias Philomena.Galleries.Gallery
alias Philomena.Galleries
plug :load_resource, model: Gallery, id_name: "gallery_id", persisted: true
def create(conn, _params) do
gallery = conn.assigns.gallery
user = conn.assigns.current_user
Galleries.clear_notification(gallery, user)
send_resp(conn, :ok, "")
end
end

View file

@ -0,0 +1,34 @@
defmodule PhilomenaWeb.Gallery.ReportController do
use PhilomenaWeb, :controller
alias PhilomenaWeb.ReportController
alias PhilomenaWeb.ReportView
alias Philomena.Galleries.Gallery
alias Philomena.Reports.Report
alias Philomena.Reports
plug PhilomenaWeb.FilterBannedUsersPlug
plug PhilomenaWeb.UserAttributionPlug
plug PhilomenaWeb.CaptchaPlug when action in [:create]
plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show
plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true, preload: [:creator]
def new(conn, _params) do
gallery = conn.assigns.gallery
action = Routes.gallery_report_path(conn, :create, gallery)
changeset =
%Report{reportable_type: "Gallery", reportable_id: gallery.id}
|> Reports.change_report()
conn
|> put_view(ReportView)
|> render("new.html", reportable: gallery, changeset: changeset, action: action)
end
def create(conn, params) do
gallery = conn.assigns.gallery
action = Routes.gallery_report_path(conn, :create, gallery)
ReportController.create(conn, action, gallery, "Gallery", params)
end
end

View file

@ -0,0 +1,35 @@
defmodule PhilomenaWeb.Gallery.SubscriptionController do
use PhilomenaWeb, :controller
alias Philomena.Galleries.Gallery
alias Philomena.Galleries
plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show
plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true
def create(conn, _params) do
gallery = conn.assigns.gallery
user = conn.assigns.current_user
case Galleries.create_subscription(gallery, user) do
{:ok, _subscription} ->
render(conn, "_subscription.html", gallery: gallery, watching: true, layout: false)
{:error, _changeset} ->
render(conn, "_error.html", layout: false)
end
end
def delete(conn, _params) do
gallery = conn.assigns.gallery
user = conn.assigns.current_user
case Galleries.delete_subscription(gallery, user) do
{:ok, _subscription} ->
render(conn, "_subscription.html", gallery: gallery, watching: false, layout: false)
{:error, _changeset} ->
render(conn, "_error.html", layout: false)
end
end
end

View file

@ -2,6 +2,7 @@ defmodule PhilomenaWeb.GalleryController do
use PhilomenaWeb, :controller
alias PhilomenaWeb.ImageLoader
alias PhilomenaWeb.NotificationCountPlug
alias Philomena.ImageSorter
alias Philomena.Interactions
alias Philomena.Galleries.Gallery
@ -31,16 +32,24 @@ defmodule PhilomenaWeb.GalleryController do
def show(conn, _params) do
gallery = conn.assigns.gallery
user = conn.assigns.current_user
query = "gallery_id:#{gallery.id}"
params = Map.put(conn.params, "q", query)
sort = ImageSorter.parse_sort(%{"sf" => "gallery_id:#{gallery.id}", "sd" => position_order(gallery)})
{:ok, images} = ImageLoader.search_string(conn, query, queries: sort.queries, sorts: sort.sorts)
interactions = Interactions.user_interactions(images, conn.assigns.current_user)
interactions = Interactions.user_interactions(images, user)
watching = Galleries.subscribed?(gallery, user)
gallery_images = Jason.encode!(Enum.map(images, & &1.id))
Galleries.clear_notification(gallery, user)
conn
|> NotificationCountPlug.call([])
|> Map.put(:params, params)
|> render("show.html", layout_class: "layout--wide", gallery: gallery, images: images, interactions: interactions)
|> assign(:clientside_data, [gallery_images: gallery_images])
|> render("show.html", layout_class: "layout--wide", watching: watching, gallery: gallery, images: images, interactions: interactions)
end
def new(conn, _params) do

View file

@ -3,7 +3,7 @@ defmodule PhilomenaWeb.ImageController do
alias PhilomenaWeb.ImageLoader
alias PhilomenaWeb.NotificationCountPlug
alias Philomena.{Images, Images.Image, Comments.Comment, Textile.Renderer}
alias Philomena.{Images, Images.Image, Comments.Comment, Galleries.Gallery, Galleries.Interaction, Textile.Renderer}
alias Philomena.Servers.ImageProcessor
alias Philomena.Interactions
alias Philomena.Comments
@ -70,6 +70,8 @@ defmodule PhilomenaWeb.ImageController do
watching =
Images.subscribed?(image, conn.assigns.current_user)
{user_galleries, image_galleries} = image_and_user_galleries(image, conn.assigns.current_user)
render(
conn,
"show.html",
@ -77,6 +79,8 @@ defmodule PhilomenaWeb.ImageController do
comments: comments,
image_changeset: image_changeset,
comment_changeset: comment_changeset,
image_galleries: image_galleries,
user_galleries: user_galleries,
description: description,
interactions: interactions,
watching: watching,
@ -110,4 +114,23 @@ defmodule PhilomenaWeb.ImageController do
|> render("new.html", changeset: changeset)
end
end
defp image_and_user_galleries(_image, nil), do: {[], []}
defp image_and_user_galleries(image, user) do
image_galleries =
Gallery
|> where(creator_id: ^user.id)
|> join(:inner, [g], gi in Interaction, on: g.id == gi.gallery_id and gi.image_id == ^image.id)
|> Repo.all()
image_gallery_ids = Enum.map(image_galleries, & &1.id)
user_galleries =
Gallery
|> where(creator_id: ^user.id)
|> where([g], g.id not in ^image_gallery_ids)
|> Repo.all()
{user_galleries, image_galleries}
end
end

View file

@ -125,7 +125,12 @@ defmodule PhilomenaWeb.Router do
resources "/reports", ReportController, only: [:index]
resources "/user_links", UserLinkController, only: [:index, :new, :create, :show]
resources "/galleries", GalleryController, only: [:new, :create, :edit, :update, :delete]
resources "/galleries", GalleryController, only: [:new, :create, :edit, :update, :delete] do
resources "/images", Gallery.ImageController, only: [:create, :delete], singleton: true
resources "/order", Gallery.OrderController, only: [:update], singleton: true
resources "/read", Gallery.ReadController, only: [:create], singleton: true
resources "/subscription", Gallery.SubscriptionController, only: [:create, :delete], singleton: true
end
end
scope "/", PhilomenaWeb do
@ -188,7 +193,9 @@ defmodule PhilomenaWeb.Router do
end
resources "/posts", PostController, only: [:index]
resources "/commissions", CommissionController, only: [:index]
resources "/galleries", GalleryController, only: [:index, :show]
resources "/galleries", GalleryController, only: [:index, :show] do
resources "/reports", Gallery.ReportController, only: [:new, :create]
end
resources "/adverts", AdvertController, only: [:show]
resources "/pages", PageController, only: [:show]
resources "/dnp", DnpEntryController, only: [:index, :show]

View file

@ -16,7 +16,7 @@ elixir:
= pagination
.flex__right
a href="#"
a href=Routes.gallery_report_path(@conn, :new, @gallery)
i.fa.fa-exclamation-triangle>
span.hide-mobile Report
@ -29,13 +29,13 @@ elixir:
a.rearrange-button.js-rearrange href='#' data-click-hide='.js-rearrange' data-click-show='.js-save,#gallery-rearrange-info'
i.fa.fa-sort>
' Rearrange
/data-reorder-path=gallery_order_path(@gallery)
a.rearrange-button.js-save.hidden href='#' data-click-hide='.js-save,#gallery-rearrange-info' data-click-show='.js-rearrange'
a.rearrange-button.js-save.hidden href='#' data-click-hide='.js-save,#gallery-rearrange-info' data-click-show='.js-rearrange' data-reorder-path=Routes.gallery_order_path(@conn, :update, @gallery)
i.fa.fa-check>
' Save
= if show_subscription_link?(@gallery.creator, @conn.assigns.current_user) do
/= subscription_link(@gallery, current_user)
= render PhilomenaWeb.Gallery.SubscriptionView, "_subscription.html", watching: @watching, gallery: @gallery, conn: @conn
.block__header.block__header--light.block__header--sub
span.block__header__title A gallery by

View file

@ -0,0 +1,2 @@
#js-subscription-target
' Error!

View file

@ -0,0 +1,23 @@
elixir:
watch_path = Routes.gallery_subscription_path(@conn, :create, @gallery)
watch_class = if @watching, do: "hidden", else: ""
unwatch_path = Routes.gallery_subscription_path(@conn, :delete, @gallery)
unwatch_class = if @watching, do: "", else: "hidden"
= if @conn.assigns.current_user do
span#js-subscription-target
a.js-subscription-link href=watch_path class=watch_class data-remote="true" data-method="post"
i.fa.fa-bell>
span.hide-mobile
' Subscribe
a.js-subscription-link href=unwatch_path class=unwatch_class data-remote="true" data-method="delete"
i.fa.fa-bell-slash>
span.hide-mobile
' Unsubscribe
- else
a href=Routes.pow_session_path(@conn, :new)
i.fa.fa-bell>
span.hide-mobile
' Subscribe

View file

@ -0,0 +1,39 @@
.dropdown.block__header__dropdown-tab
a href="#"
i.fa.fa-images>
span.hide-limited-desktop.hide-mobile Galleries
span data-click-preventdefault="true"
i.fa.fa-caret-down>
.dropdown__content.dropdown__content-right
.block
.block__content.add-to-gallery-list
.block__list
a.block__list__link.primary href=Routes.gallery_path(@conn, :index, include_image: @image.id)
i.fa.fa-table>
span.hide-mobile Featured in
= if Enum.any?(@image_galleries) or Enum.any?(@user_galleries) do
ul.block__list.js-gallery-list
= for gallery <- @user_galleries do
li id="gallery_#{gallery.id}"
a.block__list__link.js-gallery-add data-fetchcomplete-hide="#gallery_#{gallery.id} .js-gallery-add" data-fetchcomplete-show="#gallery_#{gallery.id} .js-gallery-remove" data-method="post" data-remote="true" href=Routes.gallery_image_path(@conn, :create, gallery, image_id: @image.id)
= gallery.title
a.block__list__link.active.js-gallery-remove.hidden data-fetchcomplete-hide="#gallery_#{gallery.id} .js-gallery-remove" data-fetchcomplete-show="#gallery_#{gallery.id} .js-gallery-add" data-method="delete" data-remote="true" href=Routes.gallery_image_path(@conn, :delete, gallery, image_id: @image.id)
= gallery.title
= for gallery <- @image_galleries do
li id="gallery_#{gallery.id}"
a.block__list__link.js-gallery-add.hidden data-fetchcomplete-hide="#gallery_#{gallery.id} .js-gallery-add" data-fetchcomplete-show="#gallery_#{gallery.id} .js-gallery-remove" data-method="post" data-remote="true" href=Routes.gallery_image_path(@conn, :create, gallery, image_id: @image.id)
= gallery.title
a.block__list__link.active.js-gallery-remove data-fetchcomplete-hide="#gallery_#{gallery.id} .js-gallery-remove" data-fetchcomplete-show="#gallery_#{gallery.id} .js-gallery-add" data-method="delete" data-remote="true" href=Routes.gallery_image_path(@conn, :delete, gallery, image_id: @image.id)
= gallery.title
.block__list
= if @conn.assigns.current_user do
a.block__list__link.primary href=Routes.gallery_path(@conn, :new, with_image: @image.id)
i.fa.fa-plus>
span.hide-limited-desktop.hide-mobile Create a gallery
- else
a.block__list__link.primary href=Routes.pow_registration_path(@conn, :new)
i.fa.fa-user-plus>
span.hide-limited-desktop.hide-mobile Register to create a gallery

View file

@ -33,6 +33,7 @@
i.fa.fa-eye-slash
.stretched-mobile-links
= render PhilomenaWeb.Image.SubscriptionView, "_subscription.html", watching: @watching, image: @image, conn: @conn
= render PhilomenaWeb.ImageView, "_add_to_gallery_dropdown.html", image: @image, image_galleries: @image_galleries, user_galleries: @user_galleries, conn: @conn
.stretched-mobile-links
a href="#{pretty_url(@image, false, false)}" rel="nofollow" title="View (tags in filename)"
i.fa.fa-eye>

View file

@ -1,4 +1,4 @@
= render PhilomenaWeb.ImageView, "_image_meta.html", image: @image, watching: @watching, conn: @conn
= render PhilomenaWeb.ImageView, "_image_meta.html", image: @image, watching: @watching, image_galleries: @image_galleries, user_galleries: @user_galleries, conn: @conn
= render PhilomenaWeb.ImageView, "_image_page.html", image: @image, conn: @conn
.layout--narrow

View file

@ -4,7 +4,13 @@
=> @notification.action
strong>
= link @notification.actor.title, to: "#"
/= link @notification.actor.title, to: Routes.gallery_path(@conn, :show, @notification.actor)
= link @notification.actor.title, to: Routes.gallery_path(@conn, :show, @notification.actor)
=> pretty_time @notification.updated_at
.flex.flex--centered.flex--no-wrap
a.button.button--separate-right title="Delete" href=Routes.gallery_read_path(@conn, :create, @notification.actor) data-method="post" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
i.fa.fa-trash
a.button title="Unsubscribe" href=Routes.gallery_subscription_path(@conn, :delete, @notification.actor) data-method="delete" data-remote="true" data-fetchcomplete-hide="#notification-#{@notification.id}"
i.fa.fa-bell-slash

View file

@ -0,0 +1,3 @@
defmodule PhilomenaWeb.Gallery.SubscriptionView do
use PhilomenaWeb, :view
end

View file

@ -5,6 +5,7 @@ defmodule PhilomenaWeb.ReportView do
alias Philomena.Comments.Comment
alias Philomena.Commissions.Commission
alias Philomena.Conversations.Conversation
alias Philomena.Galleries.Gallery
alias Philomena.Posts.Post
alias Philomena.Users.User
@ -52,6 +53,9 @@ defmodule PhilomenaWeb.ReportView do
def link_to_reported_thing(conn, %Commission{} = r),
do: link "#{r.user.name}'s commission page", to: Routes.profile_commission_path(conn, :show, r.user)
def link_to_reported_thing(conn, %Gallery{} = r),
do: link "Gallery '#{r.title}' by #{r.creator.name}", to: Routes.gallery_path(conn, :show, r)
def link_to_reported_thing(conn, %Post{} = r),
do: link "Post in #{r.topic.title}", to: Routes.forum_topic_path(conn, :show, r.topic.forum, r.topic, post_id: r.id) <> "#post_#{r.id}"