Skip to content
Snippets Groups Projects
Unverified Commit e01bad0a authored by Ryan Senior's avatar Ryan Senior Committed by GitHub
Browse files

Merge pull request #6458 from metabase/export-refactor

Export and pulse image attachment refactor
parents a8ee0217 add12c47
Branches
Tags
No related merge requests found
(ns metabase.api.dataset
"/api/dataset endpoints."
(:require [cheshire.core :as json]
[clojure.data.csv :as csv]
[clojure.string :as str]
[clojure.tools.logging :as log]
[compojure.core :refer [POST]]
[dk.ative.docjure.spreadsheet :as spreadsheet]
[metabase
[middleware :as middleware]
[query-processor :as qp]
......@@ -17,10 +15,10 @@
[database :as database :refer [Database]]
[query :as query]]
[metabase.query-processor.util :as qputil]
[metabase.util.schema :as su]
[schema.core :as s])
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]
org.apache.poi.ss.usermodel.Cell))
[metabase.util
[export :as ex]
[schema :as su]]
[schema.core :as s]))
;;; --------------------------------------------------- Constants ----------------------------------------------------
......@@ -66,52 +64,9 @@
;;; ----------------------------------- Downloading Query Results in Other Formats -----------------------------------
;; add a generic implementation for the method that writes values to XLSX cells that just piggybacks off the
;; implementations we've already defined for encoding things as JSON. These implementations live in
;; `metabase.middleware`.
(defmethod spreadsheet/set-cell! Object [^Cell cell, value]
(when (= (.getCellType cell) Cell/CELL_TYPE_FORMULA)
(.setCellType cell Cell/CELL_TYPE_STRING))
;; stick the object in a JSON map and encode it, which will force conversion to a string. Then unparse that JSON and
;; use the resulting value as the cell's new String value. There might be some more efficient way of doing this but
;; I'm not sure what it is.
(.setCellValue cell (str (-> (json/generate-string {:v value})
(json/parse-string keyword)
:v))))
(defn- export-to-xlsx [columns rows]
(let [wb (spreadsheet/create-workbook "Query result" (cons (mapv name columns) rows))
;; note: byte array streams don't need to be closed
out (ByteArrayOutputStream.)]
(spreadsheet/save-workbook! out wb)
(ByteArrayInputStream. (.toByteArray out))))
(defn- export-to-csv [columns rows]
(with-out-str
;; turn keywords into strings, otherwise we get colons in our output
(csv/write-csv *out* (into [(mapv name columns)] rows))))
(defn- export-to-json [columns rows]
(for [row rows]
(zipmap columns row)))
(def ^:private export-formats
{"csv" {:export-fn export-to-csv
:content-type "text/csv"
:ext "csv"
:context :csv-download},
"xlsx" {:export-fn export-to-xlsx
:content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:ext "xlsx"
:context :xlsx-download},
"json" {:export-fn export-to-json
:content-type "applicaton/json"
:ext "json"
:context :json-download}})
(def ExportFormat
"Schema for valid export formats for downloading query results."
(apply s/enum (keys export-formats)))
(apply s/enum (keys ex/export-formats)))
(defn export-format->context
"Return the `:context` that should be used when saving a QueryExecution triggered by a request to download results
......@@ -119,14 +74,14 @@
(export-format->context :json) ;-> :json-download"
[export-format]
(or (get-in export-formats [export-format :context])
(or (get-in ex/export-formats [export-format :context])
(throw (Exception. (str "Invalid export format: " export-format)))))
(defn as-format
"Return a response containing the RESULTS of a query in the specified format."
{:style/indent 1, :arglists '([export-format results])}
[export-format {{:keys [columns rows]} :data, :keys [status], :as response}]
(api/let-404 [export-conf (export-formats export-format)]
(api/let-404 [export-conf (ex/export-formats export-format)]
(if (= status :completed)
;; successful query, send file
{:status 200
......@@ -142,7 +97,7 @@
Inteneded for use in an endpoint definition:
(api/defendpoint POST [\"/:export-format\", :export-format export-format-regex]"
(re-pattern (str "(" (str/join "|" (keys export-formats)) ")")))
(re-pattern (str "(" (str/join "|" (keys ex/export-formats)) ")")))
(api/defendpoint POST ["/:export-format", :export-format export-format-regex]
"Execute a query and download the result data as a file in the specified format."
......
......@@ -114,7 +114,7 @@
:card-id id})]
{:status 200, :body (html [:html [:body {:style "margin: 0;"} (binding [render/*include-title* true
render/*include-buttons* true]
(render/render-pulse-card (p/defaulted-timezone card) card result))]])}))
(render/render-pulse-card-for-display (p/defaulted-timezone card) card result))]])}))
(api/defendpoint GET "/preview_card_info/:id"
"Get JSON object containing HTML rendering of a `Card` with ID and other information."
......@@ -126,7 +126,7 @@
data (:data result)
card-type (render/detect-pulse-card-type card data)
card-html (html (binding [render/*include-title* true]
(render/render-pulse-card (p/defaulted-timezone card) card result)))]
(render/render-pulse-card-for-display (p/defaulted-timezone card) card result)))]
{:id id
:pulse_card_type card-type
:pulse_card_html card-html
......
......@@ -6,20 +6,21 @@
[postal
[core :as postal]
[support :refer [make-props]]]
[puppetlabs.i18n.core :refer [tru trs]]
[schema.core :as s])
(:import javax.mail.Session))
;;; CONFIG
;; TODO - smtp-port should be switched to type :integer
(defsetting email-from-address "Email address you want to use as the sender of Metabase." :default "notifications@metabase.com")
(defsetting email-smtp-host "The address of the SMTP server that handles your emails.")
(defsetting email-smtp-username "SMTP username.")
(defsetting email-smtp-password "SMTP password.")
(defsetting email-smtp-port "The port your SMTP server uses for outgoing emails.")
(defsetting email-from-address (tru "Email address you want to use as the sender of Metabase.") :default "notifications@metabase.com")
(defsetting email-smtp-host (tru "The address of the SMTP server that handles your emails."))
(defsetting email-smtp-username (tru "SMTP username."))
(defsetting email-smtp-password (tru "SMTP password."))
(defsetting email-smtp-port (tru "The port your SMTP server uses for outgoing emails."))
(defsetting email-smtp-security
"SMTP secure connection protocol. (tls, ssl, starttls, or none)"
:default "none"
(tru "SMTP secure connection protocol. (tls, ssl, starttls, or none)")
:default (tru "none")
:setter (fn [new-value]
(when-not (nil? new-value)
(assert (contains? #{"tls" "ssl" "none" "starttls"} new-value)))
......@@ -71,7 +72,8 @@
{:style/indent 0}
[{:keys [subject recipients message-type message]} :- EmailMessage]
(when-not (email-smtp-host)
(throw (Exception. "SMTP host is not set.")))
(let [^String msg (tru "SMTP host is not set.")]
(throw (Exception. msg))))
;; Now send the email
(send-email! (smtp-settings)
{:from (email-from-address)
......@@ -100,8 +102,7 @@
(try
(send-message-or-throw! msg-args)
(catch Throwable e
(println "Failed to send email:" e)
(log/warn e "Failed to send email")
(log/warn e (trs "Failed to send email"))
{:error :ERROR
:message (.getMessage e)})))
......@@ -126,7 +127,7 @@
{:error :SUCCESS
:message nil}
(catch Throwable e
(log/error "Error testing SMTP connection:" (.getMessage e))
(log/error e (trs "Error testing SMTP connection"))
{:error :ERROR
:message (.getMessage e)})))
......
......@@ -178,55 +178,33 @@
:message-type :html
:message message-body)))
;; HACK: temporary workaround to postal requiring a file as the attachment
(defn- write-byte-array-to-temp-file
[^bytes img-bytes]
(u/prog1 (doto (File/createTempFile "metabase_pulse_image_" ".png")
.deleteOnExit)
(with-open [fos (FileOutputStream. <>)]
(.write fos img-bytes))))
(defn- hash-bytes
"Generate a hash to be used in a Content-ID"
[^bytes img-bytes]
(Math/abs ^Integer (Arrays/hashCode img-bytes)))
(defn- render-image [images-atom, ^bytes image-bytes]
(let [content-id (str (hash-bytes image-bytes) "@metabase")]
(if-not (contains? @images-atom content-id)
(swap! images-atom assoc content-id image-bytes))
(str "cid:" content-id)))
(defn- write-image-content [[content-id bytes]]
(defn- make-message-attachment [[content-id url]]
{:type :inline
:content-id content-id
:content-type "image/png"
:content (write-byte-array-to-temp-file bytes)})
:content url})
(defn- pulse-context [body pulse]
(defn- pulse-context [pulse]
(merge {:emailType "pulse"
:pulse (html body)
:pulseName (:name pulse)
:sectionStyle render/section-style
:colorGrey4 render/color-gray-4
:logoFooter true}
(random-quote-context)))
(defn- render-message-body
[message-template message-context images]
(vec (cons {:type "text/html; charset=utf-8" :content (stencil/render-file message-template message-context)}
(map write-image-content images))))
(defn- render-message-body [message-template message-context timezone results]
(let [rendered-cards (binding [render/*include-title* true]
;; doall to ensure we haven't exited the binding before the valures are created
(doall (map #(render/render-pulse-section timezone %) results)))
message-body (assoc message-context :pulse (html (vec (cons :div (map :content rendered-cards)))))
attachments (apply merge (map :attachments rendered-cards))]
(vec (cons {:type "text/html; charset=utf-8" :content (stencil/render-file message-template message-body)}
(map make-message-attachment attachments)))))
(defn render-pulse-email
"Take a pulse object and list of results, returns an array of attachment objects for an email"
[timezone pulse results]
(let [images (atom {})
body (binding [render/*include-title* true
render/*render-img-fn* (partial render-image images)]
(vec (cons :div (for [result results]
(render/render-pulse-section timezone result)))))]
(render-message-body "metabase/email/pulse" (pulse-context body pulse) (seq @images))))
(render-message-body "metabase/email/pulse" (pulse-context pulse) timezone results))
(defn pulse->alert-condition-kwd
"Given an `ALERT` return a keyword representing what kind of goal needs to be met."
......@@ -267,15 +245,10 @@
(defn render-alert-email
"Take a pulse object and list of results, returns an array of attachment objects for an email"
[timezone {:keys [alert_first_only] :as alert} results goal-value]
(let [images (atom {})
body (binding [render/*include-title* true
render/*render-img-fn* (partial render-image images)]
(html (vec (cons :div (for [result results]
(render/render-pulse-section timezone result))))))
message-ctx (default-alert-context alert (alert-results-condition-text goal-value))]
(let [message-ctx (default-alert-context alert (alert-results-condition-text goal-value))]
(render-message-body "metabase/email/alert"
(assoc message-ctx :pulse body :firstRunOnly? alert_first_only)
(seq @images))))
(assoc message-ctx :firstRunOnly? alert_first_only)
timezone results)))
(def ^:private alert-condition-text
{:meets "when this question meets its goal"
......
......@@ -5,18 +5,21 @@
[format :as f]]
[clojure
[pprint :refer [cl-format]]
[string :as s]]
[string :as str]]
[clojure.java.io :as io]
[clojure.tools.logging :as log]
[hiccup.core :refer [h html]]
[metabase.util :as u]
[metabase.util.urls :as urls])
[metabase.util.urls :as urls]
[puppetlabs.i18n.core :refer [tru trs]]
[schema.core :as s])
(:import cz.vutbr.web.css.MediaSpec
[java.awt BasicStroke Color Dimension RenderingHints]
java.awt.image.BufferedImage
[java.io ByteArrayInputStream ByteArrayOutputStream]
java.net.URL
java.nio.charset.StandardCharsets
java.util.Date
[java.util Arrays Date]
javax.imageio.ImageIO
org.apache.commons.io.IOUtils
[org.fit.cssbox.css CSSNorm DOMAnalyzer DOMAnalyzer$Origin]
......@@ -75,6 +78,10 @@
:padding-right :1em
:padding-top :8px}))
(def ^:private RenderedPulseCard
"Schema used for functions that operate on pulse card contents and their attachments"
{:attachments (s/maybe {s/Str URL})
:content [s/Any]})
;;; # ------------------------------------------------------------ HELPER FNS ------------------------------------------------------------
......@@ -83,9 +90,9 @@
(style {:font-weight 400, :color \"white\"}) -> \"font-weight: 400; color: white;\""
[& style-maps]
(s/join " " (for [[k v] (into {} style-maps)
:let [v (if (keyword? v) (name v) v)]]
(str (name k) ": " v ";"))))
(str/join " " (for [[k v] (into {} style-maps)
:let [v (if (keyword? v) (name v) v)]]
(str (name k) ": " v ";"))))
(defn- datetime-field?
......@@ -229,12 +236,13 @@
(.createLayout content-canvas window-size)
(ImageIO/write (.getImage content-canvas) "png" os)))
(defn- render-html-to-png
^bytes [html-body width]
(s/defn ^:private render-html-to-png :- bytes
[{:keys [content]} :- RenderedPulseCard
width]
(let [html (html [:html [:body {:style (style {:margin 0
:padding 0
:background-color :white})}
html-body]])
content]])
os (ByteArrayOutputStream.)]
(render-to-png html os width)
(.toByteArray os)))
......@@ -288,7 +296,7 @@
;; If this column is remapped from another, it's already
;; in the output and should be skipped
:when (not (:remapped_from maybe-remapped-col))]
(s/upper-case (name (or (:display_name col) (:name col)))))
(str/upper-case (name (or (:display_name col) (:name col)))))
:bar-width (when include-bar? 99)})
(defn- query-results->row-seq
......@@ -336,23 +344,26 @@
" of " [:strong {:style (style {:color color-gray-3})} (format-number col-count)]
" columns."])]))
(defn- render:table
(s/defn ^:private render:table :- RenderedPulseCard
[timezone card {:keys [cols rows] :as data}]
[: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 [: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- render:bar
(s/defn ^:private render:bar :- RenderedPulseCard
[timezone card {:keys [cols rows] :as data}]
(let [max-value (apply max (map second rows))]
[:div
(render-table (prep-for-html-rendering timezone cols rows second max-value 2))
(render-truncation-warning 2 (count cols) rows-limit (count rows))]))
{:attachments nil
:content [:div
(render-table (prep-for-html-rendering timezone cols rows second max-value 2))
(render-truncation-warning 2 (count cols) rows-limit (count rows))]}))
(defn- render:scalar
(s/defn ^:private render:scalar :- RenderedPulseCard
[timezone card {:keys [cols rows]}]
[:div {:style (style scalar-style)}
(h (format-cell timezone (ffirst rows) (first cols)))])
{:attachments nil
:content [:div {:style (style scalar-style)}
(h (format-cell timezone (ffirst rows) (first cols)))]})
(defn- render-sparkline-to-png
"Takes two arrays of numbers between 0 and 1 and plots them as a sparkline"
......@@ -380,10 +391,54 @@
(* 2 sparkline-dot-radius)
(* 2 sparkline-dot-radius)))
(when-not (ImageIO/write image "png" os) ; returns `true` if successful -- see JavaDoc
(throw (Exception. "No approprate image writer found!")))
(let [^String msg (tru "No approprate image writer found!")]
(throw (Exception. msg))))
(.toByteArray os)))
(defn- render:sparkline
(defn- hash-bytes
"Generate a hash to be used in a Content-ID"
[^bytes img-bytes]
(Math/abs ^Integer (Arrays/hashCode img-bytes)))
(defn- hash-image-url
"Generate a hash to be used in a Content-ID"
[^java.net.URL url]
(-> url io/input-stream IOUtils/toByteArray hash-bytes))
(defn- content-id-reference [content-id]
(str "cid:" content-id))
(defn- mb-hash-str [image-hash]
(str image-hash "@metabase"))
(defn- resource-path->content-id-pair [path]
(let [external-link-url (io/resource path)]
[(mb-hash-str (hash-image-url external-link-url))
external-link-url]))
(defn- write-byte-array-to-temp-file
[^bytes img-bytes]
(let [f (doto (java.io.File/createTempFile "metabase_pulse_image_" ".png")
.deleteOnExit)]
(with-open [fos (java.io.FileOutputStream. f)]
(.write fos img-bytes))
f))
(defn- byte-array->content-id-pair
[^bytes img-bytes]
(let [f (write-byte-array-to-temp-file img-bytes)]
[(mb-hash-str (hash-bytes img-bytes))
(io/as-url f)]))
(def ^:private external-link-image
(delay
(resource-path->content-id-pair "frontend_client/app/assets/img/external_link.png")))
(def ^:private no-results-image
(delay
(resource-path->content-id-pair "frontend_client/app/assets/img/pulse_no_results@2x.png")))
(s/defn ^:private render:sparkline :- RenderedPulseCard
[timezone card {:keys [rows cols]}]
(let [ft-row (if (datetime-field? (first cols))
#(.getTime ^Date (u/->Timestamp %))
......@@ -406,42 +461,47 @@
ys' (map #(/ (double (- % ymin)) yrange) ys) ; cast to double to avoid "Non-terminating decimal expansion" errors
rows' (reverse (take-last 2 rows))
values (map (comp format-number second) rows')
labels (format-timestamp-pair timezone (map first rows') (first cols))]
[:div
[:img {:style (style {:display :block
:width :100%})
:src (*render-img-fn* (render-sparkline-to-png xs' ys' 524 130))}]
[:table
[:tr
[:td {:style (style {:color color-brand
:font-size :24px
:font-weight 700
:padding-right :16px})}
(first values)]
[:td {:style (style {:color color-gray-3
:font-size :24px
:font-weight 700})}
(second values)]]
[:tr
[:td {:style (style {:color color-brand
:font-size :16px
:font-weight 700
:padding-right :16px})}
(first labels)]
[:td {:style (style {:color color-gray-3
:font-size :16px})}
(second labels)]]]]))
(defn- render-image-with-filename [^String filename]
(*render-img-fn* (IOUtils/toByteArray (io/input-stream (io/resource filename)))))
(defn- render:empty [_ _]
[:div {:style (style {:text-align :center})}
[:img {:style (style {:width :104px})
:src (render-image-with-filename "frontend_client/app/assets/img/pulse_no_results@2x.png")}]
[:div {:style (style {:margin-top :8px
:color color-gray-4})}
"No results"]])
labels (format-timestamp-pair timezone (map first rows') (first cols))
[content-id png-url] (byte-array->content-id-pair (render-sparkline-to-png xs' ys' 524 130))]
{:attachments (when (and content-id png-url)
{content-id png-url})
:content [:div
[:img {:style (style {:display :block
:width :100%})
:src (content-id-reference content-id)}]
[:table
[:tr
[:td {:style (style {:color color-brand
:font-size :24px
:font-weight 700
:padding-right :16px})}
(first values)]
[:td {:style (style {:color color-gray-3
:font-size :24px
:font-weight 700})}
(second values)]]
[:tr
[:td {:style (style {:color color-brand
:font-size :16px
:font-weight 700
:padding-right :16px})}
(first labels)]
[:td {:style (style {:color color-gray-3
:font-size :16px})}
(second labels)]]]]}))
(s/defn ^:private render:empty :- RenderedPulseCard
[_ _]
(let [[content-id no-results-url] @no-results-image]
{:attachments (when (and content-id no-results-url)
{content-id no-results-url})
:content [:div {:style (style {:text-align :center})}
[:img {:style (style {:width :104px})
:src (content-id-reference content-id)}]
[:div {:style (style {:margin-top :8px
:color color-gray-4})}
"No results"]]}))
(defn detect-pulse-card-type
"Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`."
......@@ -467,61 +527,86 @@
(number-field? col-2)) :bar
:else :table)))
(defn render-pulse-card
"Render a single CARD for a `Pulse` to Hiccup HTML. RESULT is the QP results."
(s/defn ^:private make-title-if-needed :- (s/maybe RenderedPulseCard)
[card]
(when *include-title*
(let [[content-id link-url] (when *include-buttons*
@external-link-image)]
{:attachments (when (and content-id link-url)
{content-id link-url})
:content [:table {:style (style {:margin-bottom :8px
:width :100%})}
[:tbody
[:tr
[:td [:span {:style header-style}
(-> card :name h)]]
[:td {:style (style {:text-align :right})}
(when *include-buttons*
[:img {:style (style {:width :16px})
:width 16
:src (content-id-reference content-id)}])]]]]})))
(s/defn ^:private render-pulse-card-body :- RenderedPulseCard
[timezone card {:keys [data error]}]
[:a {:href (card-href card)
:target "_blank"
:style (style section-style
{:margin :16px
:margin-bottom :16px
:display :block
:text-decoration :none})}
(when *include-title*
[:table {:style (style {:margin-bottom :8px
:width :100%})}
[:tbody
[:tr
[:td [:span {:style header-style}
(-> card :name h)]]
[:td {:style (style {:text-align :right})}
(when *include-buttons*
[:img {:style (style {:width :16px})
:width 16
:src (render-image-with-filename "frontend_client/app/assets/img/external_link.png")}])]]]])
(try
(when error
(throw (Exception. (str "Card has errors: " error))))
(case (detect-pulse-card-type card data)
:empty (render:empty card data)
:scalar (render:scalar timezone card data)
:sparkline (render:sparkline timezone card data)
:bar (render:bar timezone card data)
:table (render:table timezone card data)
[:div {:style (style font-style
{:color "#F9D45C"
:font-weight 700})}
"We were unable to display this card." [:br] "Please view this card in Metabase."])
(catch Throwable e
(log/warn "Pulse card render error:" e)
[:div {:style (style font-style
{:color "#EF8C8C"
:font-weight 700
:padding :16px})}
"An error occurred while displaying this card."]))])
(defn render-pulse-section
(try
(when error
(let [^String msg (tru "Card has errors: {0}" error)]
(throw (Exception. msg))))
(case (detect-pulse-card-type card data)
:empty (render:empty card data)
:scalar (render:scalar timezone card data)
:sparkline (render:sparkline timezone card data)
:bar (render:bar timezone card data)
:table (render:table timezone card data)
{:attachments nil
:content [:div {:style (style font-style
{:color "#F9D45C"
:font-weight 700})}
"We were unable to display this card." [:br] "Please view this card in Metabase."]})
(catch Throwable e
(log/error e (trs "Pulse card render error"))
{:attachments nil
:content [:div {:style (style font-style
{:color "#EF8C8C"
:font-weight 700
:padding :16px})}
"An error occurred while displaying this card."]})))
(s/defn render-pulse-card :- RenderedPulseCard
"Render a single CARD for a `Pulse` to Hiccup HTML. RESULT is the QP results."
[timezone card results]
(let [{title :content title-attachments :attachments} (make-title-if-needed card)
{pulse-body :content body-attachments :attachments} (render-pulse-card-body timezone card results)]
{:attachments (merge title-attachments body-attachments)
:content [:a {:href (card-href card)
:target "_blank"
:style (style section-style
{:margin :16px
:margin-bottom :16px
:display :block
:text-decoration :none})}
title
pulse-body]}))
(defn render-pulse-card-for-display
"Same as `render-pulse-card` but isn't intended for an email, rather for previewing so there is no need for
attachments"
[timezone card results]
(:content (render-pulse-card timezone card results)))
(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]}]
[: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)"})}
(binding [*include-title* true]
(render-pulse-card timezone card result))])
(let [{:keys [attachments content]} (binding [*include-title* true]
(render-pulse-card 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]}))
(defn render-pulse-card-to-png
"Render a PULSE-CARD as a PNG. DATA is the `:data` from a QP result (I think...)"
......
(ns metabase.util.export
(:require [cheshire.core :as json]
[clojure.data.csv :as csv]
[dk.ative.docjure.spreadsheet :as spreadsheet])
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]
org.apache.poi.ss.usermodel.Cell))
;; add a generic implementation for the method that writes values to XLSX cells that just piggybacks off the
;; implementations we've already defined for encoding things as JSON. These implementations live in
;; `metabase.middleware`.
(defmethod spreadsheet/set-cell! Object [^Cell cell, value]
(when (= (.getCellType cell) Cell/CELL_TYPE_FORMULA)
(.setCellType cell Cell/CELL_TYPE_STRING))
;; stick the object in a JSON map and encode it, which will force conversion to a string. Then unparse that JSON and
;; use the resulting value as the cell's new String value. There might be some more efficient way of doing this but
;; I'm not sure what it is.
(.setCellValue cell (str (-> (json/generate-string {:v value})
(json/parse-string keyword)
:v))))
(defn- export-to-xlsx [columns rows]
(let [wb (spreadsheet/create-workbook "Query result" (cons (mapv name columns) rows))
;; note: byte array streams don't need to be closed
out (ByteArrayOutputStream.)]
(spreadsheet/save-workbook! out wb)
(ByteArrayInputStream. (.toByteArray out))))
(defn- export-to-csv [columns rows]
(with-out-str
;; turn keywords into strings, otherwise we get colons in our output
(csv/write-csv *out* (into [(mapv name columns)] rows))))
(defn- export-to-json [columns rows]
(for [row rows]
(zipmap columns row)))
(def export-formats
"Map of export types to their relevant metadata"
{"csv" {:export-fn export-to-csv
:content-type "text/csv"
:ext "csv"
:context :csv-download},
"xlsx" {:export-fn export-to-xlsx
:content-type "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:ext "xlsx"
:context :xlsx-download},
"json" {:export-fn export-to-json
:content-type "applicaton/json"
:ext "json"
:context :json-download}})
......@@ -553,7 +553,8 @@
(with-alert-setup
[(recipient-emails ((user->client :rasta) :get 200 (format "alert/question/%d" card-id)))
(do
((user->client :rasta) :put 204 (format "alert/%d/unsubscribe" pulse-id))
(et/with-expected-messages 1
((user->client :rasta) :put 204 (format "alert/%d/unsubscribe" pulse-id)))
(recipient-emails ((user->client :crowberto) :get 200 (format "alert/question/%d" card-id))))
(et/regex-email-bodies #"https://metabase.com/testmb"
#"Foo")]))
......@@ -683,10 +684,11 @@
[1 nil 0
(rasta-deleted-email {})]
(with-alert-setup
[(count ((user->client :rasta) :get 200 (format "alert/question/%d" card-id)))
((user->client :crowberto) :delete 204 (format "alert/%d" pulse-id))
(count ((user->client :rasta) :get 200 (format "alert/question/%d" card-id)))
(et/regex-email-bodies #"Crowberto Corv deleted an alert")]))
[(count ((user->client :rasta) :get 200 (format "alert/question/%d" card-id)))
(et/with-expected-messages 1
((user->client :crowberto) :delete 204 (format "alert/%d" pulse-id)))
(count ((user->client :rasta) :get 200 (format "alert/question/%d" card-id)))
(et/regex-email-bodies #"Crowberto Corv deleted an alert")]))
;; A deleted alert should notify the creator and any recipients
(tt/expect-with-temp [Card [{card-id :id} (basic-alert-query)]
......
......@@ -134,33 +134,38 @@
{:bar-width nil, :row ["3" "34.05" "Aug 1, 2014" "The Gorbals"]}]
(rest (prep-for-html-rendering pacific-tz test-columns-with-date-special-type test-data nil nil (count test-columns))))
(defn- render-scalar-value [results]
(-> (render:scalar pacific-tz nil results)
:content
last))
(expect
"10"
(last (render:scalar pacific-tz nil {:cols [{:name "ID",
:display_name "ID",
:base_type :type/BigInteger
:special_type nil}]
:rows [[10]]})))
(render-scalar-value {:cols [{:name "ID",
:display_name "ID",
:base_type :type/BigInteger
:special_type nil}]
:rows [[10]]}))
(expect
"10.12"
(last (render:scalar pacific-tz nil {:cols [{:name "floatnum",
:display_name "FLOATNUM",
:base_type :type/Float
:special_type nil}]
:rows [[10.12345]]})))
(render-scalar-value {:cols [{:name "floatnum",
:display_name "FLOATNUM",
:base_type :type/Float
:special_type nil}]
:rows [[10.12345]]}))
(expect
"foo"
(last (render:scalar pacific-tz nil {:cols [{:name "stringvalue",
:display_name "STRINGVALUE",
:base_type :type/Text
:special_type nil}]
:rows [["foo"]]})))
(render-scalar-value {:cols [{:name "stringvalue",
:display_name "STRINGVALUE",
:base_type :type/Text
:special_type nil}]
:rows [["foo"]]}))
(expect
"Apr 1, 2014"
(last (render:scalar pacific-tz nil {:cols [{:name "date",
:display_name "DATE",
:base_type :type/DateTime
:special_type nil}]
:rows [["2014-04-01T08:30:00.0000"]]})))
(render-scalar-value {:cols [{:name "date",
:display_name "DATE",
:base_type :type/DateTime
:special_type nil}]
:rows [["2014-04-01T08:30:00.0000"]]}))
......@@ -65,7 +65,7 @@
{:type :inline,
:content-id true,
:content-type "image/png",
:content java.io.File})
:content java.net.URL})
(defn- rasta-pulse-email [& [email]]
(et/email-to :rasta (merge {:subject "Pulse: Pulse Name",
......@@ -366,7 +366,7 @@
(defn- attachment? [{message-type :type content-type :content-type content :content}]
(and (= :inline message-type)
(= "image/png" content-type)
(instance? java.io.File content)))
(instance? java.net.URL content)))
;; Test with a slack channel and an email
(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment