From e840ed26183fbe4b7a15e2a822587054ceae0f1d Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 6 Feb 2025 19:06:59 -0500 Subject: [PATCH] Move commission searching to context --- lib/philomena/commissions.ex | 35 +++++- lib/philomena/commissions/query_builder.ex | 110 ++++++++++++++++++ lib/philomena/commissions/search_query.ex | 19 +++ .../controllers/commission_controller.ex | 100 +++------------- .../commission/_directory_sidebar.html.slime | 8 +- .../templates/commission/index.html.slime | 5 +- 6 files changed, 184 insertions(+), 93 deletions(-) create mode 100644 lib/philomena/commissions/query_builder.ex create mode 100644 lib/philomena/commissions/search_query.ex diff --git a/lib/philomena/commissions.ex b/lib/philomena/commissions.ex index 4b42840b..e61955a3 100644 --- a/lib/philomena/commissions.ex +++ b/lib/philomena/commissions.ex @@ -8,6 +8,9 @@ defmodule Philomena.Commissions do alias Philomena.Repo alias Philomena.Commissions.Commission + alias Philomena.Commissions.Item + alias Philomena.Commissions.QueryBuilder + alias Philomena.Commissions.SearchQuery @doc """ Gets a single commission. @@ -90,7 +93,37 @@ defmodule Philomena.Commissions do Commission.changeset(commission, %{}) end - alias Philomena.Commissions.Item + @doc """ + Searches commissions based on the given parameters. + + ## Parameters + + * params - Map of optional search parameters: + * item_type - Filter by item type + * category - Filter by category + * keywords - Search in information and will_create fields + * price_min - Minimum base price + * price_max - Maximum base price + + Returns `{:ok, query}` with a queryable that can be used with Repo.paginate/2, + or `{:error, changeset}` if the provided parameters are invalid. + """ + def execute_search_query(params \\ %{}) do + QueryBuilder.search_commissions(params) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking search query changes. + + ## Examples + + iex> change_search_query(search_query) + %Ecto.Changeset{source: %SearchQuery{}} + + """ + def change_search_query(%SearchQuery{} = search_query) do + SearchQuery.changeset(search_query, %{}) + end @doc """ Gets a single item. diff --git a/lib/philomena/commissions/query_builder.ex b/lib/philomena/commissions/query_builder.ex new file mode 100644 index 00000000..3633e2d5 --- /dev/null +++ b/lib/philomena/commissions/query_builder.ex @@ -0,0 +1,110 @@ +defmodule Philomena.Commissions.QueryBuilder do + @moduledoc false + + alias Philomena.Commissions.Commission + alias Philomena.Commissions.Item + alias Philomena.Commissions.SearchQuery + alias Philomena.UserIps.UserIp + import Ecto.Query + + @doc """ + Searches commissions based on the given parameters. + + ## Parameters + + * params - Map of optional search parameters: + * item_type - Filter by item type + * category - Filter by category + * keywords - Search in information and will_create fields + * price_min - Minimum base price + * price_max - Maximum base price + + Returns `{:ok, query}` with a queryable that can be used with Repo.paginate/2, + or `{:error, changeset}` if the provided parameters are invalid. + """ + def search_commissions(params \\ %{}) do + %SearchQuery{} + |> SearchQuery.changeset(params) + |> Ecto.Changeset.apply_action(:create) + |> case do + {:ok, sq} -> + {:ok, + commission_search_query() + |> maybe_filter_price(sq) + |> maybe_filter_item_type(sq) + |> maybe_filter_categories(sq) + |> maybe_filter_keywords(sq)} + + {:error, changeset} -> + {:error, changeset} + end + end + + defp commission_search_query do + # Select commissions and all of their associated items for filtering + query = + from c in Commission, + as: :commission, + where: c.open == true, + where: c.commission_items_count > 0, + inner_join: ci in Item, + as: :commission_item, + on: ci.commission_id == c.id + + # Exclude artists with no activity in the last 2 weeks + query = + from [commission: c] in query, + inner_join: ui in UserIp, + as: :user_ip, + on: ui.user_id == c.user_id, + where: ui.updated_at >= ago(2, "week") + + # Select the parent commissions, not the items belonging to them + from [commission: c] in query, + group_by: c.id, + order_by: [asc: fragment("random()")], + preload: [user: [awards: :badge], items: [example_image: [:sources, tags: :aliases]]] + end + + defp maybe_filter_price(query, sq = %SearchQuery{}) do + if not is_nil(sq.price_min) and not is_nil(sq.price_max) do + from [commission_item: ci] in query, + where: ci.base_price >= ^sq.price_min and ci.base_price <= ^sq.price_max + else + query + end + end + + def maybe_filter_item_type(query, sq = %SearchQuery{}) do + if sq.item_type do + from [commission_item: ci] in query, + where: ci.item_type == ^sq.item_type + else + query + end + end + + defp maybe_filter_categories(query, sq = %SearchQuery{}) do + if sq.category do + from [commission: c] in query, + where: fragment("? @> ?", c.categories, ^sq.category) + else + query + end + end + + defp maybe_filter_keywords(query, sq = %SearchQuery{}) do + if sq.keywords do + keywords = like_sanitize(sq.keywords) + + from [commission: c] in query, + where: ilike(c.information, ^keywords) or ilike(c.will_create, ^keywords) + else + query + end + end + + defp like_sanitize(input) do + "%" <> String.replace(input, ["\\", "%", "_"], &<<"\\", &1>>) <> "%" + end +end diff --git a/lib/philomena/commissions/search_query.ex b/lib/philomena/commissions/search_query.ex new file mode 100644 index 00000000..f203c1cf --- /dev/null +++ b/lib/philomena/commissions/search_query.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Commissions.SearchQuery do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :item_type, :string + field :category, {:array, :string} + field :keywords, :string + field :price_min, :decimal + field :price_max, :decimal + end + + @doc false + def changeset(query, params) do + cast(query, params, [:item_type, :category, :keywords, :price_min, :price_max]) + end +end diff --git a/lib/philomena_web/controllers/commission_controller.ex b/lib/philomena_web/controllers/commission_controller.ex index 776c8e48..db60c312 100644 --- a/lib/philomena_web/controllers/commission_controller.ex +++ b/lib/philomena_web/controllers/commission_controller.ex @@ -1,105 +1,35 @@ defmodule PhilomenaWeb.CommissionController do use PhilomenaWeb, :controller - alias Philomena.Commissions.{Item, Commission} - alias Philomena.UserIps.UserIp + alias Philomena.Commissions.SearchQuery + alias Philomena.Commissions alias Philomena.Repo - import Ecto.Query plug PhilomenaWeb.MapParameterPlug, [param: "commission"] when action in [:index] plug :preload_commission def index(conn, params) do - commissions = - commission_search(params["commission"]) - |> Repo.paginate(conn.assigns.scrivener) + commission_params = Map.get(params, "commission", %{}) + + {commissions, changeset} = + case Commissions.execute_search_query(commission_params) do + {:ok, commissions} -> + commissions = Repo.paginate(commissions, conn.assigns.scrivener) + changeset = Commissions.change_search_query(%SearchQuery{}) + {commissions, changeset} + + {:error, changeset} -> + {[], changeset} + end render(conn, "index.html", title: "Commissions", commissions: commissions, + changeset: changeset, layout_class: "layout--wide" ) end - defp commission_search(attrs) when is_map(attrs) do - item_type = presence(attrs["item_type"]) - categories = presence(attrs["category"]) - keywords = presence(attrs["keywords"]) - price_min = to_f(presence(attrs["price_min"]) || 0) - price_max = to_f(presence(attrs["price_max"]) || 9999) - - query = - commission_search(nil) - |> where([_c, ci], ci.base_price > ^price_min and ci.base_price < ^price_max) - - query = - if item_type do - query - |> where([_c, ci], ci.item_type == ^item_type) - else - query - end - - query = - if categories do - query - |> where([c, _ci], fragment("? @> ?", c.categories, ^categories)) - else - query - end - - query = - if keywords do - query - |> where( - [c, _ci], - ilike(c.information, ^like_sanitize(keywords)) or - ilike(c.will_create, ^like_sanitize(keywords)) - ) - else - query - end - - query - end - - defp commission_search(_attrs) do - from c in Commission, - where: c.open == true, - where: c.commission_items_count > 0, - inner_join: ci in Item, - on: ci.commission_id == c.id, - inner_join: ui in UserIp, - on: ui.user_id == c.user_id, - where: ui.updated_at >= ago(2, "week"), - group_by: c.id, - order_by: [asc: fragment("random()")], - preload: [user: [awards: :badge], items: [example_image: [:sources, tags: :aliases]]] - end - - defp presence(nil), - do: nil - - defp presence([]), - do: nil - - defp presence(string) when is_binary(string), - do: if(String.trim(string) == "", do: nil, else: string) - - defp presence(object), - do: object - - defp to_f(input) do - case Float.parse(to_string(input)) do - {float, _rest} -> float - _error -> 0.0 - end - end - - defp like_sanitize(input) do - "%" <> String.replace(input, ["\\", "%", "_"], &<<"\\", &1>>) <> "%" - end - defp preload_commission(conn, _opts) do user = conn.assigns.current_user diff --git a/lib/philomena_web/templates/commission/_directory_sidebar.html.slime b/lib/philomena_web/templates/commission/_directory_sidebar.html.slime index 6bf4c0a2..58c86be1 100644 --- a/lib/philomena_web/templates/commission/_directory_sidebar.html.slime +++ b/lib/philomena_web/templates/commission/_directory_sidebar.html.slime @@ -2,14 +2,14 @@ .block__header span.block__header__title Search .block__content - = form_for @conn, ~p"/commissions", [as: :commission, method: "get", class: "hform"], fn f -> + = form_for @changeset, ~p"/commissions", [as: :commission, method: "get", class: "hform"], fn f -> .field = label f, :categories, "Art Categories:" - = for {name, value} <- categories() do + = for {name, _value} <- categories() do - checked = @conn.params["commission"]["category"] && Atom.to_string(name) in @conn.params["commission"]["category"] .field - => checkbox f, value, checked_value: name, checked: checked, name: "commission[category][]", class: "checkbox spacing-right", hidden_input: false - => label f, value, name + => checkbox f, name, checked_value: name, checked: checked, name: "commission[category][]", class: "checkbox spacing-right", hidden_input: false + => label f, name, name br diff --git a/lib/philomena_web/templates/commission/index.html.slime b/lib/philomena_web/templates/commission/index.html.slime index 0581d86c..a6dac5e2 100644 --- a/lib/philomena_web/templates/commission/index.html.slime +++ b/lib/philomena_web/templates/commission/index.html.slime @@ -7,12 +7,11 @@ h1 Commissions Directory ' commissions with potential commissioners. We don't have any way ' for users to make payments through the site, so we can't be held ' responsible for any issues regarding payment. - a href="/pages/rules#9" More info. br .column-layout .column-layout__left - = render PhilomenaWeb.CommissionView, "_directory_sidebar.html", conn: @conn + = render PhilomenaWeb.CommissionView, "_directory_sidebar.html", changeset: @changeset, conn: @conn .column-layout__main - = render PhilomenaWeb.CommissionView, "_directory_results.html", commissions: @commissions, conn: @conn \ No newline at end of file + = render PhilomenaWeb.CommissionView, "_directory_results.html", commissions: @commissions, conn: @conn