diff --git a/resources/automagic_dashboards/field/Country.yaml b/resources/automagic_dashboards/field/Country.yaml index fa493bb6e66d221a2461f005f143cc0bd20b9690..9cd91ba6b52cd88f3fc5c87d0a139ea99e38510d 100644 --- a/resources/automagic_dashboards/field/Country.yaml +++ b/resources/automagic_dashboards/field/Country.yaml @@ -38,7 +38,7 @@ groups: - Overview: title: Overview - Breakdowns: - title: How [[this]] are distributed + title: How the [[this]] is distributed cards: - Count: title: Count @@ -60,7 +60,7 @@ cards: group: Overview width: 6 - Distribution: - title: Distribution of [[this]] + title: How the [[this]] is distributed visualization: map: map.type: region diff --git a/resources/automagic_dashboards/field/DateTime.yaml b/resources/automagic_dashboards/field/DateTime.yaml index 8af35916858fee9120726dc52d316e1fb9e77ef5..346f802e8882b3a25a9d9b26f883e325bdf79d9d 100644 --- a/resources/automagic_dashboards/field/DateTime.yaml +++ b/resources/automagic_dashboards/field/DateTime.yaml @@ -32,9 +32,9 @@ groups: - Overview: title: Overview - Breakdowns: - title: How [[this]] is distributed + title: How the [[this]] is distributed - Seasonality: - title: Seasonal patterns in [[this]] + title: Seasonal patterns in the [[this]] cards: - Count: title: Count diff --git a/resources/automagic_dashboards/field/GenericField.yaml b/resources/automagic_dashboards/field/GenericField.yaml index 71d7143394f2565736faabb1212144b36bedb305..fe5396453e26c0e6edfa2e0a58b5e0d5c94f9a8b 100644 --- a/resources/automagic_dashboards/field/GenericField.yaml +++ b/resources/automagic_dashboards/field/GenericField.yaml @@ -38,7 +38,7 @@ groups: - Overview: title: Overview - Breakdowns: - title: How [[this]] are distributed + title: How the [[this]] is distributed cards: - Count: title: Count @@ -60,7 +60,7 @@ cards: group: Overview width: 6 - Distribution: - title: How [[this]] is distributed + title: How the [[this]] is distributed visualization: bar metrics: Count dimensions: this @@ -68,12 +68,13 @@ cards: width: 12 - ByNumber: title: "[[GenericNumber]] by [[this]]" - visualization: line + visualization: bar metrics: - Sum - Avg dimensions: this group: Breakdowns + height: 8 - Crosstab: title: "[[this]] by [[GenericCategoryMedium]]" visualization: table diff --git a/resources/automagic_dashboards/field/Number.yaml b/resources/automagic_dashboards/field/Number.yaml index 937e04ec5c0dfeb17a1e3476c76ea85495433b9a..01a3864a03780028d76f0b91e6068583ceceec84 100644 --- a/resources/automagic_dashboards/field/Number.yaml +++ b/resources/automagic_dashboards/field/Number.yaml @@ -38,11 +38,11 @@ groups: - Overview: title: Overview - Breakdowns: - title: How [[this]] is distributed across categories + title: How the [[this]] is distributed across categories - Seasonality: - title: How [[this]] changes with time + title: How the [[this]] changes with time - Geographical: - title: How [[this]] is distributed geographically + title: How the [[this]] is distributed geographically cards: - Count: title: Count @@ -75,7 +75,7 @@ cards: group: Overview width: 9 - Distribution: - title: How [[this]] is distributed + title: How the [[this]] is distributed visualization: bar metrics: Count dimensions: diff --git a/resources/automagic_dashboards/field/State.yaml b/resources/automagic_dashboards/field/State.yaml index 2df61dca59ed4c93b5e5eb2c57ca5ff746e10690..f6c19178b715e7573c616ae8d185fa75316a7842 100644 --- a/resources/automagic_dashboards/field/State.yaml +++ b/resources/automagic_dashboards/field/State.yaml @@ -38,7 +38,7 @@ groups: - Overview: title: Overview - Breakdowns: - title: How [[this]] are distributed + title: How the [[this]] is distributed cards: - Count: title: Count diff --git a/resources/automagic_dashboards/metric/GenericMetric.yaml b/resources/automagic_dashboards/metric/GenericMetric.yaml index 46287df7733281d6a26caeae4dc995a39a2901e2..f76fa1582a60e4a626713555d609a7a48f3df03d 100644 --- a/resources/automagic_dashboards/metric/GenericMetric.yaml +++ b/resources/automagic_dashboards/metric/GenericMetric.yaml @@ -1,5 +1,5 @@ -title: A look at your [[this]] -transient_title: Here's a quick look at your [[this]] +title: A look at the [[this]] +transient_title: Here's a quick look at the [[this]] description: How it's distributed across time and other categories. applies_to: GenericTable metrics: @@ -35,15 +35,17 @@ dimensions: field_type: GenericTable.ZipCode groups: - Periodicity: - title: "[[this]] over time" + title: The [[this]] over time - Geographical: - title: "[[this]] by location" + title: The [[this]] by location - Categories: - title: How [[this]] is distributed across different categories + title: How this metric is distributed across different categories - Numbers: - title: How [[this]] is distributed across different numbers - - LargeCategories: - title: Top and bottom [[this]] + title: How this metric is distributed across different numbers + - LargeCategoriesTop: + title: Top 5 per category + - LargeCategoriesBottom: + title: Bottom 5 per category dashboard_filters: - Timestamp - State @@ -119,9 +121,11 @@ cards: map.region: us_states - ByNumber: group: Numbers - title: How [[this]] is distributed across [[GenericNumber]] + title: "[[this]] by [[GenericNumber]]" metrics: this - dimensions: GenericNumber + dimensions: + - GenericNumber: + aggregation: default visualization: bar - ByCategoryMedium: group: Categories @@ -151,7 +155,7 @@ cards: order_by: this: descending - ByCategoryLargeTop: - group: LargeCategories + group: LargeCategoriesTop title: "[[this]] per [[GenericCategoryLarge]], top 5" metrics: this dimensions: GenericCategoryLarge @@ -160,7 +164,7 @@ cards: this: descending limit: 5 - ByCategoryLargeBottom: - group: LargeCategories + group: LargeCategoriesBottom title: "[[this]] per [[GenericCategoryLarge]], bottom 5" metrics: this dimensions: GenericCategoryLarge diff --git a/resources/automagic_dashboards/question/GenericQuestion.yaml b/resources/automagic_dashboards/question/GenericQuestion.yaml index a7d0cfaa713b033f4af334319fbefdb4fbc5fcaa..ad7a7b8b067df81e1decbd24ceb1b06efb700844 100644 --- a/resources/automagic_dashboards/question/GenericQuestion.yaml +++ b/resources/automagic_dashboards/question/GenericQuestion.yaml @@ -1,4 +1,4 @@ title: "A closer look at your [[this]]" transient_title: "Here's a closer look at your [[this]]" -description: Here is breakdown of metrics and dimensions used in [[this]]. -applies_to: GenericTable \ No newline at end of file +description: A closer look at the metrics and dimensions used in this saved question. +applies_to: GenericTable diff --git a/resources/automagic_dashboards/table/GenericTable.yaml b/resources/automagic_dashboards/table/GenericTable.yaml index cb9744168ce4201e9fcf07687c4cd9c450e53fae..d06fc4986ef3c61f0a399bb5782081e23fb1c5ef 100644 --- a/resources/automagic_dashboards/table/GenericTable.yaml +++ b/resources/automagic_dashboards/table/GenericTable.yaml @@ -20,10 +20,6 @@ dimensions: - Source: field_type: GenericTable.Source score: 100 - - GenericCategorySmall: - field_type: GenericTable.Category - score: 80 - max_cardinality: 5 - GenericCategoryMedium: field_type: GenericTable.Category score: 75 @@ -91,13 +87,13 @@ groups: - Overview: title: Summary - Singletons: - title: These are the same for all your [[this]] + title: These are the same for all your [[this.short-name]] - ByTime: - title: "[[this]] across time" + title: "These [[this.short-name]] across time" - Geographical: - title: Where your [[this]] are + title: Where your [[this.short-name]] are - General: - title: How [[this]] are distributed + title: How these [[this.short-name]] are distributed dashboard_filters: - Timestamp - Date @@ -112,13 +108,13 @@ dashboard_filters: cards: # Overview - Rowcount: - title: Total [[this]] + title: Total [[this.short-name]] visualization: scalar metrics: Count score: 100 group: Overview - RowcountLast30Days: - title: New [[this]] in the last 30 days + title: "[[this.short-name]] added in the last 30 days" visualization: scalar metrics: Count score: 100 @@ -132,7 +128,7 @@ cards: group: Overview # General - NumberDistribution: - title: How [[this]] are distributed across [[GenericNumber]] + title: "[[this.short-name]] by [[GenericNumber]]" dimensions: - GenericNumber: aggregation: default @@ -141,7 +137,7 @@ cards: score: 90 group: General - CountByCategoryMedium: - title: "[[this]] per [[GenericCategoryMedium]]" + title: "[[this.short-name]] per [[GenericCategoryMedium]]" dimensions: GenericCategoryMedium metrics: Count visualization: row @@ -151,7 +147,7 @@ cards: order_by: - Count: descending - CountByCategoryLarge: - title: "[[this]] per [[GenericCategoryLarge]]" + title: "[[this.short-name]] per [[GenericCategoryLarge]]" dimensions: GenericCategoryLarge metrics: Count visualization: table @@ -162,7 +158,7 @@ cards: - Count: descending # Geographical - CountByCountry: - title: "[[this]] per country" + title: "[[this.short-name]] per country" metrics: Count dimensions: Country score: 90 @@ -173,7 +169,7 @@ cards: group: Geographical height: 6 - CountByState: - title: "[[this]] per state" + title: "[[this.short-name]] per state" metrics: Count dimensions: State score: 90 @@ -184,7 +180,7 @@ cards: group: Geographical height: 6 - CountByCoords: - title: "[[this]] by coordinates" + title: "[[this.short-name]] by coordinates" metrics: Count dimensions: - Long @@ -195,42 +191,42 @@ cards: height: 6 # By Time - CountByJoinDate: - title: "[[this]] that have joined over time" + title: "[[this.short-name]] that have joined over time" visualization: line dimensions: JoinTimestamp metrics: Count score: 90 group: ByTime - CountByJoinDate: - title: "[[this]] that have joined over time" + title: "[[this.short-name]] that have joined over time" visualization: line dimensions: JoinDate metrics: Count score: 90 group: ByTime - CountByCreateDate: - title: New [[this]] over time + title: New [[this.short-name]] over time visualization: line dimensions: CreateTimestamp metrics: Count score: 90 group: ByTime - CountByCreateDate: - title: New [[this]] over time + title: New [[this.short-name]] over time visualization: line dimensions: CreateDate metrics: Count score: 90 group: ByTime - CountByTimestamp: - title: "[[this]] by [[Timestamp]]" + title: "[[this.short-name]] by [[Timestamp]]" visualization: line dimensions: Timestamp metrics: Count score: 20 group: ByTime - CountByTimestamp: - title: "[[this]] by [[Timestamp]]" + title: "[[this.short-name]] by [[Timestamp]]" visualization: line dimensions: Date metrics: Count @@ -371,7 +367,7 @@ cards: group: ByTime x_label: "[[Timestamp]]" - DayOfWeekCreateDate: - title: Weekdays when new [[this]] were added + title: Weekdays when [[this.short-name]] were added visualization: bar dimensions: - CreateTimestamp: @@ -381,7 +377,7 @@ cards: group: ByTime x_label: Created At by day of the week - DayOfWeekCreateDate: - title: Weekdays when new [[this]] were added + title: Weekdays when [[this.short-name]] were added visualization: bar dimensions: - CreateDate: @@ -391,7 +387,7 @@ cards: group: ByTime x_label: Created At by day of the week - HourOfDayCreateDate: - title: Hours when new [[this]] were added + title: Hours when [[this.short-name]] were added visualization: bar dimensions: - CreateTimestamp: @@ -401,7 +397,7 @@ cards: group: ByTime x_label: Created At by hour of the day - HourOfDayCreateDate: - title: Hours when new [[this]] were added + title: Hours when [[this.short-name]] were added visualization: bar dimensions: - CreateTime: @@ -411,7 +407,7 @@ cards: group: ByTime x_label: Created At by hour of the day - DayOfMonthCreateDate: - title: Days when new [[this]] were added + title: Days when [[this.short-name]] were added visualization: bar dimensions: - CreateTimestamp: @@ -421,7 +417,7 @@ cards: group: ByTime x_label: Created At by day of the month - DayOfMonthCreateDate: - title: Days when new [[this]] were added + title: Days when [[this.short-name]] were added visualization: bar dimensions: - CreateDate: @@ -431,7 +427,7 @@ cards: group: ByTime x_label: Created At by day of the month - MonthOfYearCreateDate: - title: Months when new [[this]] were added + title: Months when [[this.short-name]] were added visualization: bar dimensions: - CreateTimestamp: @@ -441,7 +437,7 @@ cards: group: ByTime x_label: Created At by month of the year - MonthOfYearCreateDate: - title: Months when new [[this]] were added + title: Months when [[this.short-name]] were added visualization: bar dimensions: - CreateDate: @@ -451,7 +447,7 @@ cards: group: ByTime x_label: Created At by month of the year - QuerterOfYearCreateDate: - title: Quarters when new [[this]] were added + title: Quarters when [[this.short-name]] were added visualization: bar dimensions: - CreateTimestamp: @@ -461,7 +457,7 @@ cards: group: ByTime x_label: Created At by quarter of the year - QuerterOfYearCreateDate: - title: Quarters when new [[this]] were added + title: Quarters when [[this.short-name]] were added visualization: bar dimensions: - CreateDate: @@ -471,7 +467,7 @@ cards: group: ByTime x_label: Created At by quarter of the year - DayOfWeekJoinDate: - title: Weekdays when [[this]] joined + title: Weekdays when [[this.short-name]] joined visualization: bar dimensions: - JoinTimestamp: @@ -481,7 +477,7 @@ cards: group: ByTime x_label: Join date by day of the week - DayOfWeekJoinDate: - title: Weekdays when [[this]] joined + title: Weekdays when [[this.short-name]] joined visualization: bar dimensions: - JoinDate: @@ -491,7 +487,7 @@ cards: group: ByTime x_label: Join date by day of the week - HourOfDayJoinDate: - title: Hours when [[this]] joined + title: Hours when [[this.short-name]] joined visualization: bar dimensions: - JoinTimestamp: @@ -501,7 +497,7 @@ cards: group: ByTime x_label: Join date by hour of the day - HourOfDayJoinDate: - title: Hours when [[this]] joined + title: Hours when [[this.short-name]] joined visualization: bar dimensions: - JoinTime: @@ -511,7 +507,7 @@ cards: group: ByTime x_label: Join date by hour of the day - DayOfMonthJoinDate: - title: Days of the month when [[this]] joined + title: Days of the month when [[this.short-name]] joined visualization: bar dimensions: - JoinTimestamp: @@ -521,7 +517,7 @@ cards: group: ByTime x_label: Join date by day of the month - DayOfMonthJoinDate: - title: Days of the month when [[this]] joined + title: Days of the month when [[this.short-name]] joined visualization: bar dimensions: - JoinDate: @@ -531,7 +527,7 @@ cards: group: ByTime x_label: Join date by day of the month - MonthOfYearJoinDate: - title: Months when [[this]] joined + title: Months when [[this.short-name]] joined visualization: bar dimensions: - JoinTimestamp: @@ -541,7 +537,7 @@ cards: group: ByTime x_label: Join date by month of the year - MonthOfYearJoinDate: - title: Months when [[this]] joined + title: Months when [[this.short-name]] joined visualization: bar dimensions: - JoinDate: @@ -551,7 +547,7 @@ cards: group: ByTime x_label: Join date by month of the year - QuerterOfYearJoinDate: - title: Quarters when [[this]] joined + title: Quarters when [[this.short-name]] joined visualization: bar dimensions: - JoinTimestamp: @@ -561,7 +557,7 @@ cards: group: ByTime x_label: Join date by quarter of the year - QuerterOfYearJoinDate: - title: Quarters when [[this]] joined + title: Quarters when [[this.short-name]] joined visualization: bar dimensions: - JoinDate: diff --git a/resources/automagic_dashboards/table/UserTable.yaml b/resources/automagic_dashboards/table/UserTable.yaml index 1844a20527cb0c73019e8a347b9a960085c61c08..b6297d02f83611b6ea7cdda861c6dbfd49c57668 100644 --- a/resources/automagic_dashboards/table/UserTable.yaml +++ b/resources/automagic_dashboards/table/UserTable.yaml @@ -23,14 +23,11 @@ dimensions: - JoinDate: field_type: Date score: 30 -- Source: - field_type: Source - GenericNumber: field_type: GenericTable.Number score: 80 - Source: field_type: GenericTable.Source - score: 100 - GenericCategoryMedium: field_type: GenericTable.Category score: 75 @@ -54,9 +51,9 @@ groups: - Overview: title: Overview - Geographical: - title: Where these [[this]] are + title: Where these [[this.short-name]] are - General: - title: How these [[this]] are distributed + title: How these [[this.short-name]] are distributed dashboard_filters: - JoinDate - GenericCategoryMedium @@ -66,7 +63,7 @@ dashboard_filters: cards: # Overview - Rowcount: - title: Total [[this]] + title: Total [[this.short-name]] visualization: scalar metrics: Count score: 100 @@ -74,7 +71,7 @@ cards: width: 5 height: 3 - RowcountLast30Days: - title: New [[this]] in the last 30 days + title: New [[this.short-name]] in the last 30 days visualization: scalar metrics: Count score: 100 @@ -84,8 +81,7 @@ cards: height: 3 - NewUsersByMonth: visualization: line - title: New [[this]] per month - description: The number of new [[this]] each month + title: New [[this.short-name]] per month dimensions: JoinDate metrics: Count score: 100 @@ -94,7 +90,7 @@ cards: height: 7 # Geographical - CountByCountry: - title: Number of [[this]] per country + title: Per country metrics: Count dimensions: Country score: 90 @@ -104,7 +100,7 @@ cards: map.region: world_countries group: Geographical - CountByState: - title: "[[this]] per state" + title: "Per state" metrics: Count dimensions: State score: 90 @@ -115,7 +111,7 @@ cards: map.region: us_states group: Geographical - CountByCoords: - title: "[[this]] by coordinates" + title: "By coordinates" metrics: Count dimensions: - Long @@ -135,7 +131,7 @@ cards: score: 90 group: General - CountByCategoryMedium: - title: "[[this]] per [[GenericCategoryMedium]]" + title: "Per [[GenericCategoryMedium]]" dimensions: GenericCategoryMedium metrics: Count visualization: row @@ -144,8 +140,18 @@ cards: group: General order_by: - Count: descending + - CountBySource: + title: "Per [[Source]]" + dimensions: Source + metrics: Count + visualization: row + score: 80 + height: 8 + group: General + order_by: + - Count: descending - CountByCategoryLarge: - title: "[[this]] per [[GenericCategoryLarge]]" + title: "Per [[GenericCategoryLarge]]" dimensions: GenericCategoryLarge metrics: Count visualization: table diff --git a/src/metabase/automagic_dashboards/core.clj b/src/metabase/automagic_dashboards/core.clj index d0610e428eced2a73c309e6f916152ca8ce4572c..641b40a1239bbdf57cf9f4c7f7d4775eff205c5e 100644 --- a/src/metabase/automagic_dashboards/core.clj +++ b/src/metabase/automagic_dashboards/core.clj @@ -32,10 +32,12 @@ [metabase.related :as related] [metabase.sync.analyze.classify :as classify] [metabase.util :as u] + [metabase.util.date :as date] [puppetlabs.i18n.core :as i18n :refer [tru trs]] [ring.util.codec :as codec] [schema.core :as s] - [toucan.db :as db])) + [toucan.db :as db]) + (:import java.util.TimeZone)) (def ^:private public-endpoint "/auto/dashboard/") @@ -46,57 +48,80 @@ [root id-or-name] (if (->> root :source (instance? (type Table))) (Field id-or-name) - (let [field (->> root + (when-let [field (->> root :source :result_metadata - (some (comp #{id-or-name} :name)))] + (m/find-first (comp #{id-or-name} :name)))] (-> field (update :base_type keyword) (update :special_type keyword) field/map->FieldInstance (classify/run-classifiers {}))))) -(defn- metric->description - [root metric] - (let [aggregation-clause (-> metric :definition :aggregation first) - field (some->> aggregation-clause - second - filters/field-reference->id - (->field root))] - (if field - (tru "{0} of {1}" (-> aggregation-clause first name str/capitalize) (:display_name field)) - (-> aggregation-clause first name str/capitalize)))) +(def ^:private ^{:arglists '([root])} source-name + (comp (some-fn :display_name :name) :source)) + +(def ^:private op->name + {:sum (tru "sum") + :avg (tru "average") + :min (tru "minumum") + :max (tru "maximum") + :count (tru "number") + :distinct (tru "distinct count") + :stddev (tru "standard deviation") + :cum-count (tru "cumulative count") + :cum-sum (tru "cumulative sum")}) + +(def ^:private ^{:arglists '([metric])} saved-metric? + (comp #{:metric} qp.util/normalize-token first)) + +(def ^:private ^{:arglists '([metric])} custom-expression? + (comp #{:named} qp.util/normalize-token first)) + +(def ^:private ^{:arglists '([metric])} adhoc-metric? + (complement (some-fn saved-metric? custom-expression?))) + +(defn- metric-name + [[op & args :as metric]] + (cond + (adhoc-metric? metric) (-> op qp.util/normalize-token op->name) + (saved-metric? metric) (-> args first Metric :name) + :else (second args))) (defn- join-enumeration - [[x & xs]] - (if xs + [xs] + (if (next xs) (tru "{0} and {1}" (str/join ", " (butlast xs)) (last xs)) - x)) + (first xs))) + +(defn- metric->description + [root aggregation-clause] + (join-enumeration + (for [metric (if (sequential? (first aggregation-clause)) + aggregation-clause + [aggregation-clause])] + (if (adhoc-metric? metric) + (tru "{0} of {1}" (metric-name metric) (or (some->> metric + second + filters/field-reference->id + (->field root) + :display_name) + (source-name root))) + (metric-name metric))))) (defn- question-description [root question] (let [aggregations (->> (qp.util/get-in-normalized question [:dataset_query :query :aggregation]) - (map (fn [[op arg]] - (cond - (-> op qp.util/normalize-token (= :metric)) - (-> arg Metric :name) - - arg - (tru "{0} of {1}" (name op) (->> arg - filters/field-reference->id - (->field root) - :display_name)) - - :else - (name op)))) - join-enumeration) + (metric->description root)) dimensions (->> (qp.util/get-in-normalized question [:dataset_query :query :breakout]) (mapcat filters/collect-field-references) (map (comp :display_name (partial ->field root) filters/field-reference->id)) join-enumeration)] - (tru "{0} by {1}" aggregations dimensions))) + (if dimensions + (tru "{0} by {1}" aggregations dimensions) + aggregations))) (def ^:private ^{:arglists '([x])} encode-base64-json (comp codec/base64-encode codecs/str->bytes json/encode)) @@ -112,6 +137,7 @@ :full-name (if (isa? (:entity_type table) :entity/GoogleAnalyticsTable) (:display_name table) (tru "{0} table" (:display_name table))) + :short-name (:display_name table) :source table :database (:db_id table) :url (format "%stable/%s" public-endpoint (u/get-id table)) @@ -121,10 +147,11 @@ [segment] (let [table (-> segment :table_id Table)] {:entity segment - :full-name (tru "{0} segment" (:name segment)) + :full-name (tru "{0} in the {1} segment" (:display_name table) (:name segment)) + :short-name (:display_name table) :source table :database (:db_id table) - :query-filter (-> segment :definition :filter) + :query-filter [:SEGMENT (u/get-id segment)] :url (format "%ssegment/%s" public-endpoint (u/get-id segment)) :rules-prefix ["table"]})) @@ -132,7 +159,10 @@ [metric] (let [table (-> metric :table_id Table)] {:entity metric - :full-name (tru "{0} metric" (:name metric)) + :full-name (if (:id metric) + (tru "{0} metric" (:name metric)) + (:name metric)) + :short-name (:name metric) :source table :database (:db_id table) ;; We use :id here as it might not be a concrete field but rather one from a nested query which @@ -145,6 +175,7 @@ (let [table (field/table field)] {:entity field :full-name (tru "{0} field" (:display_name field)) + :short-name (:display_name field) :source table :database (:db_id table) ;; We use :id here as it might not be a concrete metric but rather one from a nested query @@ -176,31 +207,35 @@ source-question (assoc :entity_type :entity/GenericTable)) (native-query? card) (-> card (assoc :entity_type :entity/GenericTable)) - :else (-> card ((some-fn :table_id :table-id)) Table))) + :else (-> card (qp.util/get-normalized :table-id) Table))) (defmethod ->root (type Card) [card] - {:entity card - :source (source card) - :database (:database_id card) - :query-filter (qp.util/get-in-normalized card [:dataset_query :query :filter]) - :full-name (tru "{0} question" (:name card)) - :url (format "%squestion/%s" public-endpoint (u/get-id card)) - :rules-prefix [(if (table-like? card) - "table" - "question")]}) + (let [source (source card)] + {:entity card + :source source + :database (:database_id card) + :query-filter (qp.util/get-in-normalized card [:dataset_query :query :filter]) + :full-name (tru "\"{0}\" question" (:name card)) + :short-name (source-name {:source source}) + :url (format "%squestion/%s" public-endpoint (u/get-id card)) + :rules-prefix [(if (table-like? card) + "table" + "question")]})) (defmethod ->root (type Query) [query] - (let [source (source query)] + (let [source (source query)] {:entity query :source source :database (:database-id query) + :query-filter (qp.util/get-in-normalized query [:dataset_query :query :filter]) :full-name (cond (native-query? query) (tru "Native query") (table-like? query) (-> source ->root :full-name) :else (question-description {:source source} query)) - :url (format "%sadhoc/%s" public-endpoint (encode-base64-json query)) + :short-name (source-name {:source source}) + :url (format "%sadhoc/%s" public-endpoint (encode-base64-json (:dataset_query query))) :rules-prefix [(if (table-like? query) "table" "question")]})) @@ -349,8 +384,13 @@ bindings) (comp first #(filter-tables % tables) rules/->entity) identity)] - (str/replace s #"\[\[(\w+)\]\]" (fn [[_ identifier]] - (->reference template-type (bindings identifier)))))) + (str/replace s #"\[\[(\w+)(?:\.([\w\-]+))?\]\]" + (fn [[_ identifier attribute]] + (let [entity (bindings identifier) + attribute (some-> attribute qp.util/normalize-token)] + (or (and (ifn? entity) (entity attribute)) + (root attribute) + (->reference template-type entity))))))) (defn- field-candidates [context {:keys [field_type links_to named max_cardinality] :as constraints}] @@ -440,9 +480,9 @@ (-> context :source u/get-id) (->> context :source u/get-id (str "card__")))} (not-empty filters) - (assoc :filter (transduce (map :filter) - merge-filter-clauses - filters)) + (assoc :filter (->> filters + (map :filter) + (apply merge-filter-clauses))) (not-empty dimensions) (assoc :breakout dimensions) @@ -487,21 +527,41 @@ (u/update-when :graph.metrics metric->name) (u/update-when :graph.dimensions dimension->name))])) +(defn- capitalize-first + [s] + (str (str/upper-case (subs s 0 1)) (subs s 1))) + (defn- instantiate-metadata [x context bindings] (-> (walk/postwalk (fn [form] (if (string? form) - (fill-templates :string context bindings form) + (let [new-form (fill-templates :string context bindings form)] + (if (not= new-form form) + (capitalize-first new-form) + new-form)) form)) x) (u/update-when :visualization #(instantate-visualization % bindings (:metrics context))))) (defn- valid-breakout-dimension? - [{:keys [base_type engine] :as f}] + [{:keys [base_type engine]}] (not (and (isa? base_type :type/Number) (= engine :druid)))) +(defn- singular-cell-dimensions + [root] + (letfn [(collect-dimensions [[op & args]] + (case (some-> op qp.util/normalize-token) + :and (mapcat collect-dimensions args) + := (filters/collect-field-references args) + nil))] + (->> root + :cell-query + collect-dimensions + (map filters/field-reference->id) + set))) + (defn- card-candidates "Generate all potential cards given a card definition and bindings for dimensions, metrics, and filters." @@ -509,8 +569,7 @@ (let [order_by (build-order-by dimensions metrics order_by) metrics (map (partial get (:metrics context)) metrics) filters (cond-> (map (partial get (:filters context)) filters) - (:query-filter context) - (conj {:filter (:query-filter context)})) + (:query-filter context) (conj {:filter (:query-filter context)})) score (if query score (* (or (->> dimensions @@ -520,33 +579,35 @@ rules/max-score) (/ score rules/max-score))) dimensions (map (comp (partial into [:dimension]) first) dimensions) - used-dimensions (rules/collect-dimensions [dimensions metrics filters query])] + used-dimensions (rules/collect-dimensions [dimensions metrics filters query]) + cell-dimension? (->> context :root singular-cell-dimensions)] (->> used-dimensions (map (some-fn #(get-in (:dimensions context) [% :matches]) (comp #(filter-tables % (:tables context)) rules/->entity))) (apply combo/cartesian-product) - (filter (fn [instantiations] + (map (partial zipmap used-dimensions)) + (filter (fn [bindings] (->> dimensions - (map (comp (zipmap used-dimensions instantiations) second)) - (every? valid-breakout-dimension?)))) - (map (fn [instantiations] - (let [bindings (zipmap used-dimensions instantiations) - query (if query - (build-query context bindings query) - (build-query context bindings - filters - metrics - dimensions - limit - order_by))] + (map (comp bindings second)) + (every? (every-pred valid-breakout-dimension? + (complement (comp cell-dimension? id-or-name))))))) + (map (fn [bindings] + (let [query (if query + (build-query context bindings query) + (build-query context bindings + filters + metrics + dimensions + limit + order_by))] (-> card - (assoc :metrics metrics) (instantiate-metadata context (->> metrics (map :name) (zipmap (:metrics card)) (merge bindings))) - (assoc :score score - :dataset_query query)))))))) + (assoc :dataset_query query + :metrics (map (some-fn :name (comp metric-name :metric)) metrics) + :score score)))))))) (defn- matching-rules "Return matching rules orderd by specificity. @@ -632,7 +693,7 @@ (update :special_type keyword) field/map->FieldInstance (classify/run-classifiers {}) - (map #(assoc % :engine engine))))) + (assoc :engine engine)))) constantly))] (as-> {:source (assoc source :fields (table->fields source)) :root root @@ -664,8 +725,7 @@ ([root rule context] (-> rule (select-keys [:title :description :transient_title :groups]) - (instantiate-metadata context {}) - (assoc :refinements (:cell-query root))))) + (instantiate-metadata context {})))) (s/defn ^:private apply-rule [root, rule :- rules/Rule] @@ -673,15 +733,16 @@ dashboard (make-dashboard root rule context) filters (->> rule :dashboard_filters - (mapcat (comp :matches (:dimensions context)))) + (mapcat (comp :matches (:dimensions context))) + (remove (comp (singular-cell-dimensions root) id-or-name))) cards (make-cards context rule)] (when (or (not-empty cards) (-> rule :cards nil?)) [(assoc dashboard - :filters filters - :cards cards - :context context) - rule]))) + :filters filters + :cards cards) + rule + context]))) (def ^:private ^:const ^Long max-related 6) (def ^:private ^:const ^Long max-cards 15) @@ -709,16 +770,15 @@ [root, rule :- (s/maybe rules/Rule)] (->> (rules/get-rules (concat (:rules-prefix root) [(:rule rule)])) (keep (fn [indepth] - (when-let [[dashboard _] (apply-rule root indepth)] + (when-let [[dashboard _ _] (apply-rule root indepth)] {:title ((some-fn :short-title :title) dashboard) :description (:description dashboard) :url (format "%s/rule/%s/%s" (:url root) (:rule rule) (:rule indepth))}))) (hash-map :indepth))) (defn- drilldown-fields - [dashboard] - (->> dashboard - :context + [context] + (->> context :dimensions vals (mapcat :matches) @@ -786,47 +846,57 @@ [sideways sideways sideways down down up])}) (s/defn ^:private related - "Build a balancee list of related X-rays. General composition of the list is determined for each + "Build a balanced list of related X-rays. General composition of the list is determined for each root type individually via `related-selectors`. That recepie is then filled round-robin style." - [dashboard, rule :- (s/maybe rules/Rule)] - (let [root (-> dashboard :context :root)] - (->> (merge (indepth root rule) - (drilldown-fields dashboard) - (related-entities root)) - (fill-related max-related (related-selectors (-> root :entity type))) - (group-by :selector) - (m/map-vals (partial map :entity))))) + [{:keys [root] :as context}, rule :- (s/maybe rules/Rule)] + (->> (merge (indepth root rule) + (drilldown-fields context) + (related-entities root)) + (fill-related max-related (related-selectors (-> root :entity type))) + (group-by :selector) + (m/map-vals (partial map :entity)))) + +(defn- filter-referenced-fields + "Return a map of fields referenced in filter cluase." + [root filter-clause] + (->> filter-clause + filters/collect-field-references + (mapcat (fn [[_ & ids]] + (for [id ids] + [id (->field root id)]))) + (remove (comp nil? second)) + (into {}))) (defn- automagic-dashboard "Create dashboards for table `root` using the best matching heuristics." - [{:keys [rule show rules-prefix query-filter cell-query full-name] :as root}] - (if-let [[dashboard rule] (if rule - (apply-rule root (rules/get-rule rule)) - (->> root - (matching-rules (rules/get-rules rules-prefix)) - (keep (partial apply-rule root)) - ;; `matching-rules` returns an `ArraySeq` (via `sort-by`) so - ;; `first` realises one element at a time (no chunking). - first))] - (do + [{:keys [rule show rules-prefix full-name] :as root}] + (if-let [[dashboard rule context] (if rule + (apply-rule root (rules/get-rule rule)) + (->> root + (matching-rules (rules/get-rules rules-prefix)) + (keep (partial apply-rule root)) + ;; `matching-rules` returns an `ArraySeq` (via `sort-by`) + ;; so `first` realises one element at a time + ;; (no chunking). + first))] + (let [show (or show max-cards)] (log/infof (trs "Applying heuristic %s to %s.") (:rule rule) full-name) (log/infof (trs "Dimensions bindings:\n%s") - (->> dashboard - :context + (->> context :dimensions (m/map-vals #(update % :matches (partial map :name))) u/pprint-to-str)) (log/infof (trs "Using definitions:\nMetrics:\n%s\nFilters:\n%s") - (-> dashboard :context :metrics u/pprint-to-str) - (-> dashboard :context :filters u/pprint-to-str)) - (-> (cond-> dashboard - (or query-filter cell-query) - (assoc :title (tru "A closer look at {0}" full-name))) - (populate/create-dashboard (or show max-cards)) - (assoc :related (related dashboard rule)) - (assoc :more (when (and (-> dashboard :cards count (> max-cards)) - (not= show :all)) - (format "%s#show=all" (:url root)))))) + (-> context :metrics u/pprint-to-str) + (-> context :filters u/pprint-to-str)) + (-> dashboard + (populate/create-dashboard show) + (assoc :related (related context rule) + :more (when (and (not= show :all) + (-> dashboard :cards count (> show))) + (format "%s#show=all" (:url root))) + :transient_filters (:query-filter context) + :param_fields (->> context :query-filter (filter-referenced-fields root))))) (throw (ex-info (trs "Can''t create dashboard for {0}" full-name) {:root root :available-rules (map :rule (or (some-> rule rules/get-rule vector) @@ -858,11 +928,11 @@ qp.util/normalize-token (= :metric)) (-> aggregation-clause second Metric) - (let [metric (metric/map->MetricInstance - {:definition {:aggregation [aggregation-clause] - :source_table (:table_id question)} - :table_id (:table_id question)})] - (assoc metric :name (metric->description root metric))))) + (let [table-id (qp.util/get-normalized question :table-id)] + (metric/map->MetricInstance {:definition {:aggregation [aggregation-clause] + :source_table table-id} + :name (metric->description root aggregation-clause) + :table_id table-id})))) (qp.util/get-in-normalized question [:dataset_query :query :aggregation]))) (defn- collect-breakout-fields @@ -876,48 +946,158 @@ (defn- decompose-question [root question opts] (map #(automagic-analysis % (assoc opts - :source (:source root) - :database (:database root))) + :source (:source root) + :query-filter (:query-filter root) + :database (:database root))) (concat (collect-metrics root question) (collect-breakout-fields root question)))) +(defn- pluralize + [x] + (case (mod x 10) + 1 (tru "{0}st" x) + 2 (tru "{0}nd" x) + 3 (tru "{0}rd" x) + (tru "{0}th" x))) + +(defn- humanize-datetime + [dt unit] + (let [dt (date/str->date-time dt) + tz (.getID ^TimeZone @date/jvm-timezone) + unparse-with-formatter (fn [formatter dt] + (t.format/unparse + (t.format/formatter formatter (t/time-zone-for-id tz)) + dt))] + (case unit + :minute (tru "at {0}" (unparse-with-formatter "h:mm a, MMMM d, YYYY" dt)) + :hour (tru "at {0}" (unparse-with-formatter "h a, MMMM d, YYYY" dt)) + :day (tru "on {0}" (unparse-with-formatter "MMMM d, YYYY" dt)) + :week (tru "in {0} week - {1}" + (pluralize (date/date-extract :week-of-year dt tz)) + (str (date/date-extract :year dt tz))) + :month (tru "in {0}" (unparse-with-formatter "MMMM YYYY" dt)) + :quarter (tru "in Q{0} - {1}" + (date/date-extract :quarter-of-year dt tz) + (str (date/date-extract :year dt tz))) + :year (unparse-with-formatter "YYYY" dt) + :day-of-week (unparse-with-formatter "EEEE" dt) + :hour-of-day (tru "at {0}" (unparse-with-formatter "h a" dt)) + :month-of-year (unparse-with-formatter "MMMM" dt) + :quarter-of-year (tru "Q{0}" (date/date-extract :quarter-of-year dt tz)) + (:minute-of-hour + :day-of-month + :day-of-year + :week-of-year) (date/date-extract unit dt tz)))) + +(defn- field-reference->field + [root field-reference] + (cond-> (->> field-reference + filters/collect-field-references + first + filters/field-reference->id + (->field root)) + (-> field-reference first qp.util/normalize-token (= :datetime-field)) + (assoc :unit (-> field-reference last qp.util/normalize-token)))) + +(defmulti + ^{:private true + :arglists '([fieldset [op & args]])} + humanize-filter-value (fn [_ [op & args]] + (qp.util/normalize-token op))) + +(def ^:private unit-name (comp {:minute-of-hour "minute" + :hour-of-day "hour" + :day-of-week "day of week" + :day-of-month "day of month" + :day-of-year "day of year" + :week-of-year "week" + :month-of-year "month" + :quarter-of-year "quarter"} + qp.util/normalize-token)) + +(defn- field-name + ([root field-reference] + (->> field-reference (field-reference->field root) field-name)) + ([{:keys [display_name unit] :as field}] + (cond->> display_name + (and (filters/periodic-datetime? field) unit) (format "%s of %s" (unit-name unit))))) + +(defmethod humanize-filter-value := + [root [_ field-reference value]] + (let [field (field-reference->field root field-reference) + field-name (field-name field)] + (if (or (filters/datetime? field) + (filters/periodic-datetime? field)) + (tru "{0} is {1}" field-name (humanize-datetime value (:unit field))) + (tru "{0} is {1}" field-name value)))) + +(defmethod humanize-filter-value :between + [root [_ field-reference min-value max-value]] + (tru "{0} is between {1} and {2}" (field-name root field-reference) min-value max-value)) + +(defmethod humanize-filter-value :inside + [root [_ lat-reference lon-reference lat-max lon-min lat-min lon-max]] + (tru "{0} is between {1} and {2}; and {3} is between {4} and {5}" + (field-name root lon-reference) lon-min lon-max + (field-name root lat-reference) lat-min lat-max)) + +(defmethod humanize-filter-value :and + [root [_ & clauses]] + (->> clauses + (map (partial humanize-filter-value root)) + join-enumeration)) + +(defn- cell-title + [root cell-query] + (str/join " " [(->> (qp.util/get-in-normalized (-> root :entity) [:dataset_query :query :aggregation]) + (metric->description root)) + (tru "where {0}" (humanize-filter-value root cell-query))])) + (defmethod automagic-analysis (type Card) [card {:keys [cell-query] :as opts}] - (let [root (->root card)] - (if (or (table-like? card) - cell-query) + (let [root (->root card) + cell-url (format "%squestion/%s/cell/%s" public-endpoint + (u/get-id card) + (encode-base64-json cell-query))] + (if (table-like? card) (automagic-dashboard (merge (cond-> root - cell-query (merge {:url (format "%squestion/%s/cell/%s" public-endpoint - (u/get-id card) - (encode-base64-json cell-query)) + cell-query (merge {:url cell-url :entity (:source root) :rules-prefix ["table"]})) opts)) (let [opts (assoc opts :show :all)] - (->> (decompose-question root card opts) - (apply populate/merge-dashboards (automagic-dashboard root)) - (merge {:related (related {:context {:root {:entity card}}} nil)})))))) + (cond-> (apply populate/merge-dashboards + (automagic-dashboard (merge (cond-> root + cell-query (assoc :url cell-url)) + opts)) + (decompose-question root card opts)) + cell-query (merge (let [title (tru "A closer look at {0}" (cell-title root cell-query))] + {:transient_name title + :name title}))))))) (defmethod automagic-analysis (type Query) [query {:keys [cell-query] :as opts}] - (let [root (->root query)] - (if (or (table-like? query) - (:cell-query opts)) + (let [root (->root query) + cell-url (format "%sadhoc/%s/cell/%s" public-endpoint + (encode-base64-json (:dataset_query query)) + (encode-base64-json cell-query))] + (if (table-like? query) (automagic-dashboard (merge (cond-> root - cell-query (merge {:url (format "%sadhoc/%s/cell/%s" public-endpoint - (encode-base64-json (:dataset_query query)) - (encode-base64-json cell-query)) + cell-query (merge {:url cell-url :entity (:source root) :rules-prefix ["table"]})) - (update opts :cell-query - (partial filters/inject-refinement - (qp.util/get-in-normalized query [:dataset_query :query :filter]))))) + opts)) (let [opts (assoc opts :show :all)] - (->> (decompose-question root query opts) - (apply populate/merge-dashboards (automagic-dashboard root)) - (merge {:related (related {:context {:root {:entity query}}} nil)})))))) + (cond-> (apply populate/merge-dashboards + (automagic-dashboard (merge (cond-> root + cell-query (assoc :url cell-url)) + opts)) + (decompose-question root query opts)) + cell-query (merge (let [title (tru "A closer look at the {0}" (cell-title root cell-query))] + {:transient_name title + :name title}))))))) (defmethod automagic-analysis (type Field) [field opts] @@ -930,8 +1110,7 @@ :from [Field] :where [:in :table_id (map u/get-id tables)] :group-by [:table_id]}) - (into {} (map (fn [{:keys [count table_id]}] - [table_id count])))) + (into {} (map (juxt :table_id :count)))) list-like? (->> (when-let [candidates (->> field-count (filter (comp (partial >= 2) val)) (map key) diff --git a/src/metabase/automagic_dashboards/filters.clj b/src/metabase/automagic_dashboards/filters.clj index d6d7c5b8c7e63ca1e0765a8e163006720c68e912..a560f807175fb0419c0947f2ac78df022bdb8cca 100644 --- a/src/metabase/automagic_dashboards/filters.clj +++ b/src/metabase/automagic_dashboards/filters.clj @@ -11,7 +11,7 @@ [(s/one (s/constrained su/KeywordOrString (comp #{:field-id :fk-> :field-literal} qp.util/normalize-token)) "head") - s/Any]) + (s/cond-pre s/Int su/KeywordOrString)]) (def ^:private ^{:arglists '([form])} field-reference? "Is given form an MBQL field reference?" @@ -25,7 +25,7 @@ (defmethod field-reference->id :field-id [[_ id]] (if (sequential? id) - (second id) + (field-reference->id id) id)) (defmethod field-reference->id :fk-> @@ -44,12 +44,14 @@ (tree-seq (some-fn sequential? map?) identity) (filter field-reference?))) -(def ^:private ^{:arglists '([field])} periodic-datetime? +(def ^{:arglists '([field])} periodic-datetime? + "Is `field` a periodic datetime (eg. day of month)?" (comp #{:minute-of-hour :hour-of-day :day-of-week :day-of-month :day-of-year :week-of-year :month-of-year :quarter-of-year} :unit)) -(defn- datetime? +(defn datetime? + "Is `field` a datetime?" [field] (and (not (periodic-datetime? field)) (or (isa? (:base_type field) :type/DateTime) @@ -154,10 +156,10 @@ remove-unqualified (sort-by interestingness >) (take max-filters) - (map #(assoc % :fk-map (build-fk-map fks %))) (reduce (fn [dashboard candidate] - (let [filter-id (-> candidate hash str) + (let [filter-id (-> candidate ((juxt :id :name :unit)) hash str) + candidate (assoc candidate :fk-map (build-fk-map fks candidate)) dashcards (:ordered_cards dashboard) dashcards-new (map #(add-filter % filter-id candidate) dashcards)] ;; Only add filters that apply to all cards. @@ -172,17 +174,6 @@ dashboard))))) -(defn filter-referenced-fields - "Return a map of fields referenced in filter cluase." - [filter-clause] - (->> filter-clause - collect-field-references - (mapcat (fn [[_ & ids]] - (for [id ids] - [id (Field id)]))) - (into {}))) - - (defn- flatten-filter-clause [filter-clause] (when (not-empty filter-clause) @@ -208,4 +199,4 @@ (->> filter-clause flatten-filter-clause (remove (comp in-refinement? collect-field-references)) - (reduce merge-filter-clauses refinement)))) + (apply merge-filter-clauses refinement)))) diff --git a/src/metabase/automagic_dashboards/populate.clj b/src/metabase/automagic_dashboards/populate.clj index fb1c1c048d320d38acf44a7f2a4795bce621e52a..60fd4bce4fa89b1add9f12ec40ffbac9d8fc402f 100644 --- a/src/metabase/automagic_dashboards/populate.clj +++ b/src/metabase/automagic_dashboards/populate.clj @@ -4,10 +4,9 @@ [clojure.tools.logging :as log] [metabase.api.common :as api] [metabase.automagic-dashboards.filters :as filters] - [metabase.models - [card :as card] - [field :refer [Field]]] + [metabase.models.card :as card] [metabase.query-processor.util :as qp.util] + [metabase.util :as u] [puppetlabs.i18n.core :as i18n :refer [trs]] [toucan.db :as db])) @@ -80,17 +79,17 @@ (defn- visualization-settings [{:keys [metrics x_label y_label series_labels visualization dimensions] :as card}] - (let [metric-name (some-fn :name (comp str/capitalize name first :metric)) - [display visualization-settings] visualization] + (let [[display visualization-settings] visualization] {:display display - :visualization_settings - (-> visualization-settings - (merge (colorize card)) - (cond-> - (some :name metrics) (assoc :graph.series_labels (map metric-name metrics)) - series_labels (assoc :graph.series_labels series_labels) - x_label (assoc :graph.x_axis.title_text x_label) - y_label (assoc :graph.y_axis.title_text y_label)))})) + :visualization_settings (-> visualization-settings + (assoc :graph.series_labels metrics) + (merge (colorize card)) + (cond-> + series_labels (assoc :graph.series_labels series_labels) + + x_label (assoc :graph.x_axis.title_text x_label) + + y_label (assoc :graph.y_axis.title_text y_label)))})) (defn- add-card "Add a card to dashboard `dashboard` at position [`x`, `y`]." @@ -236,15 +235,13 @@ (defn create-dashboard "Create dashboard and populate it with cards." ([dashboard] (create-dashboard dashboard :all)) - ([{:keys [title transient_title description groups filters cards refinements fieldset]} n] + ([{:keys [title transient_title description groups filters cards refinements]} n] (let [n (cond (= n :all) (count cards) (keyword? n) (Integer/parseInt (name n)) :else n) dashboard {:name title :transient_name (or transient_title title) - :transient_filters refinements - :param_fields (filters/filter-referenced-fields refinements) :description description :creator_id api/*current-user-id* :parameters []} @@ -265,43 +262,62 @@ (cond-> dashboard (not-empty filters) (filters/add-filters filters max-filters))))) +(defn- downsize-titles + [markdown] + (->> markdown + str/split-lines + (map (fn [line] + (if (str/starts-with? line "#") + (str "#" line) + line))) + str/join)) + +(defn- merge-filters + [ds] + (when (->> ds + (mapcat :ordered_cards) + (keep (comp :table_id :card)) + distinct + count + (= 1)) + [(->> ds (mapcat :parameters) distinct) + (->> ds + (mapcat :ordered_cards) + (mapcat :parameter_mappings) + (map #(dissoc % :card_id)) + distinct)])) + (defn merge-dashboards "Merge dashboards `ds` into dashboard `d`." - [d & ds] - (let [filter-targets (when (->> ds - (mapcat :ordered_cards) - (keep (comp :table_id :card)) - distinct - count - (= 1)) - (->> ds - (mapcat :ordered_cards) - (mapcat :parameter_mappings) - (mapcat (comp filters/collect-field-references :target)) - (map filters/field-reference->id) - distinct - (map Field)))] - (cond-> (reduce - (fn [target dashboard] - (let [offset (->> target - :ordered_cards - (map #(+ (:row %) (:sizeY %))) - (apply max -1) ; -1 so it neturalizes +1 for spacing if - ; the target dashboard is empty. - inc)] - (-> target - (add-text-card {:width grid-width - :height group-heading-height - :text (format "# %s" (:name dashboard)) - :visualization-settings {:dashcard.background false - :text.align_vertical :bottom}} - [offset 0]) - (update :ordered_cards concat - (->> dashboard - :ordered_cards - (map #(-> % - (update :row + offset group-heading-height) - (dissoc :parameter_mappings)))))))) - d - ds) - (not-empty filter-targets) (filters/add-filters filter-targets max-filters)))) + [& ds] + (let [[paramters parameter-mappings] (merge-filters ds)] + (reduce + (fn [target dashboard] + (let [offset (->> target + :ordered_cards + (map #(+ (:row %) (:sizeY %))) + (apply max -1) ; -1 so it neturalizes +1 for spacing if + ; the target dashboard is empty. + inc) + cards (->> dashboard + :ordered_cards + (map #(-> % + (update :row + offset group-heading-height) + (u/update-in-when [:visualization_settings :text] + downsize-titles) + (assoc :parameter_mappings + (when (:card_id %) + (for [mapping parameter-mappings] + (assoc mapping :card_id (:card_id %))))))))] + (-> target + (add-text-card {:width grid-width + :height group-heading-height + :text (format "# %s" (:name dashboard)) + :visualization-settings {:dashcard.background false + :text.align_vertical :bottom}} + [offset 0]) + (update :ordered_cards concat cards)))) + (-> ds + first + (assoc :parameters paramters)) + (rest ds)))) diff --git a/src/metabase/automagic_dashboards/rules.clj b/src/metabase/automagic_dashboards/rules.clj index 50bad7029869ed93be85428e75301a0876ac2905..6e6842033f60c07b16edf0c3aae7e21fcd7fde51 100644 --- a/src/metabase/automagic_dashboards/rules.clj +++ b/src/metabase/automagic_dashboards/rules.clj @@ -4,6 +4,7 @@ [clojure.string :as str] [clojure.tools.logging :as log] [metabase.automagic-dashboards.populate :as populate] + [metabase.query-processor.util :as qp.util] [metabase.util :as u] [metabase.util.schema :as su] [puppetlabs.i18n.core :as i18n :refer [trs]] @@ -119,8 +120,7 @@ (mapcat (comp k val first) cards)) (def ^:private DimensionForm - [(s/one (s/constrained (s/cond-pre s/Str s/Keyword) - (comp #{"dimension"} str/lower-case name)) + [(s/one (s/constrained (s/cond-pre s/Str s/Keyword) (comp #{:dimension} qp.util/normalize-token)) "dimension") (s/one s/Str "identifier") su/Map]) diff --git a/src/metabase/query_processor/middleware/expand_macros.clj b/src/metabase/query_processor/middleware/expand_macros.clj index d4365fa7d252632e1a8feb8c2315d73de698e5a6..dd4a878c8c36635be1568d3f970a54953f446038 100644 --- a/src/metabase/query_processor/middleware/expand_macros.clj +++ b/src/metabase/query_processor/middleware/expand_macros.clj @@ -112,13 +112,14 @@ "Merge filter clauses." ([] []) ([clause] clause) - ([base-clause additional-clauses] - (cond - (and (seq base-clause) - (seq additional-clauses)) [:and base-clause additional-clauses] - (seq base-clause) base-clause - (seq additional-clauses) additional-clauses - :else []))) + ([base-clause & additional-clauses] + (let [additional-clauses (filter seq additional-clauses)] + (cond + (and (seq base-clause) + (seq additional-clauses)) (apply vector :and base-clause additional-clauses) + (seq base-clause) base-clause + (seq additional-clauses) (apply merge-filter-clauses additional-clauses) + :else [])))) (defn- add-metrics-filter-clauses "Add any FILTER-CLAUSES to the QUERY-DICT. If query has existing filter clauses, the new ones are diff --git a/src/metabase/util/date.clj b/src/metabase/util/date.clj index 03a07b3fdb73e443a668b66843bd2497a5d41be2..d74ccbe438a3d429b4d60c67d4d7d3917bbd0c1f 100644 --- a/src/metabase/util/date.clj +++ b/src/metabase/util/date.clj @@ -41,7 +41,8 @@ "UTC TimeZone" (coerce-to-timezone "UTC")) -(def ^:private jvm-timezone +(def jvm-timezone + "Machine time zone" (delay (coerce-to-timezone (System/getProperty "user.timezone")))) (defn- warn-on-timezone-conflict diff --git a/test/metabase/automagic_dashboards/core_test.clj b/test/metabase/automagic_dashboards/core_test.clj index fd17104868250334e912012325ff03e054eece9c..9e7c3f72a9a92343762d116cad09b2d67b349140 100644 --- a/test/metabase/automagic_dashboards/core_test.clj +++ b/test/metabase/automagic_dashboards/core_test.clj @@ -1,14 +1,20 @@ (ns metabase.automagic-dashboards.core-test - (:require [expectations :refer :all] + (:require [clj-time + [core :as t] + [format :as t.format]] + [expectations :refer :all] [metabase.api.common :as api] [metabase.automagic-dashboards [core :refer :all :as magic] [rules :as rules]] [metabase.models [card :refer [Card]] + [collection :refer [Collection]] [database :refer [Database]] [field :as field :refer [Field]] [metric :refer [Metric]] + [permissions :as perms] + [permissions-group :as perms-group] [query :as query] [table :refer [Table] :as table] [user :as user]] @@ -16,6 +22,8 @@ [metabase.test.data :as data] [metabase.test.data.users :as test-users] [metabase.test.util :as tu] + [metabase.util.date :as date] + [puppetlabs.i18n.core :as i18n :refer [tru]] [toucan.db :as db] [toucan.util.test :as tt])) @@ -82,15 +90,15 @@ (tree-seq (some-fn sequential? map?) identity) (keep (fn [form] (when (map? form) - (:url form)))))) + ((some-fn :url :more) form)))))) (defn- valid-urls? [dashboard] (->> dashboard collect-urls (every? (fn [url] - ((test-users/user->client :rasta) :get 200 (format "automagic-dashboards/%s" - (subs url 16))))))) + ((test-users/user->client :rasta) :get 200 + (format "automagic-dashboards/%s" (subs url 16))))))) (def ^:private valid-card? (comp qp/expand :dataset_query)) @@ -103,12 +111,28 @@ (assert (every? valid-card? (keep :card (:ordered_cards dashboard)))) true) +(defn- test-automagic-analysis + ([entity] (test-automagic-analysis entity nil)) + ([entity cell-query] + ;; We want to both generate as many cards as we can to catch all aberrations, but also make sure + ;; that size limiting works. + (and (valid-dashboard? (automagic-analysis entity {:cell-query cell-query :show :all})) + (valid-dashboard? (automagic-analysis entity {:cell-query cell-query :show 1}))))) + (expect (with-rasta (with-dashboard-cleanup (->> (db/select Table :db_id (data/id)) - (keep #(automagic-analysis % {})) - (every? valid-dashboard?))))) + (every? test-automagic-analysis))))) + +(expect + (with-rasta + (with-dashboard-cleanup + (->> (automagic-analysis (Table (data/id :venues)) {:show 1}) + :ordered_cards + (filter :card) + count + (= 1))))) (expect (with-rasta @@ -116,28 +140,32 @@ (->> (db/select Field :table_id [:in (db/select-field :id Table :db_id (data/id))] :visibility_type "normal") - (keep #(automagic-analysis % {})) - (every? valid-dashboard?))))) + (every? test-automagic-analysis))))) (expect (tt/with-temp* [Metric [{metric-id :id} {:table_id (data/id :venues) :definition {:query {:aggregation ["count"]}}}]] (with-rasta (with-dashboard-cleanup - (->> (Metric) (keep #(automagic-analysis % {})) (every? valid-dashboard?)))))) + (->> (Metric) (every? test-automagic-analysis)))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] :source_table (data/id :venues)} :type :query :database (data/id)}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:aggregation [[:count]] :breakout [[:field-id (data/id :venues :category_id)]] :source_table (data/id :venues)} @@ -145,80 +173,98 @@ :database (data/id)}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id nil + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id nil + :collection_id collection-id :dataset_query {:native {:query "select * from users"} :type :native :database (data/id)}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{source-id :id} {:table_id (data/id :venues) + (tt/with-temp* [Collection [{collection-id :id}] + Card [{source-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:source_table (data/id :venues)} :type :query :database (data/id)}}] Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] :source_table (str "card__" source-id)} :type :query :database -1337}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{source-id :id} {:table_id nil + (tt/with-temp* [Collection [{collection-id :id}] + Card [{source-id :id} {:table_id nil + :collection_id collection-id :dataset_query {:native {:query "select * from users"} :type :native :database (data/id)}}] Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] :source_table (str "card__" source-id)} :type :query :database -1337}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id nil + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id nil + :collection_id collection-id :dataset_query {:native {:query "select * from users"} :type :native :database (data/id)}}]] (with-rasta (with-dashboard-cleanup - (-> card-id Card (automagic-analysis {}) valid-dashboard?))))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) + (-> card-id Card test-automagic-analysis))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] :source_table (data/id :venues)} :type :query :database (data/id)}}]] (with-rasta (with-dashboard-cleanup + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) (-> card-id Card - (automagic-analysis {:cell-query [:= [:field-id (data/id :venues :category_id)] 2]}) - valid-dashboard?))))) + (test-automagic-analysis [:= [:field-id (data/id :venues :category_id)] 2])))))) (expect - (tt/with-temp* [Card [{card-id :id} {:table_id (data/id :venues) + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id} {:table_id (data/id :venues) + :collection_id collection-id :dataset_query {:query {:filter [:> [:field-id (data/id :venues :price)] 10] :source_table (data/id :venues)} :type :query :database (data/id)}}]] (with-rasta (with-dashboard-cleanup + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection-id) (-> card-id Card - (automagic-analysis {:cell-query [:!= [:field-id (data/id :venues :category_id)] 2]}) - valid-dashboard?))))) + (test-automagic-analysis [:= [:field-id (data/id :venues :category_id)] 2])))))) (expect @@ -228,7 +274,7 @@ :source_table (data/id :venues)} :type :query :database (data/id)})] - (-> q (automagic-analysis {}) valid-dashboard?))))) + (test-automagic-analysis q))))) (expect (with-rasta @@ -238,7 +284,7 @@ :source_table (data/id :venues)} :type :query :database (data/id)})] - (-> q (automagic-analysis {}) valid-dashboard?))))) + (test-automagic-analysis q))))) (expect (with-rasta @@ -248,7 +294,7 @@ :source_table (data/id :checkins)} :type :query :database (data/id)})] - (-> q (automagic-analysis {}) valid-dashboard?))))) + (test-automagic-analysis q))))) (expect (with-rasta @@ -257,9 +303,7 @@ :source_table (data/id :venues)} :type :query :database (data/id)})] - (-> q - (automagic-analysis {:cell-query [:= [:field-id (data/id :venues :category_id)] 2]}) - valid-dashboard?))))) + (test-automagic-analysis q [:= [:field-id (data/id :venues :category_id)] 2]))))) ;;; ------------------- /candidates ------------------- @@ -408,3 +452,60 @@ (#'magic/optimal-datetime-resolution {:fingerprint {:type {:type/DateTime {:earliest "2017-01-01T00:00:00" :latest "2017-01-01T00:02:00"}}}})) + + +;;; ------------------- Datetime humanization (for chart and dashboard titles) ------------------- + +(let [tz (-> date/jvm-timezone deref ^TimeZone .getID) + dt (t/from-time-zone (t/date-time 1990 9 9 12 30) + (t/time-zone-for-id tz)) + unparse-with-formatter (fn [formatter dt] + (t.format/unparse + (t.format/formatter formatter (t/time-zone-for-id tz)) dt))] + (expect + [(tru "at {0}" (unparse-with-formatter "h:mm a, MMMM d, YYYY" dt)) + (tru "at {0}" (unparse-with-formatter "h a, MMMM d, YYYY" dt)) + (tru "on {0}" (unparse-with-formatter "MMMM d, YYYY" dt)) + (tru "in {0} week - {1}" + (#'magic/pluralize (date/date-extract :week-of-year dt tz)) + (str (date/date-extract :year dt tz))) + (tru "in {0}" (unparse-with-formatter "MMMM YYYY" dt)) + (tru "in Q{0} - {1}" + (date/date-extract :quarter-of-year dt tz) + (str (date/date-extract :year dt tz))) + (unparse-with-formatter "YYYY" dt) + (unparse-with-formatter "EEEE" dt) + (tru "at {0}" (unparse-with-formatter "h a" dt)) + (unparse-with-formatter "MMMM" dt) + (tru "Q{0}" (date/date-extract :quarter-of-year dt tz)) + (date/date-extract :minute-of-hour dt tz) + (date/date-extract :day-of-month dt tz) + (date/date-extract :week-of-year dt tz)] + (let [dt (t.format/unparse (t.format/formatters :date-hour-minute-second) dt)] + [(#'magic/humanize-datetime dt :minute) + (#'magic/humanize-datetime dt :hour) + (#'magic/humanize-datetime dt :day) + (#'magic/humanize-datetime dt :week) + (#'magic/humanize-datetime dt :month) + (#'magic/humanize-datetime dt :quarter) + (#'magic/humanize-datetime dt :year) + (#'magic/humanize-datetime dt :day-of-week) + (#'magic/humanize-datetime dt :hour-of-day) + (#'magic/humanize-datetime dt :month-of-year) + (#'magic/humanize-datetime dt :quarter-of-year) + (#'magic/humanize-datetime dt :minute-of-hour) + (#'magic/humanize-datetime dt :day-of-month) + (#'magic/humanize-datetime dt :week-of-year)]))) + +(expect + [(tru "{0}st" 1) + (tru "{0}nd" 22) + (tru "{0}rd" 303) + (tru "{0}th" 0) + (tru "{0}th" 8)] + (map #'magic/pluralize [1 22 303 0 8])) + +;; Make sure we have handlers for all the units available +(expect + (every? (partial #'magic/humanize-datetime "1990-09-09T12:30:00") + (concat (var-get #'date/date-extract-units) (var-get #'date/date-trunc-units)))) diff --git a/test/metabase/automagic_dashboards/filters_test.clj b/test/metabase/automagic_dashboards/filters_test.clj index 28f3e6584d08378e691066376a2e9873e2db5b2b..0c64afef3910cf613e903d9ec06febc39101572a 100644 --- a/test/metabase/automagic_dashboards/filters_test.clj +++ b/test/metabase/automagic_dashboards/filters_test.clj @@ -12,7 +12,7 @@ ;; If there's no overlap between filter clauses, just merge using `:and`. (expect - [:and [:and [:and [:= [:field-id 3] 42] [:= [:fk-> 1 9] "foo"]] [:> [:field-id 2] 10]] [:< [:field-id 2] 100]] + [:and [:= [:field-id 3] 42] [:= [:fk-> 1 9] "foo"] [:> [:field-id 2] 10] [:< [:field-id 2] 100]] (inject-refinement [:and [:= [:fk-> 1 9] "foo"] [:and [:> [:field-id 2] 10] [:< [:field-id 2] 100]]] diff --git a/test/metabase/models/dashboard_test.clj b/test/metabase/models/dashboard_test.clj index f3548eb6f6c6cee6f1b7dbccb9601acd5856708f..1c7338e6491f802e8b26a15fe453e86c385905b6 100644 --- a/test/metabase/models/dashboard_test.clj +++ b/test/metabase/models/dashboard_test.clj @@ -229,14 +229,15 @@ ;; test that we save a transient dashboard (expect - 8 (tu/with-model-cleanup ['Card 'Dashboard 'DashboardCard 'Collection] (binding [api/*current-user-id* (users/user->id :rasta) api/*current-user-permissions-set* (-> :rasta users/user->id user/permissions-set atom)] - (->> (magic/automagic-analysis (Table (id :venues)) {}) - save-transient-dashboard! - :id - (db/count 'DashboardCard :dashboard_id))))) + (let [dashboard (magic/automagic-analysis (Table (id :venues)) {})] + (->> dashboard + save-transient-dashboard! + :id + (db/count 'DashboardCard :dashboard_id) + (= (-> dashboard :ordered_cards count)))))))