(ns metabase.notification.payload.execute (:require [malli.core :as mc] [medley.core :as m] [metabase.api.common :as api] [metabase.models.dashboard-card :as dashboard-card] [metabase.models.interface :as mi] [metabase.models.params.shared :as shared.params] [metabase.models.serialization :as serdes] [metabase.notification.payload.temp-storage :as notification.temp-storage] [metabase.public-settings :as public-settings] [metabase.query-processor :as qp] [metabase.query-processor.dashboard :as qp.dashboard] [metabase.query-processor.middleware.permissions :as qp.perms] [metabase.query-processor.pivot :as qp.pivot] [metabase.request.core :as request] [metabase.util :as u] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.urls :as urls] [toucan2.core :as t2])) | |
Check if the card is empty | (defn is-card-empty?
[card]
(let [result (:result card)]
(or
;; Text cards have no result; treat as empty
(nil? result)
(zero? (-> result :row_count))
;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters
(= [[nil]] (-> result :data :rows))))) |
(defn- tab->part
[{:keys [name]}]
{:text name
:type :tab-title}) | |
Check if a dashboard has more than 1 tab, and thus needs them to be rendered. We don't need to render the tab title if only 1 exists (issue #45123). | (defn- render-tabs? [dashboard-or-id] (< 1 (t2/count :model/DashboardTab :dashboard_id (u/the-id dashboard-or-id)))) |
Check if dashcard is a virtual with type There are currently 4 types of virtual card: "text", "action", "link", "placeholder". | (defn virtual-card-of-type?
[dashcard ttype]
(when (= ttype (get-in dashcard [:visualization_settings :virtual_card :display]))
dashcard)) |
For the specific case of Dashboard Subscriptions we should use | (defn- merge-default-values
[parameters]
(for [{default-value :default, :as parameter} parameters]
(merge
(when default-value
{:value default-value})
(dissoc parameter :default)))) |
(defn- link-card-entity->url
[{:keys [db_id id model] :as _entity}]
(case model
"card" (urls/card-url id)
"dataset" (urls/card-url id)
"collection" (urls/collection-url id)
"dashboard" (urls/dashboard-url id)
"database" (urls/database-url id)
"table" (urls/table-url db_id id))) | |
(defn- link-card->text-part
[{:keys [entity url] :as _link-card}]
(let [url-link-card? (some? url)]
{:text (str (format
"### [%s](%s)"
(if url-link-card? url (:name entity))
(if url-link-card? url (link-card-entity->url entity)))
(when-let [description (if url-link-card? nil (:description entity))]
(format "\n%s" description)))
:type :text})) | |
Convert a dashcard that is a link card to pulse part. This function should be executed under pulse's creator permissions. | (defn- dashcard-link-card->part
[dashcard]
(assert api/*current-user-id* "Makes sure you wrapped this with a `with-current-user`.")
(let [link-card (get-in dashcard [:visualization_settings :link])]
(cond
(some? (:url link-card))
(link-card->text-part link-card)
;; if link card link to an entity, update the setting because
;; the info in viz-settings might be out-of-date
(some? (:entity link-card))
(let [{:keys [model id]} (:entity link-card)
instance (t2/select-one
(serdes/link-card-model->toucan-model model)
(dashboard-card/link-card-info-query-for-model model id))]
(when (mi/can-read? instance)
(link-card->text-part (assoc link-card :entity instance))))))) |
(defn- escape-heading-markdown
[dashcard]
(if (= "heading" (get-in dashcard [:visualization_settings :virtual_card :display]))
;; If there's no heading text, the heading is empty, so we return nil.
(when (get-in dashcard [:visualization_settings :text])
(update-in dashcard [:visualization_settings :text]
#(str "## " %)))
dashcard)) | |
Heading cards should not escape characters. | (defn- escape-markdown-chars? [dashcard] (not= "heading" (get-in dashcard [:visualization_settings :virtual_card :display]))) |
Given a dashcard and the parameters on a dashboard, returns the dashcard with any parameter values appropriately substituted into connected variables in the text. | (defn process-virtual-dashcard
[dashcard parameters]
(let [text (-> dashcard :visualization_settings :text)
parameter-mappings (:parameter_mappings dashcard)
tag-names (shared.params/tag_names text)
param-id->param (into {} (map (juxt :id identity) parameters))
tag-name->param-id (into {} (map (juxt (comp second :target) :parameter_id) parameter-mappings))
tag->param (reduce (fn [m tag-name]
(when-let [param-id (get tag-name->param-id tag-name)]
(assoc m tag-name (get param-id->param param-id))))
{}
tag-names)]
(update-in dashcard [:visualization_settings :text] shared.params/substitute-tags tag->param (public-settings/site-locale) (escape-markdown-chars? dashcard)))) |
(defn- data-rows-to-disk! [qp-result] (update-in qp-result [:data :rows] notification.temp-storage/to-temp-file!)) | |
Returns subscription result for a card. This function should be executed under pulse's creator permissions. | (defn execute-dashboard-subscription-card
[{:keys [card_id dashboard_id] :as dashcard} parameters]
(try
(when-let [card (t2/select-one :model/Card :id card_id :archived false)]
(let [multi-cards (dashboard-card/dashcard->multi-cards dashcard)
result-fn (fn [card-id]
{:card (if (= card-id (:id card))
card
(t2/select-one :model/Card :id card-id))
:dashcard dashcard
;; TODO should this be dashcard?
:type :card
:result (qp.dashboard/process-query-for-dashcard
:dashboard-id dashboard_id
:card-id card-id
:dashcard-id (u/the-id dashcard)
:context :dashboard-subscription
:export-format :api
:parameters parameters
:constraints {}
:middleware {:process-viz-settings? true
:js-int-to-string? false
:add-default-userland-constraints? false}
:make-run (fn make-run [qp _export-format]
(^:once fn* [query info]
(qp
(qp/userland-query query info)
nil))))})
result (result-fn card_id)
series-results (mapv (comp result-fn :id) multi-cards)]
(when-not (and (get-in dashcard [:visualization_settings :card.hide_empty])
(is-card-empty? (assoc card :result (:result result))))
(update result :dashcard assoc :series-results series-results))))
(catch Throwable e
(log/warnf e "Error running query for Card %s" (:card_id dashcard))))) |
Given a dashcard returns its part based on its type. The result will follow the pulse's creator permissions. | (defn- dashcard->part
[dashcard parameters]
(assert api/*current-user-id* "Makes sure you wrapped this with a `with-current-user`.")
(cond
(:card_id dashcard)
(let [parameters (merge-default-values parameters)]
;; only do this for dashboard subscriptions but not alerts since alerts has only one card, which doesn't eat much
;; memory
(m/update-existing (execute-dashboard-subscription-card dashcard parameters) :result data-rows-to-disk!))
(virtual-card-of-type? dashcard "iframe")
nil
(virtual-card-of-type? dashcard "action")
nil
(virtual-card-of-type? dashcard "link")
(dashcard-link-card->part dashcard)
(virtual-card-of-type? dashcard "placeholder")
nil
;; text cards have existed for a while and I'm not sure if all existing text cards
;; will have virtual_card.display = "text", so assume everything else is a text card
:else
(let [parameters (merge-default-values parameters)]
(some-> dashcard
(process-virtual-dashcard parameters)
escape-heading-markdown
:visualization_settings
(assoc :type :text))))) |
(defn- dashcards->part
[dashcards parameters]
(let [ordered-dashcards (sort dashboard-card/dashcard-comparator dashcards)]
(doall (keep #(dashcard->part % parameters) ordered-dashcards)))) | |
(mr/def ::Part
"Part."
[:multi {:dispatch :type}
[:card [:map {:closed true}
[:type [:= :card]]
[:card :map]
[:result [:maybe :map]]
[:dashcard {:optional true} [:maybe :map]]]]
[:text [:map
[:text :string]
[:type [:= :text]]]]
[:tab-title [:map {:closed true}
[:text :string]
[:type [:= :tab-title]]]]
[::mc/default :map]]) | |
(mu/defn execute-dashboard :- [:sequential ::Part]
"Execute a dashboard and return its parts."
[dashboard-id user-id parameters]
(request/with-current-user user-id
(if (render-tabs? dashboard-id)
(let [tabs (t2/hydrate (t2/select :model/DashboardTab :dashboard_id dashboard-id) :tab-cards)
tabs-with-cards (filter #(seq (:cards %)) tabs)
should-render-tab? (< 1 (count tabs-with-cards))]
(doall (flatten (for [{:keys [cards] :as tab} tabs-with-cards]
(concat
(when should-render-tab?
[(tab->part tab)])
(dashcards->part cards parameters))))))
(dashcards->part (t2/select :model/DashboardCard :dashboard_id dashboard-id) parameters)))) | |
TODO - this should be done async
TODO - this and | (mu/defn execute-card :- [:maybe ::Part]
"Returns the result for a card."
[creator-id :- pos-int?
card-id :- pos-int?
& {:as options}]
(try
(when-let [{query :dataset_query
metadata :result_metadata
card-type :type
:as card} (t2/select-one :model/Card :id card-id, :archived false)]
(let [query (assoc query :async? false)
process-fn (if (= :pivot (:display card))
qp.pivot/run-pivot-query
qp/process-query)
process-query (fn []
(binding [qp.perms/*card-id* card-id]
(process-fn
(qp/userland-query
(assoc query
:middleware {:skip-results-metadata? false
:process-viz-settings? true
:js-int-to-string? false
:add-default-userland-constraints? false})
(merge (cond-> {:executed-by creator-id
:context :pulse
:card-id card-id}
(= card-type :model)
(assoc :metadata/model-metadata metadata))
{:visualization-settings (:visualization_settings card)}
options)))))
result (if creator-id
(request/with-current-user creator-id
(process-query))
(process-query))]
{:card card
:result result
:type :card}))
(catch Throwable e
(log/warnf e "Error running query for Card %s" card-id)))) |