Validation, transformation to canonical form, and loading of heuristics. | (ns metabase.xrays.automagic-dashboards.dashboard-templates (:gen-class) (:require [clojure.set :as set] [clojure.string :as str] [clojure.walk :as walk] [malli.core :as mc] [malli.transform :as mtx] [metabase.models.dashboard.constants :as dashboards.constants] [metabase.query-processor.util :as qp.util] [metabase.util :as u] [metabase.util.files :as u.files] [metabase.util.i18n :as i18n] [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.yaml :as yaml] [metabase.xrays.automagic-dashboards.populate :as populate]) (:import (java.nio.file Files Path))) |
(set! *warn-on-reflection* true) | |
(def ^:private LocalizedString
[:schema
{:decode/dashboard-template (fn [s]
(if (i18n/localized-string? s)
s
(i18n/->UserLocalizedString s nil {})))}
i18n/LocalizedString]) | |
Maximal (and default) value for heuristics scores. | (def ^Long ^:const max-score 100) |
(def ^:private Score
[:int {:min 0, :max max-score}]) | |
(def ^:private MBQL [:maybe [:sequential :any]]) | |
(def ^:private Identifier
[:string
{:decode/dashboard-template (fn [x]
(if (keyword? x)
(name x)
x))}]) | |
(defn- with-defaults
[defaults]
(fn [identifier->definition]
(update-vals identifier->definition
(fn [definition]
(merge defaults definition))))) | |
Expand definition of the form {identifier value} with regards to key | (defn- shorthand-definition
[k]
(fn [x]
(let [[identifier definition] (first x)]
(if (map? definition)
x
{identifier {k definition}})))) |
(def ^:private Metric
[:map-of
{:decode/dashboard-template
(comp (with-defaults {:score max-score})
(shorthand-definition :metric))}
Identifier
[:map
[:metric MBQL]
[:score Score]
[:name {:optional true} LocalizedString]]]) | |
(def ^:private Filter
[:map-of
{:decode/dashboard-template
(comp (with-defaults {:score max-score})
(shorthand-definition :filter))}
Identifier
[:map
[:filter MBQL]
[:score Score]]]) | |
Turn | (defn ->type
[x]
(if (keyword? x)
x
(keyword "type" x))) |
Turn | (defn ->entity
[x]
(if (keyword? x)
x
(keyword "entity" x))) |
(defn- field-type? [t] (some (partial isa? t) [:type/* :Semantic/* :Relation/*])) | |
(defn- table-type? [t] (isa? t :entity/*)) | |
(def ^:private TableType
[:and
{:decode/dashboard-template ->entity}
:keyword
[:fn {:error/message "valid table type"} table-type?]]) | |
(def ^:private FieldType
[:and
{:decode/dashboard-template ->type}
:keyword
[:fn {:error/message "Valid Field type"} field-type?]]) | |
(def ^:private AppliesTo
[:or
{:decode/dashboard-template
(fn [x]
(if (string? x)
(let [[table-type field-type] (str/split x #"\.")]
(if field-type
[(->entity table-type) (->type field-type)]
[(if (-> table-type ->entity table-type?)
(->entity table-type)
(->type table-type))]))
x))}
[:sequential FieldType]
[:sequential TableType]
[:cat TableType [:* FieldType]]]) | |
(def ^:private Dimension
[:map-of
{:decode/dashboard-template
(comp (with-defaults {:score max-score})
(shorthand-definition :field_type))}
Identifier
[:map
[:field_type AppliesTo]
[:score Score]
[:links_to {:optional true} TableType]
[:named {:optional true} :string]
[:max_cardinality {:optional true} :int]]]) | |
(def ^:private OrderByPair
[:map-of
{:decode/dashboard-template (fn [x]
(if (string? x)
{x "ascending"}
x))}
Identifier
[:enum "descending" "ascending"]]) | |
(def ^:private Visualization
[:cat
{:decode/dashboard-template (fn [x]
(cond
(string? x) [x {}]
;; for malformed YAML when this comes back as a map
(map? x) (first x)
:else x))}
[:string {:decode/dashboard-template (fn [x]
(if (string? x)
x
(u/qualified-name x)))}]
[:* :map]]) | |
(def ^:private Width
[:int {:min 1, :max populate/grid-width}]) | |
(def ^:private Height pos-int?) | |
(def ^:private CardDimension
[:map-of
{:decode/dashboard-template (fn [x]
(if (string? x)
{x {}}
x))}
Identifier
[:map [:aggregation {:optional true} :string]]]) | |
(def ^:private Card
[:map-of
{:decode/dashboard-template
(with-defaults {:card-score max-score
:width populate/default-card-width
:height populate/default-card-height})}
Identifier
[:map
{:decode/dashboard-template (fn [x]
(if (sequential? x)
(into {} x)
x))}
[:title LocalizedString]
[:card-score Score]
[:visualization {:optional true} Visualization]
[:text {:optional true} LocalizedString]
[:dimensions {:optional true} [:maybe [:sequential {:decode/dashboard-template u/one-or-many} CardDimension]]]
[:filters {:optional true} [:maybe [:sequential {:decode/dashboard-template u/one-or-many} :string]]]
[:metrics {:optional true} [:maybe [:sequential {:decode/dashboard-template u/one-or-many} :string]]]
[:limit {:optional true} pos-int?]
[:order_by {:optional true} [:maybe [:sequential {:decode/dashboard-template u/one-or-many} OrderByPair]]]
[:description {:optional true} LocalizedString]
[:query {:optional true} :string]
[:width {:optional true} Width]
[:height {:optional true} Height]
[:group {:optional true} :string]
[:y_label {:optional true} LocalizedString]
[:x_label {:optional true} LocalizedString]
[:series_labels {:optional true} [:maybe [:sequential LocalizedString]]]]]) | |
(def ^:private Groups
[:map-of
{:decode/dashboard-template (fn [x]
(if (map? x)
x
(apply merge x)))}
Identifier
[:map
[:title LocalizedString]
[:score :int]
[:comparison_title {:optional true} LocalizedString]
[:description {:optional true} LocalizedString]]]) | |
Return | (def ^{:arglists '([definition])} identifier
(comp key first)) |
(def ^:private ^{:arglists '([definitions])} identifiers
(partial into #{"this"} (map identifier))) | |
(defn- all-references [k cards] (mapcat (comp k val first) cards)) | |
(def ^:private DimensionForm
[:cat
[:and
[:or :string :keyword]
[:fn
{:error/message ":dimension"}
(comp #{:dimension} qp.util/normalize-token)]]
:string
[:* :map]]) | |
Does form denote a dimension reference? | (def ^{:arglists '([form])} dimension-form?
(mr/validator DimensionForm)) |
Return all dimension references in form. | (defn collect-dimensions
[form]
(->> form
(tree-seq (some-fn map? sequential?) identity)
(mapcat (fn [subform]
(cond
(dimension-form? subform) [(second subform)]
(string? subform) (->> subform
(re-seq #"\[\[(\w+)\]\]")
(map second)))))
distinct)) |
(defn- valid-metrics-references?
[{:keys [metrics cards]}]
(every? (identifiers metrics) (all-references :metrics cards))) | |
(defn- valid-filters-references?
[{:keys [filters cards]}]
(every? (identifiers filters) (all-references :filters cards))) | |
(defn- valid-group-references?
[{:keys [cards groups]}]
(every? groups (keep (comp :group val first) cards))) | |
(defn- valid-order-by-references?
[{:keys [dimensions metrics cards]}]
(every? (comp (into (identifiers dimensions)
(identifiers metrics))
identifier)
(all-references :order_by cards))) | |
(defn- valid-dimension-references?
[{:keys [dimensions] :as dashboard-template}]
(every? (some-fn (identifiers dimensions) (comp table-type? ->entity))
(collect-dimensions dashboard-template))) | |
(defn- valid-dashboard-filters-references?
[{:keys [dimensions dashboard_filters]}]
(every? (identifiers dimensions) dashboard_filters)) | |
(defn- valid-breakout-dimension-references?
[{:keys [cards dimensions]}]
(->> cards
(all-references :dimensions)
(map identifier)
(every? (identifiers dimensions)))) | |
Specification defining an automagic dashboard. | (def DashboardTemplate
[:and
[:map
[:title LocalizedString]
[:dashboard-template-name :string]
[:specificity :int]
[:cards {:optional true} [:maybe [:sequential Card]]]
[:dimensions {:optional true} [:maybe [:sequential Dimension]]]
[:applies_to {:optional true} AppliesTo]
[:transient_title {:optional true} LocalizedString]
[:description {:optional true} LocalizedString]
[:metrics {:optional true} [:maybe [:sequential Metric]]]
[:filters {:optional true} [:maybe [:sequential Filter]]]
[:groups {:optional true} Groups]
[:indepth {:optional true} [:maybe [:sequential :any]]]
[:dashboard_filters {:optional true} [:maybe [:sequential {:decode/dashboard-template u/one-or-many} :string]]]]
[:fn {:error/message "Valid metrics references"} valid-metrics-references?]
[:fn {:error/message "Valid filters references"} valid-filters-references?]
[:fn {:error/message "Valid group references"} valid-group-references?]
[:fn {:error/message "Valid order_by references"} valid-order-by-references?]
[:fn {:error/message "Valid dashboard filters references"} valid-dashboard-filters-references?]
[:fn {:error/message "Valid dimension references"} valid-dimension-references?]
[:fn {:error/message "Valid card dimension references"} valid-breakout-dimension-references?]]) |
(def ^:private dashboard-templates-dir "automagic_dashboards/") | |
(def ^:private ^{:arglists '([f])} file->entity-type
(comp (partial re-find #".+(?=\.yaml$)") str (memfn ^Path getFileName))) | |
(defn- specificity [dashboard-template] (transduce (map (comp count ancestors)) + (:applies_to dashboard-template))) | |
Given a card definition from a template, fill in the card template with default width and height values based on the template display type if those dimensions aren't already present. | (defn- ensure-default-card-sizes
[card-spec]
(update-vals
card-spec
(fn [{:keys [visualization] :as card-spec}]
(let [defaults (get-in dashboards.constants/card-size-defaults [(keyword visualization) :default])]
(into defaults card-spec))))) |
Update the card template dimensions to align with the default FE dimensions. | (defn- set-default-card-dimensions [dashboard-template] (update dashboard-template :cards #(mapv ensure-default-card-sizes %))) |
(defn- coerce-to-dashboard-template [template]
(mc/coerce DashboardTemplate
template
(mtx/transformer
mtx/string-transformer
mtx/json-transformer
(mtx/transformer {:name :dashboard-template})))) | |
(defn- make-dashboard-template
[entity-type {:keys [cards] :as r}]
(-> (cond-> r
(seq cards)
(update :cards (partial mapv (fn [m] (update-vals m #(set/rename-keys % {:score :card-score}))))))
(assoc :dashboard-template-name entity-type
:specificity 0)
(update :applies_to #(or % entity-type))
set-default-card-dimensions
coerce-to-dashboard-template
(as-> dashboard-template
(assoc dashboard-template
:specificity (specificity dashboard-template))))) | |
(defn- trim-trailing-slash
[s]
(if (str/ends-with? s "/")
(subs s 0 (-> s count dec))
s)) | |
(defn- load-dashboard-template-dir
([dir]
(load-dashboard-template-dir dir [] {}))
([dir path dashboard-templates]
(with-open [ds (Files/newDirectoryStream dir)]
(reduce
(fn [acc ^Path f]
(let [entity-type (file->entity-type f)]
(cond
(Files/isDirectory f (into-array java.nio.file.LinkOption []))
(load-dashboard-template-dir f (->> f (.getFileName) str trim-trailing-slash (conj path)) acc)
entity-type
(let [template (try
(yaml/load (partial make-dashboard-template entity-type) f)
(catch Throwable e
(throw (ex-info (format "Error loading template %s: %s" (str f) (ex-message e))
{:path path, :f f}
e))))]
(assoc-in acc (concat path [entity-type ::leaf]) template))
:else
acc)))
dashboard-templates
ds)))) | |
(def ^:private dashboard-templates
(delay
(u.files/with-open-path-to-resource [path dashboard-templates-dir]
(into {} (load-dashboard-template-dir path))))) | |
Get all dashboard templates with prefix | (mu/defn get-dashboard-templates
[prefix :- [:sequential :string]]
(->> prefix
(get-in @dashboard-templates)
(keep (comp ::leaf val)))) |
Get dashboard template at path | (defn get-dashboard-template [path] (get-in @dashboard-templates (concat path [::leaf]))) |
(defn- extract-localized-strings
[[path dashboard-template]]
(let [strings (atom [])
template (coerce-to-dashboard-template dashboard-template)
i18n-string? (mr/validator LocalizedString)]
(walk/postwalk
(fn [form]
(when (i18n-string? form)
(swap! strings conj (str form))))
template)
(map vector (distinct @strings) (repeat path)))) | |
(defn- make-pot
[strings]
(->> strings
(group-by first)
(mapcat (fn [[s ctxs]]
(concat (for [[_ ctx] ctxs]
(format "#: resources/%s%s.yaml" dashboard-templates-dir (str/join "/" ctx)))
[(format "msgid \"%s\"\nmsgstr \"\"\n" s)])))
(str/join "\n"))) | |
(defn- all-dashboard-templates
([]
(all-dashboard-templates [] @dashboard-templates))
([path dashboard-templates]
(when (map? dashboard-templates)
(mapcat (fn [[k v]]
(if (= k ::leaf)
[[path v]]
(all-dashboard-templates (conj path k) v)))
dashboard-templates)))) | |
(defn- generate-templates! []
(->> (all-dashboard-templates)
(mapcat extract-localized-strings)
make-pot
(spit "locales/metabase-automatic-dashboards.pot"))) | |
Entry point for Clojure CLI task clojure -M:generate-automagic-dashboards-pot | (defn -main [& _options] (generate-templates!) (System/exit 0)) |