(ns metabase.models.dashboard-card (:require [clojure.set :as set] [medley.core :as m] [metabase.db :as mdb] [metabase.db.query :as mdb.query] [metabase.models.interface :as mi] [metabase.models.serialization :as serdes] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [methodical.core :as methodical] [toucan2.core :as t2])) | |
(methodical/defmethod t2/table-name :model/DashboardCard [_model] :report_dashboardcard) | |
(doto :model/DashboardCard (derive :metabase/model) (derive ::mi/read-policy.full-perms-for-perms-set) (derive ::mi/write-policy.full-perms-for-perms-set) (derive :hook/timestamped?) (derive :hook/entity-id) ;; Disabled for performance reasons, see update-dashboard-card!-call-count-test #_(derive :hook/search-index)) | |
(t2/deftransforms :model/DashboardCard {:parameter_mappings mi/transform-parameters-list :visualization_settings mi/transform-visualization-settings}) | |
(t2/define-before-insert :model/DashboardCard [dashcard] (merge {:parameter_mappings [] :visualization_settings {}} dashcard)) | |
(declare series) | |
Return the set of permissions required to | (defmethod mi/perms-objects-set :model/DashboardCard [dashcard read-or-write] (let [card (or (:card dashcard) (t2/select-one [:model/Card :dataset_query] :id (u/the-id (:card_id dashcard)))) series (or (:series dashcard) (series dashcard))] (apply set/union (mi/perms-objects-set card read-or-write) (for [series-card series] (mi/perms-objects-set series-card read-or-write))))) |
Convert a map with dashboard-card into a Toucan instance assuming it came from parsed JSON and the map keys have
been keywordized. This is useful if the data from a request body inside a For example: ``` (= dashcard ;; from toucan select, excluding :createdat and :updatedat (-> (json/encode dashcard) json/decode+kw from-parsed-json)) => true ``` | (defn from-parsed-json [dashboard-card] (t2/instance :model/DashboardCard (-> dashboard-card (m/update-existing :parameter_mappings mi/normalize-parameters-list) (m/update-existing :visualization_settings mi/normalize-visualization-settings)))) |
(defmethod serdes/hash-fields :model/DashboardCard [_dashboard-card] [(serdes/hydrated-hash :card) ; :card is optional, eg. text cards (comp serdes/identity-hash #(t2/select-one 'Dashboard :id %) :dashboard_id) :visualization_settings :row :col :created_at]) | |
--------------------------------------------------- HYDRATION ---------------------------------------------------- | |
(mi/define-batched-hydration-method series :series "Return the `Cards` associated as additional series on this DashboardCard." [dashcards] (when (seq dashcards) (let [dashcard-ids (map :id dashcards) dashcard-id->series (when (seq dashcard-ids) (as-> (t2/select [:model/Card :id :name :description :display :dataset_query :type :database_id :visualization_settings :collection_id :series.dashboardcard_id] {:left-join [[:dashboardcard_series :series] [:= :report_card.id :series.card_id]] :where [:in :series.dashboardcard_id dashcard-ids] :order-by [[:series.position :asc]]}) series (group-by :dashboardcard_id series) (update-vals series #(map (fn [card] (dissoc card :dashboardcard_id)) %))))] (map (fn [dashcard] (assoc dashcard :series (get dashcard-id->series (:id dashcard) []))) dashcards)))) | |
---------------------------------------------------- CRUD FNS ---------------------------------------------------- | |
Fetch a single DashboardCard by its ID value. | (mu/defn retrieve-dashboard-card [id :- ms/PositiveInt] (-> (t2/select-one :model/DashboardCard :id id) (t2/hydrate :series))) |
Return the cards which have been added to this dashcard using the 'add series' dashboard feature. Dashboards allow Line, Area, and Bar dashcards to have other questions (series) added to them so that several Questions are displayed in a single dashcard. It's important to know that this is different from the combo display, which is its own visualization type for Questions. This is also different from having multiple series displayed on Line, Area, or Bar Questions. | (defn dashcard->multi-cards [dashcard] (mdb.query/query {:select [:newcard.*] :from [[:report_dashboardcard :dashcard]] :left-join [[:dashboardcard_series :dashcardseries] [:= :dashcard.id :dashcardseries.dashboardcard_id] [:report_card :newcard] [:= :dashcardseries.card_id :newcard.id]] :where [:and [:= :newcard.archived false] [:= :dashcard.id (:id dashcard)]]})) |
Batch update the DashboardCardSeries for multiple DashboardCards.
Each
| (defn update-dashboard-cards-series! {:arglists '([dashcard-id->card-ids])} [dashcard-id->card-ids] (when (seq dashcard-id->card-ids) ;; first off, just delete all series on the dashboard card (we add them again below) (t2/delete! :model/DashboardCardSeries :dashboardcard_id [:in (keys dashcard-id->card-ids)]) ;; now just insert all of the series that were given to us (when-let [card-series (seq (for [[dashcard-id card-ids] dashcard-id->card-ids [i card-id] (map-indexed vector card-ids)] {:dashboardcard_id dashcard-id, :card_id card-id, :position i}))] (t2/insert! :model/DashboardCardSeries card-series)))) |
(def ^:private DashboardCardUpdates [:map [:id ms/PositiveInt] [:action_id {:optional true} [:maybe ms/PositiveInt]] [:parameter_mappings {:optional true} [:maybe [:sequential :map]]] [:visualization_settings {:optional true} [:maybe :map]] ;; series is a sequence of IDs of additional cards after the first to include as "additional serieses" [:series {:optional true} [:maybe [:sequential ms/PositiveInt]]]]) | |
Returns the keys in | (defn- shallow-updates [new old] (into {} (filter (fn [[k v]] (not= v (get old k))) new))) |
Updates an existing DashboardCard including all DashboardCardSeries.
| (mu/defn update-dashboard-card! [{dashcard-id :id :keys [series] :as dashboard-card} :- DashboardCardUpdates old-dashboard-card :- DashboardCardUpdates] (t2/with-transaction [_conn] (let [update-ks [:action_id :card_id :row :col :size_x :size_y :parameter_mappings :visualization_settings :dashboard_tab_id] updates (shallow-updates (select-keys dashboard-card update-ks) (select-keys old-dashboard-card update-ks))] (when (seq updates) (t2/update! :model/DashboardCard dashcard-id updates)) (when (not= (:series dashboard-card []) (:series old-dashboard-card [])) (update-dashboard-cards-series! {dashcard-id series})) nil))) |
Schema for a parameter mapping as it would appear in the DashboardCard | (def ParamMapping [:and [:map-of :keyword :any] [:map ;; TODO -- validate `:target` as well... breaks a few tests tho so those will have to be fixed (#40021) [:parameter_id ms/NonBlankString] #_[:target :any]]]) |
(def ^:private NewDashboardCard ;; TODO - make the rest of the options explicit instead of just allowing whatever for other keys (#40021) [:map [:dashboard_id ms/PositiveInt] [:action_id {:optional true} [:maybe ms/PositiveInt]] ;; TODO - use ParamMapping. Breaks too many tests right now tho (#40021) [:parameter_mappings {:optional true} [:maybe [:sequential map?]]] [:visualization_settings {:optional true} [:maybe map?]] [:series {:optional true} [:maybe [:sequential ms/PositiveInt]]]]) | |
Create a new DashboardCard by inserting it into the database along with all associated pieces of data such as DashboardCardSeries. Returns the newly created DashboardCard or throws an Exception. | (mu/defn create-dashboard-cards! [dashboard-cards :- [:sequential NewDashboardCard]] (when (seq dashboard-cards) (t2/with-transaction [_conn] (let [dashboard-card-ids (t2/insert-returning-pks! :model/DashboardCard (for [dashcard dashboard-cards] (merge {:parameter_mappings [] :visualization_settings {}} (dissoc dashcard :id :created_at :updated_at :entity_id :series :card :collection_authority_level))))] ;; add series to the DashboardCard (update-dashboard-cards-series! (zipmap dashboard-card-ids (map #(get % :series []) dashboard-cards))) ;; return the full DashboardCard (-> (t2/select :model/DashboardCard :id [:in dashboard-card-ids]) (t2/hydrate :series)))))) |
Delete DashboardCards of a Dasbhoard. | (defn delete-dashboard-cards! [dashboard-card-ids] {:pre [(coll? dashboard-card-ids)]} (t2/with-transaction [_conn] (t2/delete! :model/PulseCard :dashboard_card_id [:in dashboard-card-ids]) (t2/delete! :model/DashboardCard :id [:in dashboard-card-ids]))) |
----------------------------------------------- Link cards ---------------------------------------------------- | |
(def ^:private all-card-info-columns {:model :text :id :integer :name :text :description :text ;; for cards and datasets :collection_id :integer :display :text ;; for tables :db_id :integer}) | |
(def ^:private link-card-columns-for-model {"database" [:id :name :description] "table" [:id [:display_name :name] :description :db_id] "dashboard" [:id :name :description :collection_id] "card" [:id :name :description :collection_id :display] "dataset" [:id :name :description :collection_id :display] "collection" [:id :name :description]}) | |
Returns the column name. If the column is aliased, i.e. [ | (defn- ->column-alias [column-or-aliased] (if (sequential? column-or-aliased) (second column-or-aliased) column-or-aliased)) |
The search query uses a | (defn- select-clause-for-link-card-model [model] (let [model-cols (link-card-columns-for-model model) model-col-alias->honeysql-clause (m/index-by ->column-alias model-cols)] (for [[col col-type] all-card-info-columns :let [maybe-aliased-col (get model-col-alias->honeysql-clause col)]] (cond (= col :model) [(h2x/literal model) :model] maybe-aliased-col maybe-aliased-col ;; This entity is missing the column, project a null for that column value. For Postgres and H2, cast it to the ;; correct type, e.g. ;; ;; SELECT cast(NULL AS integer) ;; ;; For MySQL, this is not needed. :else [(when-not (= (mdb/db-type) :mysql) [:cast nil col-type]) col])))) |
(def ^:private link-card-models (set (keys serdes/link-card-model->toucan-model))) | |
Return a honeysql query that is used to fetch info for a linkcard. | (defn link-card-info-query-for-model [model id-or-ids] {:select (select-clause-for-link-card-model model) :from (t2/table-name (serdes/link-card-model->toucan-model model)) :where (if (coll? id-or-ids) [:in :id id-or-ids] [:= :id id-or-ids])}) |
(defn- link-card-info-query [link-card-model->ids] (if (= 1 (count link-card-model->ids)) (apply link-card-info-query-for-model (first link-card-model->ids)) {:select [:*] :from [[{:union-all (map #(apply link-card-info-query-for-model %) link-card-model->ids)} :alias_is_required_by_sql_but_not_needed_here]]})) | |
(mi/define-batched-hydration-method dashcard-linkcard-info :dashcard/linkcard-info "Update entity info for link cards. Link cards are dashcards that link to internal entities like Database/Dashboard/... or an url. The viz-settings only store the model name and id, info like name, description will need to be hydrated on fetch to make sure those info are up-to-date." [dashcards] (let [entity-path [:visualization_settings :link :entity] ;; find all dashcards that are link-cards and get its model, id ;; [[:table #{1 2}] [:database #{3 4}]] model-and-ids (->> dashcards (map #(get-in % entity-path)) (filter #(link-card-models (:model %))) (group-by :model) (map (fn [[k v]] [k (set (map :id v))])))] (if (seq model-and-ids) (let [;; query all entities in 1 db call ;; {[:table 3] {:name ...}} model-and-id->info (-> (m/index-by (juxt :model :id) (t2/query (link-card-info-query model-and-ids))) (update-vals (fn [{model :model :as instance}] (if (mi/can-read? (t2/instance (serdes/link-card-model->toucan-model model) instance)) instance {:restricted true}))))] (map (fn [card] (if-let [model-info (->> (get-in card entity-path) ((juxt :model :id)) (get model-and-id->info))] (assoc-in card entity-path model-info) card)) dashcards)) dashcards))) | |
Comparator that determines which of two dashcards comes first in the layout order used for pulses. This is the same order used on the frontend for the mobile layout. Orders cards left-to-right, then top-to-bottom | (defn dashcard-comparator [{row-1 :row col-1 :col} {row-2 :row col-2 :col}] (if (= row-1 row-2) (compare col-1 col-2) (compare row-1 row-2))) |
----------------------------------------------- SERIALIZATION ---------------------------------------------------- | |
(defmethod serdes/generate-path "DashboardCard" [_ dashcard] (remove nil? [(serdes/infer-self-path "Dashboard" (t2/select-one 'Dashboard :id (:dashboard_id dashcard))) (when (:dashboard_tab_id dashcard) (serdes/infer-self-path "DashboardTab" (t2/select-one :model/DashboardTab :id (:dashboard_tab_id dashcard)))) (serdes/infer-self-path "DashboardCard" dashcard)])) | |
(defmethod serdes/make-spec "DashboardCard" [_model-name opts] {:copy [:col :entity_id :row :size_x :size_y] :skip [] :transform {:created_at (serdes/date) :dashboard_id (serdes/parent-ref) :card_id (serdes/fk :model/Card) :action_id (serdes/fk :model/Action) :dashboard_tab_id (serdes/fk :model/DashboardTab) :parameter_mappings {:export serdes/export-parameter-mappings :import serdes/import-parameter-mappings} :visualization_settings {:export serdes/export-visualization-settings :import serdes/import-visualization-settings} :series (serdes/nested :model/DashboardCardSeries :dashboardcard_id (assoc opts :sort-by :position :key-field :card_id))}}) | |