(ns metabase.xrays.api.automagic-dashboards (:require [buddy.core.codecs :as codecs] [metabase.api.common :as api] [metabase.api.macros :as api.macros] [metabase.api.query-metadata :as api.query-metadata] [metabase.models.query :as query] [metabase.models.query.permissions :as query-perms] [metabase.util.i18n :as i18n :refer [deferred-tru]] [metabase.util.json :as json] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [metabase.util.regex :as u.regex] [metabase.xrays.automagic-dashboards.comparison :as automagic-dashboards.comparison] [metabase.xrays.automagic-dashboards.core :as automagic-dashboards.core] [metabase.xrays.automagic-dashboards.dashboard-templates :as automagic-dashboards.dashboard-templates] [metabase.xrays.transforms.dashboard :as transforms.dashboard] [metabase.xrays.transforms.materialize :as transforms.materialize] [ring.util.codec :as codec] [toucan2.core :as t2])) | |
(set! *warn-on-reflection* true) | |
(def ^:private Show (mu/with-api-error-message [:maybe [:or [:enum "all"] nat-int?]] (deferred-tru "invalid show value"))) | |
(def ^:private Prefix [:fn {:error/fn (fn [_ _] (i18n/tru "invalid value for prefix")) :api/regex #"[A-Za-z]+"} (fn [prefix] (some #(not-empty (automagic-dashboards.dashboard-templates/get-dashboard-templates [% prefix])) ["table" "metric" "field"]))]) | |
(def ^:private DashboardTemplate (mu/with-api-error-message [:fn (fn [dashboard-template] (some (fn [toplevel] (some (comp automagic-dashboards.dashboard-templates/get-dashboard-template (fn [prefix] [toplevel prefix dashboard-template]) :dashboard-template-name) (automagic-dashboards.dashboard-templates/get-dashboard-templates [toplevel]))) ["table" "metric" "field"]))] (deferred-tru "invalid value for dashboard template name"))) | |
(def ^:private ^{:arglists '([s])} decode-base64-json (comp json/decode+kw codecs/bytes->str codec/base64-decode)) | |
(def ^:private Base64EncodedJSON [:fn {:description "form-encoded base-64-encoded JSON" :error/fn (fn [_ _] (i18n/tru "value couldn''t be parsed as base64 encoded JSON"))} decode-base64-json]) | |
(api.macros/defendpoint :get "/database/:id/candidates" "Return a list of candidates for automagic dashboards ordered by interestingness." [{:keys [id]} :- [:map [:id ms/PositiveInt]]] (-> (t2/select-one :model/Database :id id) api/read-check automagic-dashboards.core/candidate-tables)) | |
----------------------------------------- API Endpoints for viewing a transient dashboard ---------------- | |
(defn- adhoc-query-read-check [query] (api/check-403 (query-perms/check-data-perms (:dataset_query query) (query-perms/required-perms-for-query (:dataset_query query)) :throw-exceptions? false)) query) | |
(defn- ensure-int [x] (if (string? x) (Integer/parseInt x) x)) | |
Parse/decode/coerce string | (defmulti ^:private ->entity {:arglists '([entity-type s])} (fn [entity-type _s] (keyword entity-type))) |
(defmethod ->entity :table [_entity-type table-id-str] ;; table-id can also be a source query reference like `card__1` so in that case we should pull the ID out and use the ;; `:question` method instead (if-let [[_ card-id-str] (when (string? table-id-str) (re-matches #"^card__(\d+$)" table-id-str))] (->entity :question card-id-str) (api/read-check (t2/select-one :model/Table :id (ensure-int table-id-str))))) | |
(defmethod ->entity :segment [_entity-type segment-id-str] (api/read-check (t2/select-one :model/Segment :id (ensure-int segment-id-str)))) | |
(defmethod ->entity :model [_entity-type card-id-str] (api/read-check (t2/select-one :model/Card :id (ensure-int card-id-str) :type :model))) | |
(defmethod ->entity :question [_entity-type card-id-str] (api/read-check (t2/select-one :model/Card :id (ensure-int card-id-str)))) | |
(defmethod ->entity :adhoc [_entity-type encoded-query] (adhoc-query-read-check (query/adhoc-query (decode-base64-json encoded-query)))) | |
(defmethod ->entity :metric [_entity-type metric-id-str] (api/read-check (t2/select-one :model/LegacyMetric :id (ensure-int metric-id-str)))) | |
(defmethod ->entity :field [_entity-type field-id-str] (api/read-check (t2/select-one :model/Field :id (ensure-int field-id-str)))) | |
(defmethod ->entity :transform [_entity-type transform-name] (api/read-check (t2/select-one :model/Collection :id (transforms.materialize/get-collection transform-name))) transform-name) | |
(def ^:private entities (map name (keys (methods ->entity)))) | |
(def ^:private Entity (into [:enum {:api/regex (u.regex/re-or entities) :error/fn (fn [_ _] (i18n/tru "Invalid entity type"))}] entities)) | |
(def ^:private ComparisonEntity (mu/with-api-error-message [:enum "segment" "adhoc" "table"] (deferred-tru "Invalid comparison entity type. Can only be one of \"table\", \"segment\", or \"adhoc\))) | |
Show is either nil, "all", or a number. If it's a string it needs to be converted into a keyword. | (defn- coerce-show [show] (cond-> show (= "all" show) keyword)) |
Return an automagic dashboard for entity | (defn get-automagic-dashboard [entity entity-id-or-query show] (if (= entity "transform") (transforms.dashboard/dashboard (->entity entity entity-id-or-query)) (-> (->entity entity entity-id-or-query) (automagic-dashboards.core/automagic-analysis {:show (coerce-show show)})))) |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query" "Return an automagic dashboard for entity `entity` with id `id`." [{:keys [entity entity-id-or-query]} :- [:map [:entity Entity]] {:keys [show]} :- [:map [:show {:optional true} [:maybe [:or [:= "all"] nat-int?]]]]] (get-automagic-dashboard entity entity-id-or-query show)) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/query_metadata" "Return all metadata for an automagic dashboard for entity `entity` with id `id`." [{:keys [entity entity-id-or-query]} :- [:map [:entity Entity]]] (api.query-metadata/batch-fetch-dashboard-metadata [(get-automagic-dashboard entity entity-id-or-query nil)])) | |
Identify the pk field of the model with | (defn linked-entities [{{field-ref :pk_ref} :model-index {rsmd :result_metadata} :model}] (when-let [field-id (:id (some #(when ((comp #{field-ref} :field_ref) %) %) rsmd))] (map (fn [{:keys [table_id id]}] {:linked-table-id table_id :linked-field-id id}) (t2/select 'Field :fk_target_field_id field-id)))) |
Insert a source model link card into the sequence of passed in cards. | (defn- add-source-model-link [{model-name :name model-id :id} cards] (let [max-width (->> (map (fn [{:keys [col size_x]}] (+ col size_x)) cards) (into [4]) (apply max))] (cons {:id (gensym) :size_x max-width :size_y 1 :row 0 :col 0 :visualization_settings {:virtual_card {:display "link" :archived false}, :link {:entity {:id model-id :name model-name :model "dataset" :display "table" :description nil}}}} cards))) |
For each joinable table from | (defn- create-linked-dashboard [{{indexed-entity-name :name :keys [model_pk]} :model-index-value {model-name :name :as model} :model :keys [linked-tables]}] (if (seq linked-tables) (let [child-dashboards (map (fn [{:keys [linked-table-id linked-field-id]}] (let [table (t2/select-one :model/Table :id linked-table-id)] (automagic-dashboards.core/automagic-analysis table {:show :all :query-filter [:= [:field linked-field-id nil] model_pk]}))) linked-tables) seed-dashboard (-> (first child-dashboards) (merge {:name (format "Here's a look at \"%s\" from \"%s\"" indexed-entity-name model-name) :description (format "A dashboard focusing on information linked to %s" indexed-entity-name) :parameters [] :param_fields {}}) (dissoc :transient_name :transient_filters))] (if (second child-dashboards) (->> child-dashboards (map-indexed (fn [idx {tab-name :name tab-cards :dashcards}] ;; id starts at 0. want our temporary ids to start at -1, -2, ... (let [tab-id (dec (- idx))] {:tab {:id tab-id :name tab-name :position idx} :dash-cards (map (fn [dc] (assoc dc :dashboard_tab_id tab-id)) (add-source-model-link model tab-cards))}))) (reduce (fn [dashboard {:keys [tab dash-cards]}] (-> dashboard (update :dashcards into dash-cards) (update :tabs conj tab))) (merge seed-dashboard {:dashcards [] :tabs []}))) (update seed-dashboard :dashcards (fn [cards] (add-source-model-link model cards))))) {:name (format "Here's a look at \"%s\" from \"%s\"" indexed-entity-name model-name) :dashcards (add-source-model-link model [{:row 0 :col 0 :size_x 18 :size_y 2 :visualization_settings {:text "# Unfortunately, there's not much else to show right now..." :virtual_card {:display :text} :dashcard.background false :text.align_vertical :bottom}}])})) |
(api.macros/defendpoint :get "/model_index/:model-index-id/primary_key/:pk-id" "Return an automagic dashboard for an entity detail specified by `entity` with id `id` and a primary key of `indexed-value`." [{:keys [model-index-id pk-id]} :- [:map [:model-index-id :int] [:pk-id :int]]] (api/let-404 [model-index (t2/select-one :model/ModelIndex model-index-id) model (t2/select-one :model/Card (:model_id model-index)) model-index-value (t2/select-one :model/ModelIndexValue :model_index_id model-index-id :model_pk pk-id)] ;; `->entity` does a read check on the model but this is here as well to be extra sure. (api/read-check :model/Card (:model_id model-index)) (let [linked (linked-entities {:model model :model-index model-index :model-index-value model-index-value})] (create-linked-dashboard {:model model :linked-tables linked :model-index model-index :model-index-value model-index-value})))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/rule/:prefix/:dashboard-template" "Return an automagic dashboard for entity `entity` with id `id` using dashboard-template `dashboard-template`." [{:keys [entity entity-id-or-query prefix dashboard-template]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:prefix Prefix] [:dashboard-template DashboardTemplate]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (-> (->entity entity entity-id-or-query) (automagic-dashboards.core/automagic-analysis {:show (coerce-show show) :dashboard-template ["table" prefix dashboard-template]}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/cell/:cell-query" "Return an automagic dashboard analyzing cell in automagic dashboard for entity `entity` defined by query `cell-query`." [{:keys [entity entity-id-or-query cell-query]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:cell-query Base64EncodedJSON]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (-> (->entity entity entity-id-or-query) (automagic-dashboards.core/automagic-analysis {:show (coerce-show show) :cell-query (decode-base64-json cell-query)}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:dashboard-template" "Return an automagic dashboard analyzing cell in question with id `id` defined by query `cell-query` using dashboard-template `dashboard-template`." [{:keys [entity entity-id-or-query cell-query prefix dashboard-template]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:prefix Prefix] [:dashboard-template DashboardTemplate] [:cell-query Base64EncodedJSON]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (-> (->entity entity entity-id-or-query) (automagic-dashboards.core/automagic-analysis {:show (coerce-show show) :dashboard-template ["table" prefix dashboard-template] :cell-query (decode-base64-json cell-query)}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for entity `entity` with id `id` compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`" [{:keys [entity entity-id-or-query comparison-entity comparison-entity-id-or-query]} :- [:map [:entity-id-or-query ms/NonBlankString] [:entity Entity] [:comparison-entity ComparisonEntity]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (let [left (->entity entity entity-id-or-query) right (->entity comparison-entity comparison-entity-id-or-query) dashboard (automagic-dashboards.core/automagic-analysis left {:show (coerce-show show) :query-filter nil :comparison? true})] (automagic-dashboards.comparison/comparison-dashboard dashboard left right {}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/rule/:prefix/:dashboard-template/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for entity `entity` with id `id` using dashboard-template `dashboard-template`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." [{:keys [entity entity-id-or-query prefix dashboard-template comparison-entity comparison-entity-id-or-query]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:prefix Prefix] [:dashboard-template DashboardTemplate] [:comparison-entity ComparisonEntity]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (let [left (->entity entity entity-id-or-query) right (->entity comparison-entity comparison-entity-id-or-query) dashboard (automagic-dashboards.core/automagic-analysis left {:show (coerce-show show) :dashboard-template ["table" prefix dashboard-template] :query-filter nil :comparison? true})] (automagic-dashboards.comparison/comparison-dashboard dashboard left right {}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/cell/:cell-query/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for cell in automagic dashboard for entity `entity` with id `id` defined by query `cell-query`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." [{:keys [entity entity-id-or-query cell-query comparison-entity comparison-entity-id-or-query]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:cell-query Base64EncodedJSON] [:comparison-entity ComparisonEntity]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (let [left (->entity entity entity-id-or-query) right (->entity comparison-entity comparison-entity-id-or-query) dashboard (automagic-dashboards.core/automagic-analysis left {:show (coerce-show show) :query-filter nil :comparison? true})] (automagic-dashboards.comparison/comparison-dashboard dashboard left right {:left {:cell-query (decode-base64-json cell-query)}}))) | |
(api.macros/defendpoint :get "/:entity/:entity-id-or-query/cell/:cell-query/rule/:prefix/:dashboard-template/compare/:comparison-entity/:comparison-entity-id-or-query" "Return an automagic comparison dashboard for cell in automagic dashboard for entity `entity` with id `id` defined by query `cell-query` using dashboard-template `dashboard-template`; compared with entity `comparison-entity` with id `comparison-entity-id-or-query.`." [{:keys [entity entity-id-or-query cell-query prefix dashboard-template comparison-entity comparison-entity-id-or-query]} :- [:map [:entity Entity] [:entity-id-or-query ms/NonBlankString] [:prefix Prefix] [:dashboard-template DashboardTemplate] [:cell-query Base64EncodedJSON] [:comparison-entity ComparisonEntity]] {:keys [show]} :- [:map [:show {:optional true} Show]]] (let [left (->entity entity entity-id-or-query) right (->entity comparison-entity comparison-entity-id-or-query) dashboard (automagic-dashboards.core/automagic-analysis left {:show (coerce-show show) :dashboard-template ["table" prefix dashboard-template] :query-filter nil})] (automagic-dashboards.comparison/comparison-dashboard dashboard left right {:left {:cell-query (decode-base64-json cell-query)}}))) | |