diff --git a/frontend/src/metabase/pulse/components/PulseCardPreview.jsx b/frontend/src/metabase/pulse/components/PulseCardPreview.jsx index 1ccbf48d936a779ca700f917905fff83f251353e..3db924022146e883bd9e8233665e394ba8b571a2 100644 --- a/frontend/src/metabase/pulse/components/PulseCardPreview.jsx +++ b/frontend/src/metabase/pulse/components/PulseCardPreview.jsx @@ -67,7 +67,12 @@ export default class PulseCardPreview extends Component { cardPreview && cardPreview.pulse_card_type == null; return ( - <div className="flex relative flex-full"> + <div + className="flex relative flex-full" + style={{ + maxWidth: 379, + }} + > <div className="absolute p2 text-grey-2" style={{ diff --git a/frontend/src/metabase/pulse/components/PulseEditCards.jsx b/frontend/src/metabase/pulse/components/PulseEditCards.jsx index b6f721492135ff52351885d1572f7b5f1a075ac1..df98dfd4a23f3e9cb6d2a2174712d9f3e76a8d8e 100644 --- a/frontend/src/metabase/pulse/components/PulseEditCards.jsx +++ b/frontend/src/metabase/pulse/components/PulseEditCards.jsx @@ -11,6 +11,17 @@ import MetabaseAnalytics from "metabase/lib/analytics"; const SOFT_LIMIT = 10; const HARD_LIMIT = 25; +const TABLE_MAX_ROWS = 20; +const TABLE_MAX_COLS = 10; + +function isAutoAttached(cardPreview) { + return ( + cardPreview && + cardPreview.pulse_card_type === "table" && + (cardPreview.row_count > TABLE_MAX_ROWS || + cardPreview.col_cound > TABLE_MAX_COLS) + ); +} export default class PulseEditCards extends Component { constructor(props) { @@ -69,9 +80,10 @@ export default class PulseEditCards extends Component { const showSoftLimitWarning = index === SOFT_LIMIT; let notices = []; const hasAttachment = - this.props.attachmentsEnabled && - card && - (card.include_csv || card.include_xls); + isAutoAttached(cardPreview) || + (this.props.attachmentsEnabled && + card && + (card.include_csv || card.include_xls)); if (hasAttachment) { notices.push({ head: t`Attachment`, @@ -85,6 +97,13 @@ export default class PulseEditCards extends Component { }); } if (cardPreview) { + if (isAutoAttached(cardPreview)) { + notices.push({ + type: "warning", + head: t`Heads up`, + body: t`We'll show the first 10 columns and 20 rows of this table in your Pulse. If you email this, we'll add a file attachment with all columns and up to 2,000 rows.`, + }); + } if (cardPreview.pulse_card_type == null && !hasAttachment) { notices.push({ type: "warning", @@ -164,7 +183,10 @@ export default class PulseEditCards extends Component { onChange={this.setCard.bind(this, index)} onRemove={this.removeCard.bind(this, index)} fetchPulseCardPreview={this.props.fetchPulseCardPreview} - attachmentsEnabled={this.props.attachmentsEnabled} + attachmentsEnabled={ + this.props.attachmentsEnabled && + !isAutoAttached(cardPreviews[card.id]) + } trackPulseEvent={this.trackPulseEvent} /> ) : ( diff --git a/frontend/test/pulse/pulse.integ.spec.js b/frontend/test/pulse/pulse.integ.spec.js index b6255cf420b49c45a4d03792c54acae85e0d803a..756e0b2e4ee9cc7f97f6b705a08e12ed1f2b96f7 100644 --- a/frontend/test/pulse/pulse.integ.spec.js +++ b/frontend/test/pulse/pulse.integ.spec.js @@ -133,8 +133,8 @@ describe("Pulse", () => { // NOTE: check text content since enzyme doesn't doesn't seem to work well with dangerouslySetInnerHTML expect(previews.at(0).text()).toBe("count12,805"); expect(previews.at(0).find(".Icon-attachment").length).toBe(1); - expect(previews.at(1).text()).toBe( - "tableThis question will be added as a file attachment", + expect(previews.at(1).text()).toEqual( + expect.stringContaining("Showing 20 of 12,805 rows"), ); expect(previews.at(1).find(".Icon-attachment").length).toBe(0); @@ -144,8 +144,8 @@ describe("Pulse", () => { previews = app.find(PulseCardPreview); expect(previews.at(0).text()).toBe("count12,805"); expect(previews.at(0).find(".Icon-attachment").length).toBe(0); - expect(previews.at(1).text()).toBe( - "tableThis question won't be included in your Pulse", + expect(previews.at(1).text()).toEqual( + expect.stringContaining("Showing 20 of 12,805 rows"), ); expect(previews.at(1).find(".Icon-attachment").length).toBe(0); diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 181ab8214394d21ce490a12785c82fc48369cae6..45814d0c2c7138dbc3713146ab8f97fec17bf55a 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -594,7 +594,7 @@ "Run the query for Card with PARAMETERS and CONSTRAINTS, and return results in the usual format." {:style/indent 1} [card-id & {:keys [parameters constraints context dashboard-id] - :or {constraints dataset-api/default-query-constraints + :or {constraints qp/default-query-constraints context :question}}] {:pre [(u/maybe? sequential? parameters)]} (let [card (api/read-check (hydrate (Card card-id) :in_public_dashboard)) diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 0b7a49f856f99dec95a807c0c7183ff386b6c431..02e994e4769acc3b55cb6d181d69d56af5cce236 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -4,6 +4,7 @@ [compojure.core :refer [DELETE GET POST PUT]] [metabase [events :as events] + [query-processor :as qp] [util :as u]] [metabase.api [common :as api] @@ -123,7 +124,7 @@ [{:keys [dataset_query]}] (u/ignore-exceptions [(qp-util/query-hash dataset_query) - (qp-util/query-hash (assoc dataset_query :constraints dataset/default-query-constraints))])) + (qp-util/query-hash (assoc dataset_query :constraints qp/default-query-constraints))])) (defn- dashcard->query-hashes "Return a sequence of all the query hashes for this DASHCARD, including the top-level Card and any Series." diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index be2c68d17e734f5ed42d2cbe6935e1cd6ff20a5d..e7999d5b4a95e821a2dcae0de5ffca0a05eed1a8 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -21,22 +21,6 @@ [schema :as su]] [schema.core :as s])) -;;; --------------------------------------------------- Constants ---------------------------------------------------- - -(def ^:private ^:const max-results-bare-rows - "Maximum number of rows to return specifically on :rows type queries via the API." - 2000) - -(def ^:private ^:const max-results - "General maximum number of rows to return from an API query." - 10000) - -(def ^:const default-query-constraints - "Default map of constraints that we apply on dataset queries executed by the api." - {:max-results max-results - :max-results-bare-rows max-results-bare-rows}) - - ;;; -------------------------------------------- Running a Query Normally -------------------------------------------- (defn- query->source-card-id @@ -61,8 +45,8 @@ (let [source-card-id (query->source-card-id query)] (api/cancellable-json-response (fn [] - (qp/process-query-and-save-execution! (assoc query :constraints default-query-constraints) - {:executed-by api/*current-user-id*, :context :ad-hoc, :card-id source-card-id, :nested? (boolean source-card-id)}))))) + (qp/process-query-and-save-with-max! query {:executed-by api/*current-user-id*, :context :ad-hoc, + :card-id source-card-id, :nested? (boolean source-card-id)}))))) ;;; ----------------------------------- Downloading Query Results in Other Formats ----------------------------------- @@ -151,7 +135,7 @@ ;; try calculating the average for the query as it was given to us, otherwise with the default constraints if ;; there's no data there. If we still can't find relevant info, just default to 0 {:average (or (query/average-execution-time-ms (qputil/query-hash query)) - (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints default-query-constraints))) + (query/average-execution-time-ms (qputil/query-hash (assoc query :constraints qp/default-query-constraints))) 0)}) (api/define-routes) diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index f6106dcdad95141c591c1388597025dc47d73a50..324216bd28d74f6426bbfdb8adb02a983781c9b8 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -133,7 +133,8 @@ :pulse_card_html card-html :pulse_card_name (:name card) :pulse_card_url (urls/card-url (:id card)) - :row_count (:row_count result)})) + :row_count (:row_count result) + :col_count (count (:cols (:data result)))})) (api/defendpoint GET "/preview_card_png/:id" "Get PNG rendering of a `Card` with ID." diff --git a/src/metabase/email/_footer_pulse.mustache b/src/metabase/email/_footer_pulse.mustache new file mode 100644 index 0000000000000000000000000000000000000000..725cae61054372fdcf844edacc1ec9ee8eb705f7 --- /dev/null +++ b/src/metabase/email/_footer_pulse.mustache @@ -0,0 +1,11 @@ + </div> + {{#quotation}} + <div style="padding-top: 2em; padding-bottom: 1em; text-align: left; color: #CCCCCC; font-size: small;">"{{quotation}}"<br/>- {{quotationAuthor}}</div> + {{/quotation}} + {{#logoFooter}} + <div style="padding-bottom: 2em; padding-top: 1em; text-align: left;"> + <img width="32" height="40" src="http://static.metabase.com/email_logo.png"/> + </div> + {{/logoFooter}} +</body> +</html> diff --git a/src/metabase/email/_header.mustache b/src/metabase/email/_header.mustache index 9541f783cac427b737057b370af6a5eae331689a..85c926ad42a85155f06ec1d9f7f7e93a55e1297f 100644 --- a/src/metabase/email/_header.mustache +++ b/src/metabase/email/_header.mustache @@ -14,4 +14,4 @@ <img width="47" height="60" src="http://static.metabase.com/email_logo.png"/> </div> {{/logoHeader}} - <div class="container" style="margin: 0 auto; padding: 0 0 2em 0; max-width: 500px; font-size: 16px; line-height: 24px; color: #616D75;"> + <div class="container" style="margin: 0; padding: 0 0 2em 0; max-width: 100%; font-size: 16px; line-height: 24px; color: #616D75;"> diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj index 6464251693070ddbf104a522ee23078c706c1a86..ffd585129da6bc978780f286cb05f72c820bb0ce 100644 --- a/src/metabase/email/messages.clj +++ b/src/metabase/email/messages.clj @@ -204,19 +204,21 @@ :content-type content-type :file-name (format "%s.%s" card-name ext) :content (-> attachment-file .toURI .toURL) - :description (format "Full results for '%s'" card-name)})) + :description (format "More results for '%s'" card-name)})) (defn- result-attachments [results] (remove nil? (apply concat - (for [{{card-name :name, csv? :include_csv, xls? :include_xls} :card :as result} results - :when (and (or csv? xls?) - (seq (get-in result [:result :data :rows])))] - [(when-let [temp-file (and csv? (create-temp-file "csv"))] + (for [{{card-name :name, :as card} :card :as result} results + :let [{:keys [rows] :as result-data} (get-in result [:result :data])] + :when (seq rows)] + [(when-let [temp-file (and (render/include-csv-attachment? card result-data) + (create-temp-file "csv"))] (export/export-to-csv-writer temp-file result) (create-result-attachment-map "csv" card-name temp-file)) - (when-let [temp-file (and xls? (create-temp-file "xlsx"))] + (when-let [temp-file (and (render/include-xls-attachment? card result-data) + (create-temp-file "xlsx"))] (export/export-to-xlsx-file temp-file result) (create-result-attachment-map "xlsx" card-name temp-file))])))) diff --git a/src/metabase/email/pulse.mustache b/src/metabase/email/pulse.mustache index d2f5e05cc7a2e1518f250bae9bdee722dfff6a20..e024ababd09f0a304f9949164db62eaea1515d6f 100644 --- a/src/metabase/email/pulse.mustache +++ b/src/metabase/email/pulse.mustache @@ -1,8 +1,8 @@ {{> metabase/email/_header}} {{#pulseName}} - <h1 style="{{sectionStyle}} margin: 16px; color: {{colorGrey4}};"> + <h1 style="{{sectionStyle}} margin: 16px; color: {{color-brand}};"> {{pulseName}} </h1> {{/pulseName}} {{{pulse}}} -{{> metabase/email/_footer}} +{{> metabase/email/_footer_pulse}} diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj index b2e718de90a7c3fc41da02e79f99e24f191ddb79..41ab09ac923d04a59d8ce76bf4bf7fc4c64e0088 100644 --- a/src/metabase/pulse.clj +++ b/src/metabase/pulse.clj @@ -34,9 +34,10 @@ (let [{:keys [creator_id dataset_query]} card] (try {:card card - :result (qp/process-query-and-save-execution! dataset_query - (merge {:executed-by creator_id, :context :pulse, :card-id card-id} - options))} + :result (qp/process-query-and-save-with-max! dataset_query (merge {:executed-by creator_id, + :context :pulse, + :card-id card-id} + options))} (catch Throwable t (log/warn (format "Error running card query (%n)" card-id) t)))))) diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index 2c02ae9fb86bb0fe07af09f1388765566a48dd22..d9bbd926f1aac471821230a87a6f5a9d64a4e673 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -8,7 +8,9 @@ [string :as str]] [clojure.java.io :as io] [clojure.tools.logging :as log] - [hiccup.core :refer [h html]] + [hiccup + [core :refer [h html]] + [util :as hutil]] [metabase.util :as u] [metabase.util [ui-logic :as ui-logic] @@ -35,21 +37,24 @@ ;;; # ------------------------------------------------------------ STYLES ------------------------------------------------------------ (def ^:private ^:const card-width 400) -(def ^:private ^:const rows-limit 10) -(def ^:private ^:const cols-limit 3) +(def ^:private ^:const rows-limit 20) +(def ^:private ^:const cols-limit 10) (def ^:private ^:const sparkline-dot-radius 6) (def ^:private ^:const sparkline-thickness 3) (def ^:private ^:const sparkline-pad 8) ;;; ## STYLES -(def ^:private ^:const color-brand "rgb(45,134,212)") -(def ^:private ^:const color-purple "rgb(135,93,175)") -(def ^:private ^:const color-gold "#F9D45C") -(def ^:private ^:const color-error "#EF8C8C") -(def ^:private ^:const color-gray-1 "rgb(248,248,248)") -(def ^:private ^:const color-gray-2 "rgb(189,193,191)") -(def ^:private ^:const color-gray-3 "rgb(124,131,129)") +(def ^:private ^:const color-brand "rgb(45,134,212)") +(def ^:private ^:const color-purple "rgb(135,93,175)") +(def ^:private ^:const color-gold "#F9D45C") +(def ^:private ^:const color-error "#EF8C8C") +(def ^:private ^:const color-gray-1 "rgb(248,248,248)") +(def ^:private ^:const color-gray-2 "rgb(189,193,191)") +(def ^:private ^:const color-gray-3 "rgb(124,131,129)") (def ^:const color-gray-4 "A ~25% Gray color." "rgb(57,67,64)") +(def ^:private ^:const color-dark-gray "#616D75") +(def ^:private ^:const color-row-border "#EDF0F1") + (def ^:private ^:const font-style {:font-family "Lato, \"Helvetica Neue\", Helvetica, Arial, sans-serif"}) (def ^:const section-style @@ -68,19 +73,47 @@ :color color-brand})) (def ^:private ^:const bar-th-style - (merge font-style {:font-size :10px - :font-weight 400 - :color color-gray-4 - :border-bottom (str "4px solid " color-gray-1) - :padding-top :0px - :padding-bottom :10px})) + (merge font-style {:font-size :14.22px + :font-weight 700 + :color color-gray-4 + :border-bottom (str "1px solid " color-row-border) + :padding-top :20px + :padding-bottom :5px})) + +;; TO-DO for @senior: apply this style to headings of numeric columns +(def ^:private ^:const bar-th-numeric-style + (merge font-style {:text-align :right + :font-size :14.22px + :font-weight 700 + :color color-gray-4 + :border-bottom (str "1px solid " color-row-border) + :padding-top :20px + :padding-bottom :5px})) (def ^:private ^:const bar-td-style - (merge font-style {:font-size :16px - :font-weight 400 - :text-align :left - :padding-right :1em - :padding-top :8px})) + (merge font-style {:font-size :14.22px + :font-weight 400 + :color color-dark-gray + :text-align :left + :padding-right :1em + :padding-top :2px + :padding-bottom :1px + :max-width :500px + :overflow :hidden + :text-overflow :ellipsis + :border-bottom (str "1px solid " color-row-border)})) + +;; TO-DO for @senior: apply this style to numeric cells +(def ^:private ^:const bar-td-style-numeric + (merge font-style {:font-size :14.22px + :font-weight 400 + :color color-dark-gray + :text-align :right + :padding-right :1em + :padding-top :2px + :padding-bottom :1px + :font-family "Courier, Monospace" + :border-bottom (str "1px solid " color-row-border)})) (def ^:private RenderedPulseCard "Schema used for functions that operate on pulse card contents and their attachments" @@ -98,6 +131,11 @@ :let [v (if (keyword? v) (name v) v)]] (str (name k) ": " v ";")))) +(defn- graphing-columns [card {:keys [cols] :as data}] + [(or (ui-logic/x-axis-rowfn card data) + first) + (or (ui-logic/y-axis-rowfn card data) + second)]) (defn- datetime-field? [field] @@ -109,12 +147,53 @@ (or (isa? (:base_type field) :type/Number) (isa? (:special_type field) :type/Number))) +(defn detect-pulse-card-type + "Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`." + [card data] + (let [col-count (-> data :cols count) + row-count (-> data :rows count) + [col-1-rowfn col-2-rowfn] (graphing-columns card data) + col-1 (col-1-rowfn (:cols data)) + col-2 (col-2-rowfn (:cols data)) + aggregation (-> card :dataset_query :query :aggregation first)] + (cond + (or (zero? row-count) + ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters + (= [[nil]] (-> data :rows))) :empty + (contains? #{:pin_map :state :country} (:display card)) nil + (and (= col-count 1) + (= row-count 1)) :scalar + (and (= col-count 2) + (> row-count 1) + (datetime-field? col-1) + (number-field? col-2)) :sparkline + (and (= col-count 2) + (number-field? col-2)) :bar + :else :table))) + +(defn include-csv-attachment? + "Returns true if this card and resultset should include a CSV attachment" + [{:keys [include_csv] :as card} {:keys [cols rows] :as result-data}] + (or (:include_csv card) + (and (= :table (detect-pulse-card-type card result-data)) + (or (< cols-limit (count cols)) + (< rows-limit (count rows)))))) + +(defn include-xls-attachment? + "Returns true if this card and resultset should include an XLS attachment" + [{:keys [include_csv] :as card} result-data] + (:include_xls card)) + ;;; # ------------------------------------------------------------ FORMATTING ------------------------------------------------------------ +(defrecord NumericWrapper [num-str] + hutil/ToString + (to-str [_] num-str)) + (defn- format-number [n] - (cl-format nil (if (integer? n) "~:d" "~,2f") n)) + (NumericWrapper. (cl-format nil (if (integer? n) "~:d" "~,2f") n))) (defn- reformat-timestamp [timezone old-format-timestamp new-format-string] (f/unparse (f/with-zone (f/formatter new-format-string) @@ -251,22 +330,35 @@ (render-to-png html os width) (.toByteArray os))) + +(defn- heading-style-for-type + [cell] + (if (instance? NumericWrapper cell) + bar-th-numeric-style + bar-th-style)) + +(defn- row-style-for-type + [cell] + (if (instance? NumericWrapper cell) + bar-td-style-numeric + bar-td-style)) + (defn- render-table [header+rows] - [:table {:style (style {:padding-bottom :8px, :border-bottom (str "4px solid " color-gray-1)})} + [:table {:style (style {:max-width (str "100%"), :white-space :nowrap, :padding-bottom :8px, :border-collapse :collapse})} (let [{header-row :row bar-width :bar-width} (first header+rows)] [:thead [:tr (for [header-cell header-row] - [:th {:style (style bar-td-style bar-th-style {:min-width :60px})} + [:th {:style (style (row-style-for-type header-cell) (heading-style-for-type header-cell) {:min-width :60px})} (h header-cell)]) (when bar-width [:th {:style (style bar-td-style bar-th-style {:width (str bar-width "%")})}])]]) [:tbody (map-indexed (fn [row-idx {:keys [row bar-width]}] - [:tr {:style (style {:color (if (odd? row-idx) color-gray-2 color-gray-3)})} + [:tr {:style (style {:color color-gray-3})} (map-indexed (fn [col-idx cell] - [:td {:style (style bar-td-style (when (and bar-width (= col-idx 1)) {:font-weight 700}))} + [:td {:style (style (row-style-for-type cell) (when (and bar-width (= col-idx 1)) {:font-weight 700}))} (h cell)]) row) (when bar-width @@ -289,18 +381,27 @@ :when remapped_from] [remapped_from col-idx]))) +(defn- show-in-table? [{:keys [special_type visibility_type] :as column}] + (and (not (isa? special_type :type/Description)) + (not (contains? #{:details-only :retired :sensitive} visibility_type)))) + (defn- query-results->header-row "Returns a row structure with header info from `COLS`. These values are strings that are ready to be rendered as HTML" [remapping-lookup cols include-bar?] {:row (for [maybe-remapped-col cols - :let [col (if (:remapped_to maybe-remapped-col) - (nth cols (get remapping-lookup (:name maybe-remapped-col))) - maybe-remapped-col)] + :when (show-in-table? maybe-remapped-col) + :let [{:keys [base_type special_type] :as col} (if (:remapped_to maybe-remapped-col) + (nth cols (get remapping-lookup (:name maybe-remapped-col))) + maybe-remapped-col) + column-name (name (or (:display_name col) (:name col)))] ;; If this column is remapped from another, it's already ;; in the output and should be skipped :when (not (:remapped_from maybe-remapped-col))] - (str/upper-case (name (or (:display_name col) (:name col))))) + (if (or (isa? base_type :type/Number) + (isa? special_type :type/Number)) + (NumericWrapper. column-name) + column-name)) :bar-width (when include-bar? 99)}) (defn- query-results->row-seq @@ -311,7 +412,8 @@ ;; cast to double to avoid "Non-terminating decimal expansion" errors (float (* 100 (/ (double (bar-column row)) max-value)))) :row (for [[maybe-remapped-col maybe-remapped-row-cell] (map vector cols row) - :when (not (:remapped_from maybe-remapped-col)) + :when (and (not (:remapped_from maybe-remapped-col)) + (show-in-table? maybe-remapped-col)) :let [[col row-cell] (if (:remapped_to maybe-remapped-col) [(nth cols (get remapping-lookup (:name maybe-remapped-col))) (nth row (get remapping-lookup (:name maybe-remapped-col)))] @@ -328,38 +430,58 @@ (query-results->header-row remapping-lookup limited-cols bar-column) (query-results->row-seq timezone remapping-lookup limited-cols (take rows-limit rows) bar-column max-value)))) +(defn- strong-limit-text [number] + [:strong {:style (style {:color color-gray-3})} (h (format-number number))]) + (defn- render-truncation-warning [col-limit col-count row-limit row-count] - (if (or (> row-count row-limit) - (> col-count col-limit)) - [:div {:style (style {:padding-top :16px})} - (cond - (> row-count row-limit) - [:div {:style (style {:color color-gray-2 - :padding-bottom :10px})} - "Showing " [:strong {:style (style {:color color-gray-3})} (format-number row-limit)] - " of " [:strong {:style (style {:color color-gray-3})} (format-number row-count)] - " rows."] - - (> col-count col-limit) - [:div {:style (style {:color color-gray-2 - :padding-bottom :10px})} - "Showing " [:strong {:style (style {:color color-gray-3})} (format-number col-limit)] - " of " [:strong {:style (style {:color color-gray-3})} (format-number col-count)] - " columns."])])) + (let [over-row-limit (> row-count row-limit) + over-col-limit (> col-count col-limit)] + (when (or over-row-limit over-col-limit) + [:div {:style (style {:padding-top :16px})} + (cond + + (and over-row-limit over-col-limit) + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text row-limit) + " of " (strong-limit-text row-count) + " rows and " (strong-limit-text col-limit) + " of " (strong-limit-text col-count) + " columns."] + + over-row-limit + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text row-limit) + " of " (strong-limit-text row-count) + " rows."] + + over-col-limit + [:div {:style (style {:color color-gray-2 + :padding-bottom :10px})} + "Showing " (strong-limit-text col-limit) + " of " (strong-limit-text col-count) + " columns."])]))) + +(defn- attached-results-text + "Returns hiccup structures to indicate truncated results are available as an attachment" + [render-type cols cols-limit rows rows-limit] + (when (and (not= :inline render-type) + (or (< cols-limit (count cols)) + (< rows-limit (count rows)))) + [:div {:style (style {:color color-gray-2})} + "More results have been included as a file attachment"])) (s/defn ^:private render:table :- RenderedPulseCard - [timezone card {:keys [cols rows] :as data}] - {:attachments nil - :content [:div - (render-table (prep-for-html-rendering timezone cols rows nil nil cols-limit)) - (render-truncation-warning cols-limit (count cols) rows-limit (count rows))]}) - -(defn- graphing-columns [card {:keys [cols] :as data}] - [(or (ui-logic/x-axis-rowfn card data) - first) - (or (ui-logic/y-axis-rowfn card data) - second)]) + [render-type timezone card {:keys [cols rows] :as data}] + (let [table-body [:div + (render-table (prep-for-html-rendering timezone cols rows nil nil cols-limit)) + (render-truncation-warning cols-limit (count cols) rows-limit (count rows))]] + {:attachments nil + :content (if-let [results-attached (attached-results-text render-type cols cols-limit rows rows-limit)] + (list results-attached table-body) + (list table-body))})) (s/defn ^:private render:bar :- RenderedPulseCard [timezone card {:keys [cols rows] :as data}] @@ -599,31 +721,6 @@ :padding :16px})} "An error occurred while displaying this card."]}) -(defn detect-pulse-card-type - "Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`." - [card data] - (let [col-count (-> data :cols count) - row-count (-> data :rows count) - [col-1-rowfn col-2-rowfn] (graphing-columns card data) - col-1 (col-1-rowfn (:cols data)) - col-2 (col-2-rowfn (:cols data)) - aggregation (-> card :dataset_query :query :aggregation first)] - (cond - (or (zero? row-count) - ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters - (= [[nil]] (-> data :rows))) :empty - (or (> col-count 3) - (contains? #{:pin_map :state :country} (:display card))) nil - (and (= col-count 1) - (= row-count 1)) :scalar - (and (= col-count 2) - (> row-count 1) - (datetime-field? col-1) - (number-field? col-2)) :sparkline - (and (= col-count 2) - (number-field? col-2)) :bar - :else :table))) - (s/defn ^:private make-title-if-needed :- (s/maybe RenderedPulseCard) [render-type card] (when *include-title* @@ -659,7 +756,7 @@ :scalar (render:scalar timezone card data) :sparkline (render:sparkline render-type timezone card data) :bar (render:bar timezone card data) - :table (render:table timezone card data) + :table (render:table render-type timezone card data) (if (is-attached? card) (render:attached render-type card data) (render:unknown card data))) @@ -691,16 +788,20 @@ (s/defn render-pulse-section :- RenderedPulseCard "Render a specific section of a Pulse, i.e. a single Card, to Hiccup HTML." - [timezone {:keys [card result]}] + [timezone {card :card {:keys [data] :as result} :result}] (let [{:keys [attachments content]} (binding [*include-title* true] (render-pulse-card :attachment timezone card result))] {:attachments attachments - :content [:div {:style (style {:margin-top :10px - :margin-bottom :20px - :border "1px solid #dddddd" - :border-radius :2px - :background-color :white - :box-shadow "0 1px 2px rgba(0, 0, 0, .08)"})} + :content [:div {:style (style (merge {:margin-top :10px + :margin-bottom :20px} + ;; Don't include the border on cards rendered with a table as the table + ;; will be to larger and overrun the border + (when-not (= :table (detect-pulse-card-type card data)) + {:border "1px solid #dddddd" + :border-radius :2px + :background-color :white + :width "500px !important" + :box-shadow "0 1px 2px rgba(0, 0, 0, .08)"})))} content]})) (defn render-pulse-card-to-png diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index b87f5e8d1dedb2cea8dd30cc7d6e4ee4a00cf12e..bf203e52134c805cc1b50c1c44965bd27a2c6d4a 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -281,3 +281,22 @@ (run-and-save-query! (assoc query :info (assoc options :query-hash (qputil/query-hash query) :query-type (if (qputil/mbql-query? query) "MBQL" "native"))))) + +(def ^:private ^:const max-results-bare-rows + "Maximum number of rows to return specifically on :rows type queries via the API." + 2000) + +(def ^:private ^:const max-results + "General maximum number of rows to return from an API query." + 10000) + +(def default-query-constraints + "Default map of constraints that we apply on dataset queries executed by the api." + {:max-results max-results + :max-results-bare-rows max-results-bare-rows}) + +(s/defn process-query-and-save-with-max! + "Same as `process-query-and-save-execution!` but will include the default max rows returned as a constraint" + {:style/indent 1} + [query, options :- DatasetQueryOptions] + (process-query-and-save-execution! (assoc query :constraints default-query-constraints) options)) diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index e4915aaf7245d672bac6676e8b6da67f05307cde..2ce781577505b97402bc2b1c7876c638279e4f54 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -8,10 +8,10 @@ [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [medley.core :as m] - [metabase.api.dataset :refer [default-query-constraints]] [metabase.models [database :refer [Database]] [query-execution :refer [QueryExecution]]] + [metabase.query-processor :as qp] [metabase.query-processor.middleware.expand :as ql] [metabase.sync :as sync] [metabase.test @@ -75,7 +75,7 @@ (ql/aggregation (ql/count)))) (assoc :type "query") (assoc-in [:query :aggregation] [{:aggregation-type "count", :custom-name nil}]) - (assoc :constraints default-query-constraints)) + (assoc :constraints qp/default-query-constraints)) :started_at true :running_time true :average_execution_time nil} @@ -113,7 +113,7 @@ :json_query {:database (id) :type "native" :native {:query "foobar"} - :constraints default-query-constraints} + :constraints qp/default-query-constraints} :started_at true :running_time true} ;; QueryExecution entry in the DB diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index 3412c041c24f61d11048b0072ab23c5b90f4bbc9..4a8c49e7fb8fd7f52e675037959a852f8bb8a5de 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -2,6 +2,7 @@ "Tests for /api/session" (:require [expectations :refer :all] [metabase + [email-test :as et] [http-client :refer :all] [public-settings :as public-settings] [util :as u]] @@ -68,16 +69,17 @@ ;; ## POST /api/session/forgot_password ;; Test that we can initiate password reset (expect - (let [reset-fields-set? (fn [] - (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered], :id (user->id :rasta))] - (boolean (and reset_token reset_triggered))))] - ;; make sure user is starting with no values - (db/update! User (user->id :rasta), :reset_token nil, :reset_triggered nil) - (assert (not (reset-fields-set?))) - ;; issue reset request (token & timestamp should be saved) - ((user->client :rasta) :post 200 "session/forgot_password" {:email (:username (user->credentials :rasta))}) - ;; TODO - how can we test email sent here? - (reset-fields-set?))) + (et/with-fake-inbox + (let [reset-fields-set? (fn [] + (let [{:keys [reset_token reset_triggered]} (db/select-one [User :reset_token :reset_triggered], :id (user->id :rasta))] + (boolean (and reset_token reset_triggered))))] + ;; make sure user is starting with no values + (db/update! User (user->id :rasta), :reset_token nil, :reset_triggered nil) + (assert (not (reset-fields-set?))) + ;; issue reset request (token & timestamp should be saved) + ((user->client :rasta) :post 200 "session/forgot_password" {:email (:username (user->credentials :rasta))}) + ;; TODO - how can we test email sent here? + (reset-fields-set?)))) ;; Test that email is required (expect {:errors {:email "value must be a valid email address."}} @@ -94,39 +96,41 @@ (expect {:reset_token nil :reset_triggered nil} - (let [password {:old "password" - :new "whateverUP12!!"}] - (tt/with-temp User [{:keys [email id]} {:password (:old password), :reset_triggered (System/currentTimeMillis)}] - (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) - (db/update! User id, :reset_token <>)) - creds {:old {:password (:old password) - :username email} - :new {:password (:new password) - :username email}}] - ;; Check that creds work - (client :post 200 "session" (:old creds)) - - ;; Call reset password endpoint to change the PW - (client :post 200 "session/reset_password" {:token token - :password (:new password)}) - ;; Old creds should no longer work - (assert (= (client :post 400 "session" (:old creds)) - {:errors {:password "did not match stored password"}})) - ;; New creds *should* work - (client :post 200 "session" (:new creds)) - ;; Double check that reset token was cleared - (db/select-one [User :reset_token :reset_triggered], :id id))))) + (et/with-fake-inbox + (let [password {:old "password" + :new "whateverUP12!!"}] + (tt/with-temp User [{:keys [email id]} {:password (:old password), :reset_triggered (System/currentTimeMillis)}] + (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) + (db/update! User id, :reset_token <>)) + creds {:old {:password (:old password) + :username email} + :new {:password (:new password) + :username email}}] + ;; Check that creds work + (client :post 200 "session" (:old creds)) + + ;; Call reset password endpoint to change the PW + (client :post 200 "session/reset_password" {:token token + :password (:new password)}) + ;; Old creds should no longer work + (assert (= (client :post 400 "session" (:old creds)) + {:errors {:password "did not match stored password"}})) + ;; New creds *should* work + (client :post 200 "session" (:new creds)) + ;; Double check that reset token was cleared + (db/select-one [User :reset_token :reset_triggered], :id id)))))) ;; Check that password reset returns a valid session token (expect {:success true :session_id true} - (tt/with-temp User [{:keys [id]} {:reset_triggered (System/currentTimeMillis)}] - (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) - (db/update! User id, :reset_token <>))] - (-> (client :post 200 "session/reset_password" {:token token - :password "whateverUP12!!"}) - (update :session_id tu/is-uuid-string?))))) + (et/with-fake-inbox + (tt/with-temp User [{:keys [id]} {:reset_triggered (System/currentTimeMillis)}] + (let [token (u/prog1 (str id "_" (java.util.UUID/randomUUID)) + (db/update! User id, :reset_token <>))] + (-> (client :post 200 "session/reset_password" {:token token + :password "whateverUP12!!"}) + (update :session_id tu/is-uuid-string?)))))) ;; Test that token and password are required (expect {:errors {:token "value must be a non-blank string."}} @@ -217,11 +221,12 @@ ;; should totally work if the email domains match up (expect {:first_name "Rasta", :last_name "Toucan", :email "rasta@sf-toucannery.com"} - (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" - admin-email "rasta@toucans.com"] - (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") - (db/delete! User :id (:id <>))) ; make sure we clean up after ourselves ! - [:first_name :last_name :email]))) + (et/with-fake-inbox + (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" + admin-email "rasta@toucans.com"] + (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") + (db/delete! User :id (:id <>))) ; make sure we clean up after ourselves ! + [:first_name :last_name :email])))) ;;; tests for google-auth-fetch-or-create-user! @@ -248,10 +253,11 @@ ;; test that a user that doesn't exist with the *same* domain as the auto-create accounts domain means a new user gets ;; created (expect - (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" - admin-email "rasta@toucans.com"] - (u/prog1 (is-session? (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) - (db/delete! User :email "rasta@sf-toucannery.com")))) ; clean up after ourselves + (et/with-fake-inbox + (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" + admin-email "rasta@toucans.com"] + (u/prog1 (is-session? (#'session-api/google-auth-fetch-or-create-user! "Rasta" "Toucan" "rasta@sf-toucannery.com")) + (db/delete! User :email "rasta@sf-toucannery.com"))))) ; clean up after ourselves ;;; ------------------------------------------- TESTS FOR LDAP AUTH STUFF -------------------------------------------- diff --git a/test/metabase/api/user_test.clj b/test/metabase/api/user_test.clj index 8e82e371e6890feb60e58ffeb2798ab0fe10a31c..04f58fa3e8c73443d339ee49a2da72fe0b961eb9 100644 --- a/test/metabase/api/user_test.clj +++ b/test/metabase/api/user_test.clj @@ -2,6 +2,7 @@ "Tests for /api/user endpoints." (:require [expectations :refer :all] [metabase + [email-test :as et] [http-client :as http] [middleware :as middleware] [util :as u]] @@ -73,13 +74,14 @@ :common_name (str user-name " " user-name) :is_superuser false :is_qbnewb true} - (do ((user->client :crowberto) :post 200 "user" {:first_name user-name - :last_name user-name - :email email}) - (u/prog1 (db/select-one [User :email :first_name :last_name :is_superuser :is_qbnewb] - :email email) - ;; clean up after ourselves - (db/delete! User :email email))))) + (et/with-fake-inbox + ((user->client :crowberto) :post 200 "user" {:first_name user-name + :last_name user-name + :email email}) + (u/prog1 (db/select-one [User :email :first_name :last_name :is_superuser :is_qbnewb] + :email email) + ;; clean up after ourselves + (db/delete! User :email email))))) ;; Test that reactivating a disabled account works diff --git a/test/metabase/pulse/render_test.clj b/test/metabase/pulse/render_test.clj index 43e01a9cf5ac0c26855ec811bf6f0b051c8ed3dc..2f5e95915630407467e54ee74ef4a7f0e2df6e82 100644 --- a/test/metabase/pulse/render_test.clj +++ b/test/metabase/pulse/render_test.clj @@ -1,5 +1,6 @@ (ns metabase.pulse.render-test - (:require [expectations :refer :all] + (:require [clojure.walk :as walk] + [expectations :refer :all] [hiccup.core :refer [html]] [metabase.pulse.render :as render :refer :all]) (:import java.util.TimeZone)) @@ -7,58 +8,120 @@ (def ^:private pacific-tz (TimeZone/getTimeZone "America/Los_Angeles")) (def ^:private test-columns - [{:name "ID", - :display_name "ID", - :base_type :type/BigInteger - :special_type nil} - {:name "latitude" - :display_name "Latitude" - :base-type :type/Float - :special-type :type/Latitude} - {:name "last_login" - :display_name "Last Login" - :base_type :type/DateTime - :special_type nil} - {:name "name" - :display_name "Name" - :base-type :type/Text - :special_type nil}]) + [{:name "ID", + :display_name "ID", + :base_type :type/BigInteger + :special_type nil + :visibility_type :normal} + {:name "latitude" + :display_name "Latitude" + :base_type :type/Float + :special_type :type/Latitude + :visibility_type :normal} + {:name "last_login" + :display_name "Last Login" + :base_type :type/DateTime + :special_type nil + :visibility_type :normal} + {:name "name" + :display_name "Name" + :base_type :type/Text + :special_type nil + :visibility_type :normal}]) (def ^:private test-data [[1 34.0996 "2014-04-01T08:30:00.0000" "Stout Burgers & Beers"] [2 34.0406 "2014-12-05T15:15:00.0000" "The Apple Pan"] [3 34.0474 "2014-08-01T12:45:00.0000" "The Gorbals"]]) +(defn- col-counts [results] + (set (map (comp count :row) results))) + +(defn- number [x] + (#'render/map->NumericWrapper {:num-str x})) + +(def ^:private default-header-result + [{:row [(number "ID") (number "Latitude") "Last Login" "Name"] + :bar-width nil} + #{4}]) + +(defn- prep-for-html-rendering' + [cols rows bar-column max-value] + (let [results (#'render/prep-for-html-rendering pacific-tz cols rows bar-column max-value (count cols))] + [(first results) + (col-counts results)])) + + + ;; Testing the format of headers (expect - {:row ["ID" "LATITUDE" "LAST LOGIN" "NAME"] - :bar-width nil} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data nil nil (count test-columns)))) + default-header-result + (prep-for-html-rendering' test-columns test-data nil nil)) + +(expect + default-header-result + (let [cols-with-desc (conj test-columns {:name "desc_col" + :display_name "Description Column" + :base_type :type/Text + :special_type :type/Description + :visibility_type :normal}) + data-with-desc (mapv #(conj % "Desc") test-data)] + (prep-for-html-rendering' cols-with-desc data-with-desc nil nil))) + +(expect + default-header-result + (let [cols-with-details (conj test-columns {:name "detail_col" + :display_name "Details Column" + :base_type :type/Text + :special_type nil + :visibility_type :details-only}) + data-with-details (mapv #(conj % "Details") test-data)] + (prep-for-html-rendering' cols-with-details data-with-details nil nil))) + +(expect + default-header-result + (let [cols-with-sensitive (conj test-columns {:name "sensitive_col" + :display_name "Sensitive Column" + :base_type :type/Text + :special_type nil + :visibility_type :sensitive}) + data-with-sensitive (mapv #(conj % "Sensitive") test-data)] + (prep-for-html-rendering' cols-with-sensitive data-with-sensitive nil nil))) + +(expect + default-header-result + (let [columns-with-retired (conj test-columns {:name "retired_col" + :display_name "Retired Column" + :base_type :type/Text + :special_type nil + :visibility_type :retired}) + data-with-retired (mapv #(conj % "Retired") test-data)] + (prep-for-html-rendering' columns-with-retired data-with-retired nil nil))) ;; When including a bar column, bar-width is 99% (expect - {:row ["ID" "LATITUDE" "LAST LOGIN" "NAME"] - :bar-width 99} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40.0 (count test-columns)))) + (assoc-in default-header-result [0 :bar-width] 99) + (prep-for-html-rendering' test-columns test-data second 40.0)) ;; When there are too many columns, #'render/prep-for-html-rendering show narrow it (expect - {:row ["ID" "LATITUDE"] - :bar-width 99} - (first (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40.0 2))) + [{:row [(number "ID") (number "Latitude")] + :bar-width 99} + #{2}] + (prep-for-html-rendering' (subvec test-columns 0 2) test-data second 40.0 )) ;; Basic test that result rows are formatted correctly (dates, floating point numbers etc) (expect - [{:bar-width nil, :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width nil, :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width nil, :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width nil, :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width nil, :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width nil, :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns test-data nil nil (count test-columns)))) ;; Testing the bar-column, which is the % of this row relative to the max of that column (expect - [{:bar-width (float 85.249), :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width (float 85.1015), :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width (float 85.1185), :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width (float 85.249), :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width (float 85.1015), :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width (float 85.1185), :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns test-data second 40 (count test-columns)))) (defn- add-rating @@ -91,15 +154,16 @@ ;; With a remapped column, the header should contain the name of the remapped column (not the original) (expect - {:row ["ID" "LATITUDE" "RATING DESC" "LAST LOGIN" "NAME"] - :bar-width nil} - (first (#'render/prep-for-html-rendering pacific-tz test-columns-with-remapping test-data-with-remapping nil nil (count test-columns-with-remapping)))) + [{:row [(number "ID") (number "Latitude") "Rating Desc" "Last Login" "Name"] + :bar-width nil} + #{5}] + (prep-for-html-rendering' test-columns-with-remapping test-data-with-remapping nil nil)) ;; Result rows should include only the remapped column value, not the original (expect - [["1" "34.10" "Bad" "Apr 1, 2014" "Stout Burgers & Beers"] - ["2" "34.04" "Ok" "Dec 5, 2014" "The Apple Pan"] - ["3" "34.05" "Good" "Aug 1, 2014" "The Gorbals"]] + [[(number "1") (number "34.10") "Bad" "Apr 1, 2014" "Stout Burgers & Beers"] + [(number "2") (number "34.04") "Ok" "Dec 5, 2014" "The Apple Pan"] + [(number "3") (number "34.05") "Good" "Aug 1, 2014" "The Gorbals"]] (map :row (rest (#'render/prep-for-html-rendering pacific-tz test-columns-with-remapping test-data-with-remapping nil nil (count test-columns-with-remapping))))) ;; There should be no truncation warning if the number of rows/cols is fewer than the row/column limit @@ -126,9 +190,9 @@ :special_type :type/DateTime})) (expect - [{:bar-width nil, :row ["1" "34.10" "Apr 1, 2014" "Stout Burgers & Beers"]} - {:bar-width nil, :row ["2" "34.04" "Dec 5, 2014" "The Apple Pan"]} - {:bar-width nil, :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}] + [{:bar-width nil, :row [(number "1") (number "34.10") "Apr 1, 2014" "Stout Burgers & Beers"]} + {:bar-width nil, :row [(number "2") (number "34.04") "Dec 5, 2014" "The Apple Pan"]} + {:bar-width nil, :row [(number "3") (number "34.05") "Aug 1, 2014" "The Gorbals"]}] (rest (#'render/prep-for-html-rendering pacific-tz test-columns-with-date-special-type test-data nil nil (count test-columns)))) (defn- render-scalar-value [results] @@ -166,3 +230,47 @@ :base_type :type/DateTime :special_type nil}] :rows [["2014-04-01T08:30:00.0000"]]})) + +(defn- replace-style-maps [hiccup-map] + (walk/postwalk (fn [maybe-map] + (if (and (map? maybe-map) + (contains? maybe-map :style)) + :style-map + maybe-map)) hiccup-map)) + +(def ^:private render-truncation-warning' + (comp replace-style-maps #'render/render-truncation-warning)) + +(expect + nil + (render-truncation-warning' 10 5 20 10)) + +(expect + [:div :style-map + [:div :style-map + "Showing " [:strong :style-map "10"] " of " + [:strong :style-map "11"] " columns."]] + (render-truncation-warning' 10 11 20 10)) + +(expect + [:div + :style-map + [:div :style-map "Showing " + [:strong :style-map "20"] " of " [:strong :style-map "21"] " rows."]] + (render-truncation-warning' 10 5 20 21)) + +(expect + [:div + :style-map + [:div + :style-map + "Showing " + [:strong :style-map "20"] + " of " + [:strong :style-map "21"] + " rows and " + [:strong :style-map "10"] + " of " + [:strong :style-map "11"] + " columns."]] + (render-truncation-warning' 10 11 20 21)) diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj index 7c4b06ecfaeddabe54957b1af6a5cd3758befd7e..2bce6e85ef8a39936523b961fbfe7061d3dbf9a3 100644 --- a/test/metabase/pulse_test.clj +++ b/test/metabase/pulse_test.clj @@ -1,17 +1,20 @@ (ns metabase.pulse-test - (:require [clojure.walk :as walk] + (:require [clojure.string :as str] + [clojure.walk :as walk] [expectations :refer :all] [medley.core :as m] [metabase.integrations.slack :as slack] [metabase [email-test :as et] - [pulse :refer :all]] + [pulse :refer :all] + [query-processor :as qp]] [metabase.models [card :refer [Card]] [pulse :refer [Pulse retrieve-pulse retrieve-pulse-or-alert]] [pulse-card :refer [PulseCard]] [pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]]] + [metabase.pulse.render :as render] [metabase.test [data :as data] [util :as tu]] @@ -73,6 +76,15 @@ png-attachment]} email))) +(def ^:private csv-attachment + {:type :attachment, :content-type "text/csv", :file-name "Test card.csv", + :content java.net.URL, :description "More results for 'Test card'", :content-id false}) + +(def ^:private xls-attachment + {:type :attachment, :file-name "Test card.xlsx", + :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + :content java.net.URL, :description "More results for 'Test card'", :content-id false}) + ;; Basic test, 1 card, 1 recipient (expect (rasta-pulse-email) @@ -89,6 +101,72 @@ (send-pulse! (retrieve-pulse pulse-id)) (et/summarize-multipart-email #"Pulse Name")))) +;; Basic test, 1 card, 1 recipient, 21 results results in a CSV being attached and a table being sent +(expect + (rasta-pulse-email {:body [{"Pulse Name" true + "More results have been included" true + "ID</th>" true}, + csv-attachment]}) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 21})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse pulse-id)) + (et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>")))) + +;; Validate pulse queries are limited by qp/default-query-constraints +(expect + 31 ;; Should return 30 results (the redef'd limit) plus the header row + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (with-redefs [qp/default-query-constraints {:max-results 10000 + :max-results-bare-rows 30}] + (send-pulse! (retrieve-pulse pulse-id)) + ;; Slurp in the generated CSV and count the lines found in the file + (-> @et/inbox + vals + ffirst + :body + last + :content + slurp + str/split-lines + count))))) + +;; Basic test, 1 card, 1 recipient, 19 results, so no attachment +(expect + (rasta-pulse-email {:body [{"Pulse Name" true + "More results have been included" false + "ID</th>" true}]}) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 19})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse pulse-id)) + (et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>")))) + ;; Pulse should be sent to two recipients (expect (into {} (map (fn [user-kwd] @@ -193,7 +271,8 @@ ;; Rows alert with data (expect (rasta-alert-email "Metabase alert: Test card has results" - [{"Test card.*has results for you to see" true}, png-attachment]) + [{"Test card.*has results for you to see" true + "More results have been included" false}, png-attachment]) (tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:alert_condition "rows" :alert_first_only false}] @@ -205,7 +284,28 @@ :pulse_channel_id pc-id}]] (email-test-setup (send-pulse! (retrieve-pulse-or-alert pulse-id)) - (et/summarize-multipart-email #"Test card.*has results for you to see")))) + (et/summarize-multipart-email #"Test card.*has results for you to see" #"More results have been included")))) + +;; Rows alert with too much data will attach as CSV and include a table +(expect + (rasta-alert-email "Metabase alert: Test card has results" + [{"Test card.*has results for you to see" true + "More results have been included" true + "ID</th>" true}, + csv-attachment]) + (tt/with-temp* [Card [{card-id :id} (checkins-query {:limit 21 + :aggregation nil})] + Pulse [{pulse-id :id} {:alert_condition "rows" + :alert_first_only false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [_ {:user_id (rasta-id) + :pulse_channel_id pc-id}]] + (email-test-setup + (send-pulse! (retrieve-pulse-or-alert pulse-id)) + (et/summarize-multipart-email #"Test card.*has results for you to see" #"More results have been included" #"ID</th>")))) ;; Above goal alert with data (expect @@ -322,6 +422,42 @@ (assoc result :attachments (for [attachment-info attachments] (update attachment-info :attachment-bytes-thunk fn?)))) +(defprotocol WrappedFunction + (input [_]) + (output [_])) + +(defn- invoke-with-wrapping + "Apply `args` to `func`, capturing the arguments of the invocation and the result of the invocation. Store the arguments in + `input-atom` and the result in `output-atom`." + [input-atom output-atom func args] + (swap! input-atom conj args) + (let [result (apply func args)] + (swap! output-atom conj result) + result)) + +(defn- wrap-function + "Return a function that wraps `func`, not interfering with it but recording it's input and output, which is + available via the `input` function and `output`function that can be used directly on this object" + [func] + (let [input (atom nil) + output (atom nil)] + (reify WrappedFunction + (input [_] @input) + (output [_] @output) + clojure.lang.IFn + (invoke [_ x1] + (invoke-with-wrapping input output func [x1])) + (invoke [_ x1 x2] + (invoke-with-wrapping input output func [x1 x2])) + (invoke [_ x1 x2 x3] + (invoke-with-wrapping input output func [x1 x2 x3])) + (invoke [_ x1 x2 x3 x4] + (invoke-with-wrapping input output func [x1 x2 x3 x4])) + (invoke [_ x1 x2 x3 x4 x5] + (invoke-with-wrapping input output func [x1 x2 x3 x4 x5])) + (invoke [_ x1 x2 x3 x4 x5 x6] + (invoke-with-wrapping input output func [x1 x2 x3 x4 x5 x6]))))) + ;; Basic slack test, 1 card, 1 recipient channel (tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})] Pulse [{pulse-id :id} {:name "Pulse Name" @@ -346,6 +482,47 @@ first thunk->boolean))) +(defn- force-bytes-thunk + "Grabs the thunk that produces the image byte array and invokes it" + [results] + ((-> results + :attachments + first + :attachment-bytes-thunk))) + +;; Basic slack test, 1 card, 1 recipient channel, verifies that "more results in attachment" text is not present for +;; slack pulses +(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:aggregation nil + :limit 25})] + Pulse [{pulse-id :id} {:name "Pulse Name" + :skip_if_empty false}] + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id + :channel_type "slack" + :details {:channel "#general"}}]] + [{:channel-id "#general", + :message "Pulse: Pulse Name", + :attachments + [{:title "Test card", + :attachment-bytes-thunk true + :title_link (str "https://metabase.com/testmb/question/" card-id), + :attachment-name "image.png", + :channel-id "FOO", + :fallback "Test card"}]} + 1 ;; -> attached-results-text should be invoked exactly once + [nil] ;; -> attached-results-text should return nil since it's a slack message + ] + (slack-test-setup + (with-redefs [render/attached-results-text (wrap-function (var-get #'render/attached-results-text))] + (let [[pulse-results] (send-pulse! (retrieve-pulse pulse-id))] + ;; If we don't force the thunk, the rendering code will never execute and attached-results-text won't be called + (force-bytes-thunk pulse-results) + [(thunk->boolean pulse-results) + (count (input (var-get #'render/attached-results-text))) + (output (var-get #'render/attached-results-text))])))) + (defn- produces-bytes? [{:keys [attachment-bytes-thunk]}] (< 0 (alength (attachment-bytes-thunk)))) @@ -542,15 +719,6 @@ [@et/inbox (db/exists? Pulse :id pulse-id)]))) -(def ^:private csv-attachment - {:type :attachment, :content-type "text/csv", :file-name "Test card.csv", - :content java.net.URL, :description "Full results for 'Test card'", :content-id false}) - -(def ^:private xls-attachment - {:type :attachment, :file-name "Test card.xlsx", - :content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - :content java.net.URL, :description "Full results for 'Test card'", :content-id false}) - (defn- add-rasta-attachment "Append `ATTACHMENT` to the first email found for Rasta" [email attachment]