/api/dashboard endpoints. | (ns metabase.api.dashboard (:require [clojure.core.cache :as cache] [clojure.core.memoize :as memoize] [clojure.set :as set] [compojure.core :refer [DELETE GET POST PUT]] [medley.core :as m] [metabase.actions.core :as actions] [metabase.analytics.snowplow :as snowplow] [metabase.api.collection :as api.collection] [metabase.api.common :as api] [metabase.api.common.validation :as validation] [metabase.api.dataset :as api.dataset] [metabase.api.query-metadata :as api.query-metadata] [metabase.db.query :as mdb.query] [metabase.email.messages :as messages] [metabase.events :as events] [metabase.legacy-mbql.normalize :as mbql.normalize] [metabase.legacy-mbql.schema :as mbql.s] [metabase.legacy-mbql.util :as mbql.u] [metabase.lib.metadata.jvm :as lib.metadata.jvm] [metabase.lib.schema.parameter :as lib.schema.parameter] [metabase.lib.util.match :as lib.util.match] [metabase.models.action :as action] [metabase.models.card :as card :refer [Card]] [metabase.models.collection :as collection] [metabase.models.collection.root :as collection.root] [metabase.models.dashboard :as dashboard :refer [Dashboard]] [metabase.models.dashboard-card :as dashboard-card :refer [DashboardCard]] [metabase.models.dashboard-tab :as dashboard-tab] [metabase.models.data-permissions :as data-perms] [metabase.models.field :refer [Field]] [metabase.models.interface :as mi] [metabase.models.params :as params] [metabase.models.params.chain-filter :as chain-filter] [metabase.models.params.custom-values :as custom-values] [metabase.models.pulse :as models.pulse] [metabase.models.query :refer [Query]] [metabase.models.query.permissions :as query-perms] [metabase.models.revision :as revision] [metabase.models.revision.last-edit :as last-edit] [metabase.models.table :refer [Table]] [metabase.query-processor.dashboard :as qp.dashboard] [metabase.query-processor.error-type :as qp.error-type] [metabase.query-processor.middleware.constraints :as qp.constraints] [metabase.query-processor.middleware.permissions :as qp.perms] [metabase.query-processor.pivot :as qp.pivot] [metabase.query-processor.util :as qp.util] [metabase.related :as related] [metabase.request.core :as request] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.i18n :refer [deferred-tru tru]] [metabase.util.json :as json] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [metabase.xrays :as xrays] [steffan-westcott.clj-otel.api.trace.span :as span] [toucan2.core :as t2])) |
(set! *warn-on-reflection* true) | |
(defn- dashboards-list [filter-option] (as-> (t2/select :model/Dashboard {:where [:and (case (or (keyword filter-option) :all) (:all :archived) true :mine [:= :creator_id api/*current-user-id*]) [:= :archived (= (keyword filter-option) :archived)]] :order-by [:%lower.name]}) <> (t2/hydrate <> :creator) (filter mi/can-read? <>))) | |
/ | (api/defendpoint ^:deprecated GET "This endpoint is currently unused by the Metabase frontend and may be out of date with the rest of the application. It only exists for backwards compatibility and may be removed in the future. Get `Dashboards`. With filter option `f` (default `all`), restrict results as follows: * `all` - Return all Dashboards. * `mine` - Return Dashboards created by the current user. * `archived` - Return Dashboards that have been archived. (By default, these are *excluded*.)" [f] {f [:maybe [:enum "all" "mine" "archived"]]} (let [dashboards (dashboards-list f) edit-infos (:dashboard (last-edit/fetch-last-edited-info {:dashboard-ids (map :id dashboards)}))] (into [] (map (fn [{:keys [id] :as dashboard}] (if-let [edit-info (get edit-infos id)] (assoc dashboard :last-edit-info edit-info) dashboard))) dashboards))) |
Get dashboard details for the complete dashboard, including tabs, dashcards, params, etc. | (defn- hydrate-dashboard-details [{dashboard-id :id :as dashboard}] ;; I'm a bit worried that this is an n+1 situation here. The cards can be batch hydrated i think because they ;; have a hydration key and an id. moderation_reviews currently aren't batch hydrated but i'm worried they ;; cannot be in this situation (span/with-span! {:name "hydrate-dashboard-details" :attributes {:dashboard/id dashboard-id}} (binding [params/*field-id-context* (atom params/empty-field-id-context)] (t2/hydrate dashboard [:dashcards ;; disabled :can_run_adhoc_query for performance reasons in 50 release [:card :can_write #_:can_run_adhoc_query [:moderation_reviews :moderator_details]] [:series :can_write #_:can_run_adhoc_query] :dashcard/action :dashcard/linkcard-info] :can_restore :can_delete :last_used_param_values :tabs :collection_authority_level :can_write :param_fields :param_values [:moderation_reviews :moderator_details] [:collection :is_personal :effective_location])))) |
/ | (api/defendpoint POST "Create a new Dashboard." [:as {{:keys [name description parameters cache_ttl collection_id collection_position], :as _dashboard} :body}] {name ms/NonBlankString parameters [:maybe [:sequential ms/Parameter]] description [:maybe :string] cache_ttl [:maybe ms/PositiveInt] collection_id [:maybe ms/PositiveInt] collection_position [:maybe ms/PositiveInt]} ;; if we're trying to save the new dashboard in a Collection make sure we have permissions to do that (collection/check-write-perms-for-collection collection_id) (let [dashboard-data {:name name :description description :parameters (or parameters []) :creator_id api/*current-user-id* :cache_ttl cache_ttl :collection_id collection_id :collection_position collection_position} dash (t2/with-transaction [_conn] ;; Adding a new dashboard at `collection_position` could cause other dashboards in this collection to change ;; position, check that and fix up if needed (api/maybe-reconcile-collection-position! dashboard-data) ;; Ok, now save the Dashboard (first (t2/insert-returning-instances! :model/Dashboard dashboard-data)))] (events/publish-event! :event/dashboard-create {:object dash :user-id api/*current-user-id*}) (snowplow/track-event! ::snowplow/dashboard {:event :dashboard-created :dashboard-id (u/the-id dash)}) (-> dash hydrate-dashboard-details collection.root/hydrate-root-collection (assoc :last-edit-info (last-edit/edit-information-for-user @api/*current-user*))))) |
-------------------------------------------- Hiding Unreadable Cards --------------------------------------------- | |
If CARD is unreadable, replace it with an object containing only its | (defn- hide-unreadable-card [card] (when card (if (mi/can-read? card) card (select-keys card [:id])))) |
Replace the | (defn- hide-unreadable-cards [dashboard] (update dashboard :dashcards (fn [dashcards] (vec (for [dashcard dashcards] (-> dashcard (update :card hide-unreadable-card) (update :series (partial mapv hide-unreadable-card)))))))) |
------------------------------------------ Query Average Duration Info ------------------------------------------- | |
Adding the average execution time to all of the Cards in a Dashboard efficiently is somewhat involved. There are a few things that make this tricky:
Here's an overview of the approach used to efficiently add the info:
| |
Return a tuple of possible hashes that would be associated with executions of CARD. The first is the hash of the
query dictionary as-is; the second is one with the Returns nil if | (defn- card->query-hashes [{:keys [dataset_query]}] (when dataset_query (u/ignore-exceptions [(qp.util/query-hash dataset_query) (qp.util/query-hash (assoc dataset_query :constraints (qp.constraints/default-query-constraints)))]))) |
Return a sequence of all the query hashes for this | (defn- dashcard->query-hashes [{:keys [card series]}] (reduce concat (card->query-hashes card) (for [card series] (card->query-hashes card)))) |
Return a sequence of all the query hashes used in a | (defn- dashcards->query-hashes [dashcards] (apply concat (for [dashcard dashcards] (dashcard->query-hashes dashcard)))) |
Given some query | (defn- hashes->hash-vec->avg-time [hashes] (when (seq hashes) (into {} (for [[k v] (t2/select-fn->fn :query_hash :average_execution_time Query :query_hash [:in hashes])] {(vec k) v})))) |
Add | (defn- add-query-average-duration-to-card [card hash-vec->avg-time] (assoc card :query_average_duration (some (fn [query-hash] (hash-vec->avg-time (vec query-hash))) (card->query-hashes card)))) |
Add | (defn- add-query-average-duration-to-dashcards ([dashcards] (add-query-average-duration-to-dashcards dashcards (hashes->hash-vec->avg-time (dashcards->query-hashes dashcards)))) ([dashcards hash-vec->avg-time] (for [dashcard dashcards] (-> dashcard (update :card add-query-average-duration-to-card hash-vec->avg-time) (update :series (fn [series] (for [card series] (add-query-average-duration-to-card card hash-vec->avg-time)))))))) |
Add a | (defn add-query-average-durations [dashboard] (update dashboard :dashcards add-query-average-duration-to-dashcards)) |
Using 10 seconds for the cache TTL. Dashboard load cachingWhen the FE loads a dashboard, there is a burst of requests sent to the BE: - One /api/dashboard/:id - One /api/dashboard/:id/query_metadata - Many /:dashboard-id/dashcard/:dashcard-id/card/:card-id/query Each of these needs some metadata from the appdb: to hydrate the dashboard, get the query_metadata, and to run the query processor over all the dashcards. That leads to a lot of re-fetching of the same information from the appdb, and is a great opportunity for caching. To connect the dots across these N+2 HTTP requests, the FE attaches a Why not cache on dashboard ID?There may be different users with different permissions fetching the same dashboard at the same time. They see a different picture of the queries and their metadata, so must be fetched separately. | (def ^:private dashboard-load-cache-ttl (* 10 1000)) |
(def ^:private ^:dynamic *dashboard-load-id* nil) | |
This is a kind of two-layer memoization: - The outer layer is a 10-second TTL cache on dashboard-load-id. - Its value is the function to use to get the dashboard by ID! If dashboard-load-id is set, the outer layer returns a forever-memoized wrapper around get-dashboard*. If dashboard-load-id is nil, it returns the unwrapped get-dashboard*. | |
Get Dashboard with ID. TODO: This indirect memoization by dashboard-load-id could probably be turned into a macro for reuse elsewhere. | (defn- get-dashboard* [id] (span/with-span! {:name "get-dashboard" :attributes {:dashboard/id id}} (-> (t2/select-one :model/Dashboard :id id) api/read-check hydrate-dashboard-details collection.root/hydrate-root-collection hide-unreadable-cards add-query-average-durations (api/present-in-trash-if-archived-directly (collection/trash-collection-id))))) |
(def ^:private get-dashboard-fn (memoize/ttl (fn [dashboard-load-id] (if dashboard-load-id (memoize/memo get-dashboard*) ; If dashboard-load-id is set, return a memoized get-dashboard*. get-dashboard*)) ; If unset, just call through to get-dashboard*. :ttl/threshold dashboard-load-cache-ttl)) | |
(def ^:private dashboard-load-metadata-provider-cache (memoize/ttl (fn [_dashboard-load-id] (atom (cache/basic-cache-factory {}))) :ttl/threshold dashboard-load-cache-ttl)) | |
(defn- do-with-dashboard-load-id [dashboard-load-id body-fn] (if dashboard-load-id (binding [*dashboard-load-id* dashboard-load-id lib.metadata.jvm/*metadata-provider-cache* (dashboard-load-metadata-provider-cache dashboard-load-id)] (log/debugf "Using dashboard_load_id %s" dashboard-load-id) (body-fn)) (do (log/debug "No dashboard_load_id provided") (body-fn)))) | |
(defmacro ^:private with-dashboard-load-id [dashboard-load-id & body] `(do-with-dashboard-load-id ~dashboard-load-id (^:once fn* [] ~@body))) | |
Get Dashboard with ID. Memoized per | (defn- get-dashboard [id] ((get-dashboard-fn *dashboard-load-id*) id)) |
(mu/defn- cards-to-copy :- [:map [:discard [:sequential :any]] [:copy [:map-of ms/PositiveInt :any]] [:reference [:map-of ms/PositiveInt :any]]] "Returns a map of which cards we need to copy, which cards we need to reference, and which are not to be copied. The `:copy` and `:reference` keys are maps from id to card. The `:discard` key is a vector of cards which were not copied due to permissions. If we're making a deep copy, we copy all cards that we have necessary permissions on. Otherwise, we copy Dashboard Questions (questions stored 'in' the dashboard rather than a collection) and reference the rest (assuming permissions)." [deep-copy? :- ms/MaybeBooleanValue dashcards :- [:sequential :any]] (let [card->cards (fn [{:keys [card series]}] (into [card] series)) readable? (fn [card] (and (mi/model card) (mi/can-read? card))) card->decision (fn [parent-card card] (cond (or (not (readable? parent-card)) (not (readable? card))) :discard (or (:dashboard_id card) (and deep-copy? (not= :model (:type card)))) :copy :else :reference)) split-cards (fn [{:keys [card] :as db-card}] (let [cards (card->cards db-card)] (group-by (partial card->decision card) cards)))] (reduce (fn [acc db-card] (let [{:keys [discard copy reference]} (split-cards db-card)] (-> acc (update :reference merge (m/index-by :id reference)) (update :copy merge (m/index-by :id copy)) (update :discard concat discard)))) {:reference {} :copy {} :discard []} dashcards))) | |
Takes a dashboard id, and duplicates the cards both on the dashboard's cards and dashcardseries as necessary. Returns a map of {:copied {old-card-id duplicated-card} :uncopied [card]} so that the new dashboard can adjust accordingly. If | (defn- maybe-duplicate-cards [deep-copy? new-dashboard old-dashboard dest-coll-id] (let [same-collection? (= (:collection_id old-dashboard) dest-coll-id) {:keys [copy discard reference]} (cards-to-copy deep-copy? (:dashcards old-dashboard))] {:copied (into {} (for [[id to-copy] copy] [id (card/create-card! (cond-> to-copy true (assoc :collection_id dest-coll-id) same-collection? (update :name #(str % " - " (tru "Duplicate"))) (:dashboard_id to-copy) (assoc :dashboard_id (u/the-id new-dashboard))) @api/*current-user* ;; creating cards from a transaction. wait until tx complete to signal event true ;; do not autoplace these cards. we will create the dashboard cards ourselves. false)])) :discarded discard :referenced reference})) |
(defn- duplicate-tabs [new-dashboard existing-tabs] (let [new-tab-ids (t2/insert-returning-pks! :model/DashboardTab (for [tab existing-tabs] (-> tab (assoc :dashboard_id (:id new-dashboard)) (dissoc :id :entity_id :created_at :updated_at))))] (zipmap (map :id existing-tabs) new-tab-ids))) | |
Update dashcards in a dashboard for copying. If the dashboard has tabs, fix up the tab ids in dashcards to point to the new tabs. Then if shallow copy, return the cards. If deep copy, replace ids with id from the newly-copied cards. If there is no new id, it means user lacked curate permissions for the cards collections and it is omitted. | (defn update-cards-for-copy [dashcards id->new-card id->referenced-card id->new-tab-id] (let [dashcards (if (seq id->new-tab-id) (map #(assoc % :dashboard_tab_id (id->new-tab-id (:dashboard_tab_id %))) dashcards) dashcards)] (keep (fn [dashboard-card] (cond ;; text cards need no manipulation (nil? (:card_id dashboard-card)) dashboard-card ;; referenced cards need no manipulation (get id->referenced-card (:card_id dashboard-card)) dashboard-card ;; if we didn't duplicate, it doesn't go in the dashboard (not (get id->new-card (:card_id dashboard-card))) nil :else (let [new-id (fn [id] (-> id id->new-card :id))] (-> dashboard-card (update :card_id new-id) (assoc :card (-> dashboard-card :card_id id->new-card)) (m/update-existing :parameter_mappings (fn [pms] (keep (fn [pm] (m/update-existing pm :card_id new-id)) pms))) (m/update-existing :series (fn [series] (keep (fn [card] (when-let [id' (new-id (:id card))] (assoc card :id id'))) series))))))) dashcards))) |
/:from-dashboard-id/copy | (api/defendpoint POST "Copy a Dashboard." [from-dashboard-id :as {{:keys [name description collection_id collection_position is_deep_copy], :as _dashboard} :body}] {from-dashboard-id [:maybe ms/PositiveInt] name [:maybe ms/NonBlankString] description [:maybe :string] collection_id [:maybe ms/PositiveInt] collection_position [:maybe ms/PositiveInt] is_deep_copy [:maybe :boolean]} ;; if we're trying to save the new dashboard in a Collection make sure we have permissions to do that (collection/check-write-perms-for-collection collection_id) (let [existing-dashboard (get-dashboard from-dashboard-id) dashboard-data {:name (or name (:name existing-dashboard)) :description (or description (:description existing-dashboard)) :parameters (or (:parameters existing-dashboard) []) :creator_id api/*current-user-id* :collection_id collection_id :collection_position collection_position} new-cards (atom nil) dashboard (t2/with-transaction [_conn] ;; Adding a new dashboard at `collection_position` could cause other dashboards in this ;; collection to change position, check that and fix up if needed (api/maybe-reconcile-collection-position! dashboard-data) ;; Ok, now save the Dashboard (let [dash (first (t2/insert-returning-instances! :model/Dashboard dashboard-data)) {id->new-card :copied id->referenced-card :referenced uncopied :discarded} (maybe-duplicate-cards is_deep_copy dash existing-dashboard collection_id) id->new-tab-id (when-let [existing-tabs (seq (:tabs existing-dashboard))] (duplicate-tabs dash existing-tabs))] (reset! new-cards (vals id->new-card)) (when-let [dashcards (seq (update-cards-for-copy (:dashcards existing-dashboard) id->new-card id->referenced-card id->new-tab-id))] (api/check-500 (dashboard/add-dashcards! dash dashcards))) (cond-> dash (seq uncopied) (assoc :uncopied uncopied))))] (snowplow/track-event! ::snowplow/dashboard {:event :dashboard-created :dashboard-id (u/the-id dashboard)}) ;; must signal event outside of tx so cards are visible from other threads (when-let [newly-created-cards (seq @new-cards)] (doseq [card newly-created-cards] (events/publish-event! :event/card-create {:object card :user-id api/*current-user-id*}))) (events/publish-event! :event/dashboard-create {:object dashboard :user-id api/*current-user-id*}) dashboard)) |
--------------------------------------------- Fetching/Updating/Etc. --------------------------------------------- | |
/:id | (api/defendpoint GET "Get Dashboard with ID." [id :as {{dashboard-load-id "dashboard_load_id"} :query-params}] {id ms/PositiveInt} (with-dashboard-load-id dashboard-load-id (let [dashboard (get-dashboard id)] (u/prog1 (first (last-edit/with-last-edit-info [dashboard] :dashboard)) (events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id api/*current-user-id*}))))) |
/:id/items | (api/defendpoint GET "Get Dashboard with ID." [id] {id ms/PositiveInt} ;; Output should match the shape of api/collection/<:id|root>/items. There's a test that asserts that this remains ;; the case, but if you change one, you'll want to change both. (let [dashboard (api/read-check :model/Dashboard id) query (merge {:select [:c.id :c.name :c.description :c.entity_id :c.collection_position :c.display :c.collection_preview :last_used_at :c.collection_id :c.archived_directly :c.archived :c.database_id :c.dashboard_id [nil :location] [(h2x/literal "card") :model] [{:select [:status] :from [:moderation_review] :where [:and [:= :moderated_item_type "card"] [:= :moderated_item_id :c.id] [:= :most_recent true]] ;; limit 1 to ensure that there is only one result but this invariant should hold true, just ;; protecting against potential bugs :order-by [[:id :desc]] :limit 1} :moderated_status]] :from [[:report_card :c]] :where [:and [:= :c.dashboard_id id] [:exists {:select 1 :from [[:report_dashboardcard :dc]] :where [:and [:= :c.id :dc.card_id] [:= :c.dashboard_id :dc.dashboard_id]]}] [:= :c.archived false]]} (when (request/paged?) {:limit (request/limit) :offset (request/offset)})) cards (mdb.query/query query)] {:total (count cards) :data (api.collection/post-process-rows {} (t2/select-one :model/Collection :id (:collection_id dashboard)) cards) :limit (request/limit) :offset (request/offset) :models (if (seq cards) ["card"] [])})) |
You must be a superuser to change the value of | (defn- check-allowed-to-change-embedding [dash-before-update dash-updates] (when (or (api/column-will-change? :enable_embedding dash-before-update dash-updates) (api/column-will-change? :embedding_params dash-before-update dash-updates)) (validation/check-embedding-enabled) (api/check-superuser))) |
/:id | (api/defendpoint DELETE "Hard delete a Dashboard. To soft delete, use `PUT /api/dashboard/:id` This will remove also any questions/models/segments/metrics that use this database." [id] {id ms/PositiveInt} (let [dashboard (api/write-check :model/Dashboard id)] (t2/delete! :model/Dashboard :id id) (events/publish-event! :event/dashboard-delete {:object dashboard :user-id api/*current-user-id*})) api/generic-204-no-content) |
(defn- param-target->field-id [target query] (when-let [field-clause (params/param-target->field-clause target {:dataset_query query})] (lib.util.match/match-one field-clause [:field (id :guard integer?) _] id))) | |
Starting in 0.41.0, you must have data permissions in order to add or modify a DashboardCard parameter mapping. TODO -- should we only check new or modified mappings? | (mu/defn- check-parameter-mapping-permissions {:added "0.41.0"} [parameter-mappings :- [:sequential dashboard-card/ParamMapping]] (when (seq parameter-mappings) ;; calculate a set of all Field IDs referenced by parameter mappings; then from those Field IDs calculate a set of ;; all Table IDs to which those Fields belong. This is done in a batched fashion so we can avoid N+1 query issues ;; if there happen to be a lot of parameters (let [card-ids (into #{} (comp (map :card-id) (remove nil?)) parameter-mappings)] (when (seq card-ids) (let [card-id->query (t2/select-pk->fn :dataset_query Card :id [:in card-ids]) field-ids (set (for [{:keys [target card-id]} parameter-mappings :when card-id :let [query (or (card-id->query card-id) (throw (ex-info (tru "Card {0} does not exist or does not have a valid query." card-id) {:status-code 404 :card-id card-id}))) field-id (param-target->field-id target query)] :when field-id] field-id)) table-ids (when (seq field-ids) (t2/select-fn-set :table_id Field :id [:in field-ids])) table-id->database-id (when (seq table-ids) (t2/select-pk->fn :db_id Table :id [:in table-ids]))] (doseq [table-id table-ids :let [database-id (table-id->database-id table-id)]] ;; check whether we'd actually be able to query this Table (do we have ad-hoc data perms for it?) (when-not (query-perms/can-query-table? database-id table-id) (throw (ex-info (tru "You must have data permissions to add a parameter referencing the Table {0}." (pr-str (t2/select-one-fn :name Table :id table-id))) {:status-code 403 :database-id database-id :table-id table-id :actual-permissions @api/*current-user-permissions-set*}))))))))) |
Returns a map of DashboardCard ID -> parameter mappings for a Dashboard of the form { | (defn- existing-parameter-mappings [dashboard-id] (m/map-vals (fn [mappings] (into #{} (map #(select-keys % [:target :parameter_id])) mappings)) (t2/select-pk->fn :parameter_mappings DashboardCard :dashboard_id dashboard-id))) |
In 0.41.0+ you now require data permissions for the Table in question to add or modify Dashboard parameter mappings. Check that the current user has the appropriate permissions. Don't check any parameter mappings that already exist for this Dashboard -- only check permissions for new or modified ones. | (defn- check-updated-parameter-mapping-permissions [dashboard-id dashcards] (let [dashcard-id->existing-mappings (existing-parameter-mappings dashboard-id) existing-mapping? (fn [dashcard-id mapping] (let [[mapping] (mi/normalize-parameters-list [mapping]) existing-mappings (get dashcard-id->existing-mappings dashcard-id)] (contains? existing-mappings (select-keys mapping [:target :parameter_id])))) new-mappings (for [{mappings :parameter_mappings, dashcard-id :id} dashcards mapping mappings :when (not (existing-mapping? dashcard-id mapping))] (assoc mapping :dashcard-id dashcard-id)) ;; need to add the appropriate `:card-id` for all the new mappings we're going to check. dashcard-id->card-id (when (seq new-mappings) (t2/select-pk->fn :card_id DashboardCard :dashboard_id dashboard-id :id [:in (set (map :dashcard-id new-mappings))])) new-mappings (for [{:keys [dashcard-id], :as mapping} new-mappings] (assoc mapping :card-id (get dashcard-id->card-id dashcard-id)))] (check-parameter-mapping-permissions new-mappings))) |
(defn- create-dashcards! [dashboard dashcards] (doseq [{:keys [card_id]} dashcards :when (pos-int? card_id)] (api/check-not-archived (api/read-check Card card_id))) (check-parameter-mapping-permissions (for [{:keys [card_id parameter_mappings]} dashcards mapping parameter_mappings] (assoc mapping :card-id card_id))) (api/check-500 (dashboard/add-dashcards! dashboard dashcards))) | |
(defn- update-dashcards! [dashboard dashcards] (check-updated-parameter-mapping-permissions (:id dashboard) dashcards) ;; transform the dashcard data to the format of the DashboardCard model ;; so update-dashcards! can compare them with existing dashcards (dashboard/update-dashcards! dashboard (map dashboard-card/from-parsed-json dashcards)) dashcards) | |
(defn- delete-dashcards! [dashcard-ids] (let [dashboard-cards (t2/select DashboardCard :id [:in dashcard-ids])] (dashboard-card/delete-dashboard-cards! dashcard-ids) dashboard-cards)) | |
(defn- assert-new-dashcards-are-not-internal-to-other-dashboards [dashboard to-create] (when-let [card-ids (seq (concat (seq (keep :card_id to-create)) (->> to-create (mapcat :series) (keep :id))))] (api/check-400 (not (t2/exists? :model/Card {:where [:and [:not= :dashboard_id (u/the-id dashboard)] [:not= :dashboard_id nil] [:in :id (set card-ids)]]}))))) | |
(defn- do-update-dashcards! [dashboard current-cards new-cards] (let [{:keys [to-create to-update to-delete]} (u/row-diff current-cards new-cards)] (dashboard/archive-or-unarchive-internal-dashboard-questions! (:id dashboard) new-cards) (assert-new-dashcards-are-not-internal-to-other-dashboards dashboard to-create) (when (seq to-update) (update-dashcards! dashboard to-update)) {:deleted-dashcards (when (seq to-delete) (delete-dashcards! (map :id to-delete))) :created-dashcards (when (seq to-create) (create-dashcards! dashboard to-create))})) | |
(def ^:private UpdatedDashboardCard [:map ;; id can be negative, it indicates a new card and BE should create them [:id int?] [:size_x ms/PositiveInt] [:size_y ms/PositiveInt] [:row ms/IntGreaterThanOrEqualToZero] [:col ms/IntGreaterThanOrEqualToZero] [:parameter_mappings {:optional true} [:maybe [:sequential [:map [:parameter_id ms/NonBlankString] [:target :any]]]]] [:series {:optional true} [:maybe [:sequential map?]]]]) | |
(def ^:private UpdatedDashboardTab [:map ;; id can be negative, it indicates a new card and BE should create them [:id ms/Int] [:name ms/NonBlankString]]) | |
(defn- track-dashcard-and-tab-events! [{dashboard-id :id :as dashboard} {:keys [created-dashcards deleted-dashcards created-tab-ids deleted-tab-ids total-num-tabs]}] ;; Dashcard events (when (seq deleted-dashcards) (events/publish-event! :event/dashboard-remove-cards {:object dashboard :user-id api/*current-user-id* :dashcards deleted-dashcards})) (when (seq created-dashcards) (events/publish-event! :event/dashboard-add-cards {:object dashboard :user-id api/*current-user-id* :dashcards created-dashcards}) (for [{:keys [card_id]} created-dashcards :when (pos-int? card_id)] (snowplow/track-event! ::snowplow/dashboard {:event :question-added-to-dashboard :dashboard-id dashboard-id :question-id card_id}))) ;; Tabs events (when (seq deleted-tab-ids) (snowplow/track-event! ::snowplow/dashboard {:event :dashboard-tab-deleted :dashboard-id dashboard-id :num-tabs (count deleted-tab-ids) :total-num-tabs total-num-tabs})) (when (seq created-tab-ids) (snowplow/track-event! ::snowplow/dashboard {:event :dashboard-tab-created :dashboard-id dashboard-id :num-tabs (count created-tab-ids) :total-num-tabs total-num-tabs}))) | |
Bad pulse check & repair | |
Given a pulse and bad parameters, return relevant notification data: - The name of the pulse - Which selected parameter values are broken - The user info for the creator of the pulse - The users affected by the pulse | (defn- bad-pulse-notification-data [{bad-pulse-id :id pulse-name :name :keys [parameters creator_id]}] (let [creator (t2/select-one [:model/User :first_name :last_name :email] creator_id)] {:pulse-id bad-pulse-id :pulse-name pulse-name :bad-parameters parameters :pulse-creator creator :affected-users (flatten (for [{pulse-channel-id :id channel-type :channel_type {:keys [channel]} :details} (t2/select [:model/PulseChannel :id :channel_type :details] :pulse_id [:= bad-pulse-id])] (case channel-type :email (let [pulse-channel-recipients (when (= :email channel-type) (t2/select :model/PulseChannelRecipient :pulse_channel_id pulse-channel-id))] (when (seq pulse-channel-recipients) (map (fn [{:keys [common_name] :as recipient}] (assoc recipient :notification-type channel-type :recipient common_name)) (t2/select [:model/User :first_name :last_name :email] :id [:in (map :user_id pulse-channel-recipients)])))) :slack {:notification-type channel-type :recipient channel} nil)))})) |
Identify and return any pulses used in a subscription that contain parameters that are no longer on the dashboard. | (defn- broken-pulses [dashboard-id original-dashboard-params] (when (seq original-dashboard-params) (let [{:keys [resolved-params]} (t2/hydrate (t2/select-one [:model/Dashboard :id :parameters] dashboard-id) :resolved-params) dashboard-params (set (keys resolved-params))] (->> (t2/select :model/Pulse :dashboard_id dashboard-id :archived false) (keep (fn [{:keys [parameters] :as pulse}] (let [bad-params (filterv (fn [{param-id :id}] (not (contains? dashboard-params param-id))) parameters)] (when (seq bad-params) (assoc pulse :parameters bad-params))))) seq)))) |
Given a dashboard id and original parameters, return data (if any) on any broken subscriptions. This will be a seq of maps, each containing: - The pulse id that was broken - name and email data for the dashboard creator and pulse creator - Affected recipient information - Basic descriptive data on the affected dashboard, pulse, and parameters for use in downstream notifications | (defn- broken-subscription-data [dashboard-id original-dashboard-params] (when-some [broken-pulses (broken-pulses dashboard-id original-dashboard-params)] (let [{dashboard-name :name dashboard-description :description dashboard-creator :creator} (t2/hydrate (t2/select-one [:model/Dashboard :name :description :creator_id] dashboard-id) :creator)] (for [broken-pulse broken-pulses] (assoc (bad-pulse-notification-data broken-pulse) :dashboard-id dashboard-id :dashboard-name dashboard-name :dashboard-description dashboard-description :dashboard-creator (select-keys dashboard-creator [:first_name :last_name :email :common_name])))))) |
Given a dashboard id and original parameters, determine if any of the subscriptions are broken (we've removed params that subscriptions require). If so, delete the subscriptions and notify the dashboard and pulse creators. | (defn- handle-broken-subscriptions [dashboard-id original-dashboard-params] (doseq [{:keys [pulse-id] :as broken-subscription} (broken-subscription-data dashboard-id original-dashboard-params)] ;; Archive the pulse (models.pulse/update-pulse! {:id pulse-id :archived true}) ;; Let the pulse and subscription creator know about the broken pulse (messages/send-broken-subscription-notification! broken-subscription))) |
End functions to handle broken subscriptions | |
Updates a Dashboard. Designed to be reused by PUT /api/dashboard/:id and PUT /api/dashboard/:id/cards | (defn- update-dashboard [id {:keys [dashcards tabs parameters] :as dash-updates}] (span/with-span! {:name "update-dashboard" :attributes {:dashboard/id id}} (let [current-dash (api/write-check Dashboard id) ;; If there are parameters in the update, we want the old params so that we can do a check to see if any of ;; the notifications were broken by the update. {original-params :resolved-params} (when parameters (t2/hydrate (t2/select-one :model/Dashboard id) [:dashcards :card] :resolved-params)) changes-stats (atom nil) ;; tabs are always sent in production as well when dashcards are updated, but there are lots of ;; tests that exclude it. so this only checks for dashcards update-dashcards-and-tabs? (contains? dash-updates :dashcards) dash-updates (api/updates-with-archived-directly current-dash dash-updates)] (collection/check-allowed-to-change-collection current-dash dash-updates) (check-allowed-to-change-embedding current-dash dash-updates) (api/check-500 (do (t2/with-transaction [_conn] ;; If the dashboard has an updated position, or if the dashboard is moving to a new collection, we might need to ;; adjust the collection position of other dashboards in the collection (api/maybe-reconcile-collection-position! current-dash dash-updates) (when-let [updates (not-empty (u/select-keys-when dash-updates :present #{:description :position :width :collection_id :collection_position :cache_ttl :archived_directly} :non-nil #{:name :parameters :caveats :points_of_interest :show_in_getting_started :enable_embedding :embedding_params :archived :auto_apply_filters}))] (when (api/column-will-change? :archived current-dash dash-updates) (if (:archived dash-updates) (card/with-allowed-changes-to-internal-dashboard-card (t2/update! :model/Card :dashboard_id id :archived false {:archived true :archived_directly false})) (card/with-allowed-changes-to-internal-dashboard-card (t2/update! :model/Card :dashboard_id id :archived true :archived_directly false {:archived false})))) (when (api/column-will-change? :collection_id current-dash dash-updates) (card/with-allowed-changes-to-internal-dashboard-card (t2/update! :model/Card :dashboard_id id {:collection_id (:collection_id dash-updates)}))) (t2/update! Dashboard id updates) (when (contains? updates :collection_id) (events/publish-event! :event/collection-touch {:collection-id id :user-id api/*current-user-id*})) ;; Handle broken subscriptions, if any, when parameters changed (when parameters (handle-broken-subscriptions id original-params))) (when update-dashcards-and-tabs? (when (not (false? (:archived false))) (api/check-not-archived current-dash)) (let [{current-dashcards :dashcards current-tabs :tabs :as hydrated-current-dash} (t2/hydrate current-dash [:dashcards :series :card] :tabs) _ (when (and (seq current-tabs) (not (every? #(some? (:dashboard_tab_id %)) dashcards))) (throw (ex-info (tru "This dashboard has tab, makes sure every card has a tab") {:status-code 400}))) new-tabs (map-indexed (fn [idx tab] (assoc tab :position idx)) tabs) {:keys [old->new-tab-id deleted-tab-ids] :as tabs-changes-stats} (dashboard-tab/do-update-tabs! (:id current-dash) current-tabs new-tabs) deleted-tab-ids (set deleted-tab-ids) current-dashcards (remove (fn [dashcard] (contains? deleted-tab-ids (:dashboard_tab_id dashcard))) current-dashcards) new-dashcards (cond->> dashcards ;; fixup the temporary tab ids with the real ones (seq old->new-tab-id) (map (fn [card] (if-let [real-tab-id (get old->new-tab-id (:dashboard_tab_id card))] (assoc card :dashboard_tab_id real-tab-id) card)))) dashcards-changes-stats (do-update-dashcards! hydrated-current-dash current-dashcards new-dashcards)] (reset! changes-stats (merge (select-keys tabs-changes-stats [:created-tab-ids :deleted-tab-ids :total-num-tabs]) (select-keys dashcards-changes-stats [:created-dashcards :deleted-dashcards])))))) true)) (let [dashboard (t2/select-one :model/Dashboard id)] ;; skip publishing the event if it's just a change in its collection position (when-not (= #{:collection_position} (set (keys dash-updates))) (events/publish-event! :event/dashboard-update {:object dashboard :user-id api/*current-user-id*})) (track-dashcard-and-tab-events! dashboard @changes-stats) (-> dashboard hydrate-dashboard-details (assoc :last-edit-info (last-edit/edit-information-for-user @api/*current-user*))))))) |
Schema for Dashboard Updates. | (def ^:private DashUpdates [:map [:name {:optional true} [:maybe ms/NonBlankString]] [:description {:optional true} [:maybe :string]] [:caveats {:optional true} [:maybe :string]] [:points_of_interest {:optional true} [:maybe :string]] [:show_in_getting_started {:optional true} [:maybe :boolean]] [:enable_embedding {:optional true} [:maybe :boolean]] [:embedding_params {:optional true} [:maybe ms/EmbeddingParams]] [:parameters {:optional true} [:maybe [:sequential ms/Parameter]]] [:position {:optional true} [:maybe ms/PositiveInt]] [:width {:optional true} [:enum "fixed" "full"]] [:archived {:optional true} [:maybe :boolean]] [:collection_id {:optional true} [:maybe ms/PositiveInt]] [:collection_position {:optional true} [:maybe ms/PositiveInt]] [:cache_ttl {:optional true} [:maybe ms/PositiveInt]] [:dashcards {:optional true} [:maybe (ms/maps-with-unique-key [:sequential UpdatedDashboardCard] :id)]] [:tabs {:optional true} [:maybe (ms/maps-with-unique-key [:sequential UpdatedDashboardTab] :id)]]]) |
/:id | (api/defendpoint PUT "Update a Dashboard, and optionally the `dashcards` and `tabs` of a Dashboard. The request body should be a JSON object with the same structure as the response from `GET /api/dashboard/:id`." [id :as {dash-updates :body}] {id ms/PositiveInt dash-updates DashUpdates} (update-dashboard id dash-updates)) |
/:id/cards | (api/defendpoint PUT "(DEPRECATED -- Use the `PUT /api/dashboard/:id` endpoint instead.) Update `Cards` and `Tabs` on a Dashboard. Request body should have the form: {:cards [{:id ... ; DashboardCard ID :size_x ... :size_y ... :row ... :col ... :parameter_mappings ... :series [{:id 123 ...}]} ...] :tabs [{:id ... ; DashboardTab ID :name ...}]}" [id :as {{:keys [cards tabs]} :body}] {id ms/PositiveInt cards (ms/maps-with-unique-key [:sequential UpdatedDashboardCard] :id) ;; tabs should be required in production, making it optional because lots of ;; e2e tests curerntly doesn't include it tabs [:maybe (ms/maps-with-unique-key [:sequential UpdatedDashboardTab] :id)]} (log/warn "DELETE /api/dashboard/:id/cards is deprecated. Use PUT /api/dashboard/:id instead.") (let [dashboard (update-dashboard id {:dashcards cards :tabs tabs})] {:cards (:dashcards dashboard) :tabs (:tabs dashboard)})) |
/:id/revisions | (api/defendpoint GET "Fetch `Revisions` for Dashboard with ID." [id] {id ms/PositiveInt} (api/read-check :model/Dashboard id) (revision/revisions+details :model/Dashboard id)) |
/:id/revert | (api/defendpoint POST "Revert a Dashboard to a prior `Revision`." [id :as {{:keys [revision_id]} :body}] {id ms/PositiveInt revision_id ms/PositiveInt} (api/write-check :model/Dashboard id) (revision/revert! {:entity :model/Dashboard :id id :user-id api/*current-user-id* :revision-id revision_id})) |
/:id/query_metadata | (api/defendpoint GET "Get all of the required query metadata for the cards on dashboard." [id :as {{dashboard-load-id "dashboard_load_id"} :query-params}] {id ms/PositiveInt} (with-dashboard-load-id dashboard-load-id (data-perms/with-relevant-permissions-for-user api/*current-user-id* (let [dashboard (get-dashboard id)] (api.query-metadata/batch-fetch-dashboard-metadata [dashboard]))))) |
----------------------------------------------- Sharing is Caring ------------------------------------------------ | |
/:dashboard-id/public_link | (api/defendpoint POST "Generate publicly-accessible links for this Dashboard. Returns UUID to be used in public links. (If this Dashboard has already been shared, it will return the existing public link rather than creating a new one.) Public sharing must be enabled." [dashboard-id] {dashboard-id ms/PositiveInt} (api/check-superuser) (validation/check-public-sharing-enabled) (api/check-not-archived (api/read-check :model/Dashboard dashboard-id)) {:uuid (or (t2/select-one-fn :public_uuid :model/Dashboard :id dashboard-id) (u/prog1 (str (random-uuid)) (t2/update! :model/Dashboard dashboard-id {:public_uuid <> :made_public_by_id api/*current-user-id*})))}) |
/:dashboard-id/public_link | (api/defendpoint DELETE "Delete the publicly-accessible link to this Dashboard." [dashboard-id] {dashboard-id ms/PositiveInt} (validation/check-has-application-permission :setting) (validation/check-public-sharing-enabled) (api/check-exists? :model/Dashboard :id dashboard-id, :public_uuid [:not= nil], :archived false) (t2/update! :model/Dashboard dashboard-id {:public_uuid nil :made_public_by_id nil}) {:status 204, :body nil}) |
/public | (api/defendpoint GET "Fetch a list of Dashboards with public UUIDs. These dashboards are publicly-accessible *if* public sharing is enabled." [] (validation/check-has-application-permission :setting) (validation/check-public-sharing-enabled) (t2/select [:model/Dashboard :name :id :public_uuid], :public_uuid [:not= nil], :archived false)) |
/embeddable | (api/defendpoint GET "Fetch a list of Dashboards where `enable_embedding` is `true`. The dashboards can be embedded using the embedding endpoints and a signed JWT." [] (validation/check-has-application-permission :setting) (validation/check-embedding-enabled) (t2/select [:model/Dashboard :name :id], :enable_embedding true, :archived false)) |
/:id/related | (api/defendpoint GET "Return related entities." [id] {id ms/PositiveInt} (-> (t2/select-one :model/Dashboard :id id) api/read-check related/related)) |
---------------------------------------------- Transient dashboards ---------------------------------------------- | |
/save/collection/:parent-collection-id | (api/defendpoint POST "Save a denormalized description of dashboard into collection with ID `:parent-collection-id`." [parent-collection-id :as {dashboard :body}] {parent-collection-id ms/PositiveInt} (collection/check-write-perms-for-collection parent-collection-id) (let [dashboard (dashboard/save-transient-dashboard! dashboard parent-collection-id)] (events/publish-event! :event/dashboard-create {:object dashboard :user-id api/*current-user-id*}) dashboard)) |
/save | (api/defendpoint POST "Save a denormalized description of dashboard." [:as {dashboard :body}] (let [parent-collection-id (if api/*is-superuser?* (:id (xrays/get-or-create-root-container-collection)) (t2/select-one-fn :id 'Collection :personal_owner_id api/*current-user-id*)) dashboard (dashboard/save-transient-dashboard! dashboard parent-collection-id)] (events/publish-event! :event/dashboard-create {:object dashboard :user-id api/*current-user-id*}) dashboard)) |
------------------------------------- Chain-filtering param value endpoints -------------------------------------- | |
How many results to return when chain filtering | (def ^:const result-limit 1000) |
Fetch the (get-template-tag [:template-tag :company] some-dashcard) ; -> [:field 100 nil] | (defn- get-template-tag [dimension card] (when-let [[_ tag] (mbql.u/check-clause :template-tag dimension)] (get-in card [:dataset_query :native :template-tags (u/qualified-name tag)]))) |
(defn- param-type->op [type] (if (get-in lib.schema.parameter/types [type :operator]) (keyword (name type)) :=)) | |
(mu/defn- param->fields [{:keys [mappings] :as param} :- mbql.s/Parameter] (for [{:keys [target] {:keys [card]} :dashcard} mappings :let [[_ dimension] (->> (mbql.normalize/normalize-tokens target :ignore-path) (mbql.u/check-clause :dimension))] :when dimension :let [ttag (get-template-tag dimension card) dimension (condp mbql.u/is-clause? dimension :field dimension :expression dimension :template-tag (:dimension ttag) (log/error "cannot handle this dimension" {:dimension dimension})) field-id (or ;; Get the field id from the field-clause if it contains it. This is the common case ;; for mbql queries. (lib.util.match/match-one dimension [:field (id :guard integer?) _] id) ;; Attempt to get the field clause from the model metadata corresponding to the field. ;; This is the common case for native queries in which mappings from original columns ;; have been performed using model metadata. (:id (qp.util/field->field-info dimension (:result_metadata card))))] :when field-id] {:field-id field-id :op (param-type->op (:type param)) :options (merge (:options ttag) (:options param))})) | |
(mu/defn- chain-filter-constraints :- chain-filter/Constraints [dashboard constraint-param-key->value] (vec (for [[param-key value] constraint-param-key->value :let [param (get-in dashboard [:resolved-params param-key])] :when param field (param->fields param)] (assoc field :value value)))) | |
Get filter values when only field-refs (e.g. | (defn filter-values-from-field-refs [dashboard param-key] (let [dashboard (t2/hydrate dashboard :resolved-params) param (get-in dashboard [:resolved-params param-key]) results (for [{:keys [target] {:keys [card]} :dashcard} (:mappings param) :let [[_ field-ref opts] (->> (mbql.normalize/normalize-tokens target :ignore-path) (mbql.u/check-clause :dimension))] :when field-ref] (custom-values/values-from-card card field-ref opts))] (when-some [values (seq (distinct (mapcat :values results)))] (let [has_more_values (boolean (some true? (map :has_more_values results)))] {:values (cond->> values (seq values) (sort-by (case (count (first values)) 2 second 1 first))) :has_more_values has_more_values})))) |
(defn- combine-chained-fitler-results [results] (let [;; merge values with remapped values taking priority values (->> (mapcat :values results) (sort-by count) (m/index-by first) vals)] (cond->> values (seq values) ;; sort by remapped values only if all values are remapped (sort-by (case (count (first values)) 2 second 1 first))))) | |
(mu/defn chain-filter :- ms/FieldValuesResult "C H A I N filters! Used to query for values that populate chained filter dropdowns and text search boxes." ([dashboard param-key constraint-param-key->value] (chain-filter dashboard param-key constraint-param-key->value nil)) ([dashboard :- ms/Map param-key :- ms/NonBlankString constraint-param-key->value :- ms/Map query :- [:maybe ms/NonBlankString]] (let [dashboard (t2/hydrate dashboard :resolved-params) constraints (chain-filter-constraints dashboard constraint-param-key->value) param (get-in dashboard [:resolved-params param-key]) field-ids (into #{} (map :field-id (param->fields param)))] (if (empty? field-ids) (or (filter-values-from-field-refs dashboard param-key) (throw (ex-info (tru "Parameter {0} does not have any Fields associated with it" (pr-str param-key)) {:param (get (:resolved-params dashboard) param-key) :status-code 400}))) (try (let [;; results can come back as [[value] ...] *or* as [[value remapped] ...]. results (map (if (seq query) #(chain-filter/chain-filter-search % constraints query :limit result-limit) #(chain-filter/chain-filter % constraints :limit result-limit)) field-ids) has_more_values (boolean (some true? (map :has_more_values results)))] {:values (or (combine-chained-fitler-results results) ;; chain filter results can't be nil []) :has_more_values has_more_values}) (catch clojure.lang.ExceptionInfo e (if (= (:type (u/all-ex-data e)) qp.error-type/missing-required-permissions) (api/throw-403 e) (throw e)))))))) | |
Fetch values for a parameter. The source of values could be: - static-list: user defined values list - card: values is result of running a card - nil: chain-filter | (mu/defn param-values ([dashboard param-key constraint-param-key->value] (param-values dashboard param-key constraint-param-key->value nil)) ([dashboard :- :map param-key :- ms/NonBlankString constraint-param-key->value :- :map query :- [:maybe ms/NonBlankString]] (let [dashboard (t2/hydrate dashboard :resolved-params) param (get (:resolved-params dashboard) param-key)] (when-not param (throw (ex-info (tru "Dashboard does not have a parameter with the ID {0}" (pr-str param-key)) {:resolved-params (keys (:resolved-params dashboard)) :status-code 400}))) (custom-values/parameter->values param query (fn [] (chain-filter dashboard param-key constraint-param-key->value query)))))) |
/:id/params/:param-key/values | (api/defendpoint GET "Fetch possible values of the parameter whose ID is `:param-key`. If the values come directly from a query, optionally restrict these values by passing query parameters like `other-parameter=value` e.g. ;; fetch values for Dashboard 1 parameter 'abc' that are possible when parameter 'def' is set to 100 GET /api/dashboard/1/params/abc/values?def=100" [id param-key :as {constraint-param-key->value :query-params}] {id ms/PositiveInt} (let [dashboard (api/read-check :model/Dashboard id)] ;; If a user can read the dashboard, then they can lookup filters. This also works with sandboxing. (binding [qp.perms/*param-values-query* true] (param-values dashboard param-key constraint-param-key->value)))) |
/:id/params/:param-key/search/:query | (api/defendpoint GET "Fetch possible values of the parameter whose ID is `:param-key` that contain `:query`. Optionally restrict these values by passing query parameters like `other-parameter=value` e.g. ;; fetch values for Dashboard 1 parameter 'abc' that contain 'Cam' and are possible when parameter 'def' is set ;; to 100 GET /api/dashboard/1/params/abc/search/Cam?def=100 Currently limited to first 1000 results." [id param-key query :as {constraint-param-key->value :query-params}] {id ms/PositiveInt query ms/NonBlankString} (let [dashboard (api/read-check :model/Dashboard id)] ;; If a user can read the dashboard, then they can lookup filters. This also works with sandboxing. (binding [qp.perms/*param-values-query* true] (param-values dashboard param-key constraint-param-key->value query)))) |
/params/valid-filter-fields | (api/defendpoint GET "Utility endpoint for powering Dashboard UI. Given some set of `filtered` Field IDs (presumably Fields used in parameters) and a set of `filtering` Field IDs that will be used to restrict values of `filtered` Fields, for each `filtered` Field ID return the subset of `filtering` Field IDs that would actually be used in a chain filter query with these Fields. e.g. in a chain filter query like GET /api/dashboard/10/params/PARAM_1/values?PARAM_2=100 Assume `PARAM_1` maps to Field 1 and `PARAM_2` maps to Fields 2 and 3. The underlying MBQL query may or may not filter against Fields 2 and 3, depending on whether an FK relationship that lets us create a join against Field 1 can be found. You can use this endpoint to determine which of those Fields is actually used: GET /api/dashboard/params/valid-filter-fields?filtered=1&filtering=2&filtering=3 ;; -> {1 [2 3]} Results are returned as a map of `filtered` Field ID -> subset of `filtering` Field IDs that would be used in chain filter query" [:as {{:keys [filtered filtering]} :params}] {filtered (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero) filtering [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]} (let [filtered-field-ids (if (sequential? filtered) (set filtered) #{filtered}) filtering-field-ids (if (sequential? filtering) (set filtering) #{filtering})] (doseq [field-id (set/union filtered-field-ids filtering-field-ids)] (api/read-check Field field-id)) (into {} (for [field-id filtered-field-ids] [field-id (sort (chain-filter/filterable-field-ids field-id filtering-field-ids))])))) |
Schema for a parameter map with an string | (def ParameterWithID (mu/with-api-error-message [:and [:map [:id ms/NonBlankString]] [:map-of :keyword :any]] (deferred-tru "value must be a parameter map with an 'id' key"))) |
---------------------------------- Executing the action associated with a Dashcard ------------------------------- | |
/:dashboard-id/dashcard/:dashcard-id/execute | (api/defendpoint GET "Fetches the values for filling in execution parameters. Pass PK parameters and values to select." [dashboard-id dashcard-id parameters] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt parameters ms/JSONString} (api/read-check :model/Dashboard dashboard-id) (actions/fetch-values (api/check-404 (action/dashcard->action dashcard-id)) (json/decode parameters))) |
/:dashboard-id/dashcard/:dashcard-id/execute | (api/defendpoint POST "Execute the associated Action in the context of a `Dashboard` and `DashboardCard` that includes it. `parameters` should be the mapped dashboard parameters with values. `extra_parameters` should be the extra, user entered parameter values." [dashboard-id dashcard-id :as {{:keys [parameters], :as _body} :body}] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt parameters [:maybe [:map-of :string :any]]} (api/read-check :model/Dashboard dashboard-id) ;; Undo middleware string->keyword coercion (actions/execute-dashcard! dashboard-id dashcard-id parameters)) |
---------------------------------- Running the query associated with a Dashcard ---------------------------------- | |
/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query | (api/defendpoint POST "Run the query associated with a Saved Question (`Card`) in the context of a `Dashboard` that includes it." [dashboard-id dashcard-id card-id :as {{:keys [dashboard_load_id parameters], :as body} :body}] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt card-id ms/PositiveInt dashboard_load_id [:maybe ms/NonBlankString] parameters [:maybe [:sequential ParameterWithID]]} (with-dashboard-load-id dashboard_load_id (u/prog1 (m/mapply qp.dashboard/process-query-for-dashcard (merge body {:dashboard-id dashboard-id :card-id card-id :dashcard-id dashcard-id})) (events/publish-event! :event/card-read {:object-id card-id, :user-id api/*current-user-id*, :context :dashboard})))) |
/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query/:export-format | (api/defendpoint POST "Run the query associated with a Saved Question (`Card`) in the context of a `Dashboard` that includes it, and return its results as a file in the specified format. `parameters` should be passed as query parameter encoded as a serialized JSON string (this is because this endpoint is normally used to power 'Download Results' buttons that use HTML `form` actions)." [dashboard-id dashcard-id card-id export-format :as {{:keys [parameters format_rows pivot_results] :as request-parameters} :params}] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt card-id ms/PositiveInt parameters [:maybe ms/JSONString] export-format api.dataset/ExportFormat format_rows [:maybe ms/BooleanValue] pivot_results [:maybe ms/BooleanValue]} (m/mapply qp.dashboard/process-query-for-dashcard (merge request-parameters {:dashboard-id dashboard-id :card-id card-id :dashcard-id dashcard-id :export-format export-format :parameters (json/decode+kw parameters) :context (api.dataset/export-format->context export-format) :constraints nil ;; TODO -- passing this `:middleware` map is a little repetitive, need to think of a way to not have to ;; specify this all over the codebase any time we want to do a query with an export format. Maybe this ;; should be the default if `export-format` isn't `:api`? :middleware {:process-viz-settings? true :skip-results-metadata? true :ignore-cached-results? true :format-rows? (or format_rows false) :pivot? (or pivot_results false) :js-int-to-string? false}}))) |
/pivot/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query | (api/defendpoint POST "Run a pivot table query for a specific DashCard." [dashboard-id dashcard-id card-id :as {{:keys [parameters], :as body} :body}] {dashboard-id ms/PositiveInt dashcard-id ms/PositiveInt card-id ms/PositiveInt parameters [:maybe [:sequential ParameterWithID]]} (u/prog1 (m/mapply qp.dashboard/process-query-for-dashcard (merge body {:dashboard-id dashboard-id :card-id card-id :dashcard-id dashcard-id :qp qp.pivot/run-pivot-query})) (events/publish-event! :event/card-read {:object-id card-id, :user-id api/*current-user-id*, :context :dashboard}))) |
(api/define-routes) | |