Skip to content
Snippets Groups Projects
Commit 9bb1ebab authored by Cam Saül's avatar Cam Saül
Browse files

Slack & Pulses code cleanup :flushed:

parent 6ab91878
No related branches found
No related tags found
No related merge requests found
......@@ -10,11 +10,11 @@
[metabase.integrations.slack :as slack]
(metabase.models [card :refer [Card]]
[database :refer [Database]]
[pulse :refer [Pulse] :as pulse]
[pulse :refer [Pulse retrieve-pulse] :as pulse]
[pulse-channel :refer [channel-types]])
[metabase.pulse :as p]
[metabase.task.send-pulses :refer [send-pulse]]
[metabase.models.pulse :refer [retrieve-pulse]]))
[metabase.task.send-pulses :refer [send-pulse!]]
[metabase.util :as u]))
(defendpoint GET "/"
......@@ -59,10 +59,9 @@
(defendpoint DELETE "/:id"
"Delete a `Pulse`."
[id]
(let [pulse (db/sel :one Pulse :id id)
result (db/cascade-delete Pulse :id id)]
(events/publish-event :pulse-delete (assoc pulse :actor_id *current-user-id*))
result))
(let-404 [pulse (Pulse id)]
(u/prog1 (db/cascade-delete Pulse :id id)
(events/publish-event :pulse-delete (assoc pulse :actor_id *current-user-id*)))))
(defendpoint GET "/form_input"
......@@ -75,8 +74,10 @@
;; no Slack integration, so we are g2g
chan-types
;; if we have Slack enabled build a dynamic list of channels/users
(let [slack-channels (mapv (fn [ch] (str "#" (get ch "name"))) (get (slack/channels-list) "channels"))
slack-users (mapv (fn [u] (str "@" (get u "name"))) (get (slack/users-list) "members"))]
(let [slack-channels (for [channel (slack/channels-list)]
(str \# (:name channel)))
slack-users (for [user (slack/users-list)]
(str \@ (:name user)))]
(assoc-in chan-types [:slack :fields 0 :options] (concat slack-channels slack-users))))}))
......@@ -86,7 +87,9 @@
(let [card (Card id)]
(read-check Database (:database (:dataset_query card)))
(let [data (:data (driver/dataset-query (:dataset_query card) {:executed_by *current-user-id*}))]
{:status 200, :body (html [:html [:body {:style "margin: 0;"} (p/render-pulse-card card data p/render-img-data-uri :include-title :include-buttons)]])})))
{:status 200, :body (html [:html [:body {:style "margin: 0;"} (binding [p/*include-title* true
p/*include-buttons* true]
(p/render-pulse-card card data))]])})))
(defendpoint GET "/preview_card_info/:id"
"Get JSON object containing HTML rendering of a `Card` with ID and other information."
......@@ -96,7 +99,8 @@
(let [result (driver/dataset-query (:dataset_query card) {:executed_by *current-user-id*})
data (:data result)
card-type (p/detect-pulse-card-type card data)
card-html (html (p/render-pulse-card card data p/render-img-data-uri :include-title (not :include-buttons)))]
card-html (html (binding [p/*include-title* true]
(p/render-pulse-card card data)))]
{:id id
:pulse_card_type card-type
:pulse_card_html card-html
......@@ -108,7 +112,8 @@
(let [card (Card id)]
(read-check Database (:database (:dataset_query card)))
(let [data (:data (driver/dataset-query (:dataset_query card) {:executed_by *current-user-id*}))
ba (p/render-pulse-card-to-png card data true)]
ba (binding [p/*include-title* true]
(p/render-pulse-card-to-png card data))]
{:status 200, :headers {"Content-Type" "image/png"}, :body (new java.io.ByteArrayInputStream ba) })))
(defendpoint POST "/test"
......@@ -117,7 +122,7 @@
{name [Required NonEmptyString]
cards [Required ArrayOfMaps]
channels [Required ArrayOfMaps]}
(send-pulse body)
(send-pulse! body)
{:ok true})
(define-routes)
(ns metabase.api.slack
"/api/slack endpoints"
(:require [clojure.tools.logging :as log]
[clojure.set :as set]
[cheshire.core :as cheshire]
[compojure.core :refer [GET PUT DELETE POST]]
(:require [clojure.set :as set]
[clojure.tools.logging :as log]
[compojure.core :refer [PUT]]
[metabase.api.common :refer :all]
[metabase.config :as config]
[metabase.integrations [slack :refer [slack-api-get]]]
[metabase.models.setting :as setting]))
(defn- humanize-error-messages
"Convert raw error message responses from Slack into our normal api error response structure."
[response body]
(case (get body "error")
"invalid_auth" {:errors {:slack-token "Invalid token"}}
{:message "Sorry, something went wrong. Please try again."}))
[metabase.integrations.slack :as slack]))
(defendpoint PUT "/settings"
"Update multiple `Settings` values. You must be a superuser to do this."
[:as {settings :body}]
{settings [Required Dict]}
"Update the `slack-token`. You must be a superuser to do this."
[:as {{slack-token :slack-token} :body}]
{slack-token [Required NonEmptyString]}
(check-superuser)
(let [slack-token (:slack-token settings)
response (if-not config/is-test?
;; in normal conditions, validate connection
(slack-api-get slack-token "channels.list" {:exclude_archived 1})
;; for unit testing just respond with a success message
{:status 200 :body "{\"ok\":true}"})
body (if (= 200 (:status response)) (cheshire/parse-string (:body response)))]
(if (= true (get body "ok"))
;; test was good, save our settings
(setting/set :slack-token slack-token)
;; test failed, return response message
{:status 500
:body (humanize-error-messages response body)})))
(try
;; just check that channels.list doesn't throw an exception (that the connection works)
(when-not config/is-test?
(slack/GET :channels.list, :exclude_archived 1, :token slack-token))
{:ok true}
(catch clojure.lang.ExceptionInfo info
{:status 400, :body (ex-data info)})))
(define-routes)
......@@ -6,7 +6,7 @@
[stencil.loader :as loader]
[metabase.email :as email]
[metabase.models.setting :as setting]
[metabase.pulse :as p, :refer [render-pulse-section]]
[metabase.pulse :as p]
[metabase.util :as u]
(metabase.util [quotation :as quotation]
[urls :as url])))
......@@ -109,8 +109,10 @@
"Take a pulse object and list of results, returns an array of attachment objects for an email"
[pulse results]
(let [images (atom [])
body (apply vector :div (for [result results]
(render-pulse-section (partial render-image images) :include-buttons result)))
body (binding [p/*include-title* true
p/*render-img-fn* (partial render-image images)]
(vec (cons :div (for [result results]
(p/render-pulse-section result)))))
data-quote (quotation/random-quote)
message-body (stencil/render-file "metabase/email/pulse"
{:emailType "pulse"
......
(ns metabase.integrations.slack
(:require [cheshire.core :as cheshire]
(:require [clojure.tools.logging :as log]
[cheshire.core :as json]
[clj-http.client :as http]
[clojure.tools.logging :as log]
[metabase.models.setting :refer [defsetting]]))
[metabase.models.setting :refer [defsetting]]
[metabase.util :as u]))
;; Define a setting which captures our Slack api token
(defsetting slack-token "Slack API bearer token obtained from https://api.slack.com/web#authentication")
(def ^:private ^:const slack-api-baseurl "https://slack.com/api")
(def ^:private ^:const metabase-slack-files-channel "metabase_files")
(def ^:private ^:const ^String slack-api-base-url "https://slack.com/api")
(def ^:private ^:const ^String files-channel-name "metabase_files")
(defn slack-configured?
"Predicate function which returns `true` if the application has a valid integration with Slack, `false` otherwise."
"Is Slack integration configured?"
[]
(not (empty? (slack-token))))
(defn slack-api-get
"Generic function which calls a given method on the Slack api via HTTP GET."
([token method]
(slack-api-get token method {}))
([token method params]
{:pre [(string? method)
(map? params)]}
(when token
(try
(http/get (str slack-api-baseurl "/" method) {:query-params (merge params {:token token})
:conn-timeout 1000
:socket-timeout 1000})
(catch Throwable t
(log/warn "Error making Slack API call:" (.getMessage t)))))))
(defn slack-api-post
"Generic function which calls a given method on the Slack api via HTTP POST."
([token method]
(slack-api-get token method {}))
([token method params]
{:pre [(string? method)
(map? params)]}
(when token
(try
(http/post (str slack-api-baseurl "/" method) {:form-params (merge params {:token token})
:conn-timeout 1000
:socket-timeout 1000})
(catch Throwable t
(log/warn "Error making Slack API call:" (.getMessage t)))))))
(defn- ^:private handle-api-response
"Simple helper that checks that response is a HTTP 200 and deserializes the `:body` if so, otherwise logs an error"
[response]
(if (= 200 (:status response))
(cheshire/parse-string (:body response))
(log/warn "Error in Slack api response:" (with-out-str (clojure.pprint/pprint response)))))
(defn channels-create
(boolean (seq (slack-token))))
(defn- handle-response [{:keys [status body], :as response}]
(let [body (json/parse-string body keyword)]
(if (and (= 200 status) (:ok body))
body
(let [error (if (= (:error body) "invalid_auth")
{:errors {:slack-token "Invalid token"}}
{:message (str "Slack API error: " (:error body)), :response body})]
(log/warn (u/pprint-to-str 'red error))
(throw (ex-info (:message error) error))))))
(defn- do-slack-request [request-fn params-key endpoint & {:keys [token], :as params, :or {token (slack-token)}}]
(when token
(handle-response (request-fn (str slack-api-base-url "/" (name endpoint)) {params-key (assoc params :token token)
:conn-timeout 1000
:socket-timeout 1000}))))
(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} GET "Make a GET request to the Slack API." (partial do-slack-request http/get :query-params))
(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1} POST "Make a POST request to the Slack API." (partial do-slack-request http/post :form-params))
(def ^:private ^{:arglists '([channel-id & {:as args}])} create-channel!
"Calls Slack api `channels.create` for CHANNEL."
[channel]
{:pre [(string? channel)]}
(-> (slack-api-post (slack-token) "channels.create" {:name channel})
handle-api-response))
(partial POST :channels.create, :name))
(defn channels-archive
(def ^:private ^{:arglists '([channel-id & {:as args}])} archive-channel!
"Calls Slack api `channels.archive` for CHANNEL."
[channel]
{:pre [(string? channel)]}
(-> (slack-api-post (slack-token) "channels.archive" {:channel channel})
handle-api-response))
(partial POST :channels.archive, :channel))
(defn channels-list
(def ^{:arglists '([& {:as args}])} channels-list
"Calls Slack api `channels.list` function and returns the list of available channels."
[]
(-> (slack-api-get (slack-token) "channels.list" {:exclude_archived 1})
handle-api-response))
(comp :channels (partial GET :channels.list, :exclude_archived 1)))
(defn users-list
(def ^{:arglists '([& {:as args}])} users-list
"Calls Slack api `users.list` function and returns the list of available users."
[]
(-> (slack-api-get (slack-token) "users.list")
handle-api-response))
(comp :members (partial GET :users.list)))
(defn- create-files-channel
(defn- create-files-channel!
"Convenience function for creating our Metabase files channel to store file uploads."
[]
(when-let [response (channels-create metabase-slack-files-channel)]
(if-let [files-channel (clojure.walk/keywordize-keys (get response "channel"))]
(do
;; right after creating our files channel, archive it. this is because we don't need users to see it.
(channels-archive (:id files-channel))
;; then return the info about the files channel we created as our response
files-channel)
(log/error "Error creating Slack channel for Metabase file uploads:" (with-out-str (clojure.pprint/pprint response))))))
(defn get-or-create-files-channel
(when-let [{files-channel :channel, :as response} (create-channel! files-channel-name)]
(when-not files-channel
(log/error (u/pprint-to-str 'red response))
(throw (ex-info "Error creating Slack channel for Metabase file uploads" response)))
;; Right after creating our files channel, archive it. This is because we don't need users to see it.
(u/prog1 files-channel
(archive-channel! (:id <>)))))
(defn- files-channel
"Return the `metabase_files` channel (as a map) if it exists."
[]
(some (fn [channel] (when (= (:name channel) files-channel-name)
channel))
(channels-list :exclude_archived 0)))
(defn get-or-create-files-channel!
"Calls Slack api `channels.info` and `channels.create` function as needed to ensure that a #metabase_files channel exists."
[]
(if-let [files-channel (->> (get (handle-api-response (slack-api-get (slack-token) "channels.list" {:exclude_archived 0})) "channels")
(map clojure.walk/keywordize-keys)
(filter #(= metabase-slack-files-channel (:name %)))
first)]
files-channel
(create-files-channel)))
(defn files-upload
(or (files-channel)
(create-files-channel!)))
(defn upload-file!
"Calls Slack api `files.upload` function and returns the body of the uploaded file."
[file filename channels]
{:pre [(string? filename)
(string? channels)]}
(let [response (http/post (str slack-api-baseurl "/files.upload") {:multipart [["token" (slack-token)]
["file" file]
["filename" filename]
["channels" channels]]
:as :json})]
[file filename channel-ids-str]
{:pre [file (instance? (Class/forName "[B") file) (not (zero? (count file))) (string? filename) (seq filename) (string? channel-ids-str) (seq channel-ids-str) (seq (slack-token))]}
(let [response (http/post (str slack-api-base-url "/files.upload") {:multipart [{:name "token", :content (slack-token)}
{:name "file", :content file}
{:name "filename", :content filename}
{:name "channels", :content channel-ids-str}]
:as :json})]
(if (= 200 (:status response))
(get-in (:body response) [:file :url_private])
(log/warn "Error uploading file to Slack:" (with-out-str (clojure.pprint/pprint response))))))
(defn chat-post-message
"Calls Slack api `chat.postMessage` function and posts a message to a given channel."
([channel text]
(chat-post-message channel text []))
([channel text attachments]
{:pre [(string? channel)
(string? text)]}
;; TODO: it would be nice to have an emoji or icon image to use here
(-> (slack-api-post (slack-token) "chat.postMessage" {:channel channel
:username "MetaBot"
:icon_url "http://static.metabase.com/metabot_slack_avatar_whitebg.png"
:text text
:attachments attachments})
(handle-api-response))))
(u/prog1 (get-in (:body response) [:file :url_private])
(log/debug "Uploaded image" <>))
(log/warn "Error uploading file to Slack:" (u/pprint-to-str response)))))
(defn post-chat-message!
"Calls Slack api `chat.postMessage` function and posts a message to a given channel.
ATTACHMENTS should be serialized JSON."
[channel-id text & [attachments]]
{:pre [(string? channel-id) (string? text)]}
;; TODO: it would be nice to have an emoji or icon image to use here
(POST :chat.postMessage
:channel channel-id
:username "MetaBot"
:icon_url "http://static.metabase.com/metabot_slack_avatar_whitebg.png"
:text text
:attachments (when (seq attachments)
(json/generate-string attachments))))
......@@ -155,20 +155,20 @@
`PulseCards`, `PulseChannels`, and `PulseChannelRecipients`.
Returns the newly created `Pulse` or throws an Exception."
[pulse-name creator-id cards channels]
[pulse-name creator-id card-ids channels]
{:pre [(string? pulse-name)
(integer? creator-id)
(sequential? cards)
(> (count cards) 0)
(every? integer? cards)
(sequential? card-ids)
(> (count card-ids) 0)
(every? integer? card-ids)
(coll? channels)
(every? map? channels)]}
(kdb/transaction
(let [{:keys [id] :as pulse} (db/ins Pulse
:creator_id creator-id
:name pulse-name)]
;; add cards to the Pulse
(update-pulse-cards pulse cards)
;; add card-ids to the Pulse
(update-pulse-cards pulse card-ids)
;; add channels to the Pulse
(update-pulse-channels pulse channels)
;; return the full Pulse (and record our create event)
......
......@@ -24,7 +24,7 @@
;; NOTE: hiccup does not escape content by default so be sure to use "h" to escape any user-controlled content :-/
;;; ## CONFIG
;;; # ------------------------------------------------------------ STYLES ------------------------------------------------------------
(def ^:private ^:const card-width 400)
(def ^:private ^:const rows-limit 10)
......@@ -70,16 +70,8 @@
:padding-right :1em
:padding-top :8px}))
#_(def ^:private ^:const button-style
(merge font-style {:display :inline-block
:box-sizing :border-box
:padding :8px
:color color-brand
:border (str "1px solid " color-brand)
:border-radius :4px
:text-decoration :none}))
;;; ## HELPER FNS
;;; # ------------------------------------------------------------ HELPER FNS ------------------------------------------------------------
(defn- style
"Compile one or more CSS style maps into a string.
......@@ -101,7 +93,8 @@
(or (contains? #{:IntegerField :DecimalField :FloatField :BigIntegerField} (:base_type field))
(contains? #{:number} (:special_type field))))
;;; ## FORMATTING
;;; # ------------------------------------------------------------ FORMATTING ------------------------------------------------------------
(defn- format-number
[n]
......@@ -160,7 +153,25 @@
(and (number? value) (not (datetime-field? col))) (format-number value)
:else (str value)))
;;; ## RENDERING
(defn- render-img-data-uri
"Takes a PNG byte array and returns a Base64 encoded URI"
[img-bytes]
(str "data:image/png;base64," (String. (Base64Coder/encode img-bytes))))
;;; # ------------------------------------------------------------ RENDERING ------------------------------------------------------------
(def ^:dynamic *include-buttons*
"Should the rendered pulse include buttons? (default: `false`)"
false)
(def ^:dynamic *include-title*
"Should the rendered pulse include a title? (default: `false`)"
false)
(def ^:dynamic *render-img-fn*
"The function that should be used for rendering image bytes. Defaults to `render-img-data-uri`."
render-img-data-uri)
(defn- card-href
[card]
......@@ -170,7 +181,7 @@
(defn- render-to-png
[^String html, ^ByteArrayOutputStream os, width]
(let [is (ByteArrayInputStream. (.getBytes html StandardCharsets/UTF_8))
doc-source (StreamDocumentSource. is nil "text/html")
doc-source (StreamDocumentSource. is nil "text/html; charset=utf-8")
parser (DefaultDOMSource. doc-source)
doc (.parse parser)
window-size (Dimension. width 1)
......@@ -180,8 +191,8 @@
da (doto (DOMAnalyzer. doc (.getURL doc-source))
(.setMediaSpec media)
.attributesToStyles
(.addStyleSheet nil (CSSNorm/stdStyleSheet) DOMAnalyzer$Origin/AGENT)
(.addStyleSheet nil (CSSNorm/userStyleSheet) DOMAnalyzer$Origin/AGENT)
(.addStyleSheet nil (CSSNorm/stdStyleSheet) DOMAnalyzer$Origin/AGENT)
(.addStyleSheet nil (CSSNorm/userStyleSheet) DOMAnalyzer$Origin/AGENT)
(.addStyleSheet nil (CSSNorm/formsStyleSheet) DOMAnalyzer$Origin/AGENT)
.getStyleSheets)
content-canvas (doto (BrowserCanvas. (.getRoot da) da (.getURL doc-source))
......@@ -204,22 +215,8 @@
(render-to-png html os width)
(.toByteArray os)))
(defn render-img-data-uri
"Takes a PNG byte array and returns a Base64 encoded URI"
[img-bytes]
(str "data:image/png;base64," (String. (Base64Coder/encode img-bytes))))
;; This isn't being used, not sure what the point of it was. Commented out until mystery is solved.
#_(defn render-button
[text href icon render-img]
[:a {:style button-style :href href}
[:span (h text)]
(if icon [:img {:style (style {:margin-left :4px, :width :16px})
:width 16
:src (-> (str "frontend_client/app/img/" icon "@2x.png") io/resource io/input-stream IOUtils/toByteArray render-img)}])])
(defn- render-table
[card rows cols render-img include-buttons col-indexes bar-column]
[card rows cols col-indexes bar-column]
(let [max-value (if bar-column (apply max (map bar-column rows)))]
[:table {:style (style {:padding-bottom :8px, :border-bottom (str "4px solid " color-grey-1)})}
[:thead
......@@ -246,7 +243,7 @@
rows)]]))
(defn- render-truncation-warning
[card {:keys [cols rows] :as data} render-img include-buttons rows-limit cols-limit]
[card {:keys [cols rows] :as data} rows-limit cols-limit]
(if (or (> (count rows) rows-limit)
(> (count cols) cols-limit))
[:div {:style (style {:padding-top :16px})}
......@@ -265,26 +262,26 @@
" of " [:strong {:style (style {:color color-grey-3})} (format-number (count cols))]
" columns."])]))
(defn- render-card-table
[card {:keys [cols rows] :as data} render-img include-buttons]
(defn- render:table
[card {:keys [cols rows] :as data}]
(let [truncated-rows (take rows-limit rows)
truncated-cols (take cols-limit cols)
col-indexes (map-indexed (fn [i _] i) truncated-cols)]
[:div
(render-table card truncated-rows truncated-cols render-img include-buttons col-indexes nil)
(render-truncation-warning card data render-img include-buttons rows-limit cols-limit)]))
(render-table card truncated-rows truncated-cols col-indexes nil)
(render-truncation-warning card data rows-limit cols-limit)]))
(defn- render-card-bar
[card {:keys [cols rows] :as data} render-img include-buttons]
(defn- render:bar
[card {:keys [cols rows] :as data}]
(let [truncated-rows (take rows-limit rows)]
[:div
(render-table card truncated-rows cols render-img include-buttons [0 1] second)
(render-truncation-warning card data render-img include-buttons rows-limit 2)]))
(render-table card truncated-rows cols [0 1] second)
(render-truncation-warning card data rows-limit 2)]))
(defn- render-card-scalar
[card {:keys [cols rows] :as data} render-img include-buttons]
(defn- render:scalar
[card {:keys [cols rows] :as data}]
[:div {:style (style scalar-style)}
(-> rows first first (format-cell (first cols)) h)])
(-> rows first first (format-cell (first cols)) h)])
(defn- render-sparkline-to-png
"Takes two arrays of numbers between 0 and 1 and plots them as a sparkline"
......@@ -314,8 +311,8 @@
(ImageIO/write image "png" os)
(.toByteArray os)))
(defn- render-card-sparkline
[card {:keys [rows cols] :as data} render-img include-buttons]
(defn- render:sparkline
[card {:keys [rows cols] :as data}]
(let [xs (for [row rows
:let [x (first row)]]
(if (instance? Date x)
......@@ -328,7 +325,7 @@
ys (map second rows)
ymin (apply min ys)
ymax (apply max ys)
yrange (max 1 (- ymax ymin)) ; `(max 1 ...)` so we don't divide by zero
yrange (max 1 (- ymax ymin)) ; `(max 1 ...)` so we don't divide by zero
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')
......@@ -336,7 +333,7 @@
[:div
[:img {:style (style {:display :block
:width :100%})
:src (render-img (render-sparkline-to-png xs' ys' 524 130))}]
:src (*render-img-fn* (render-sparkline-to-png xs' ys' 524 130))}]
[:table
[:tr
[:td {:style (style {:color color-brand
......@@ -358,16 +355,20 @@
:font-size :16px})}
(second labels)]]]]))
(defn- render-card-empty
[card {:keys [rows cols] :as data} render-img include-buttons]
(defn- render-image-with-filename [^String filename]
(*render-img-fn* (IOUtils/toByteArray (io/input-stream (io/resource filename)))))
(defn- render:empty
[card {:keys [rows cols] :as data}]
[:div {:style (style {:text-align :center})}
[:img {:style (style {:width :104px})
:src (-> (str "frontend_client/app/img/pulse_no_results@2x.png") io/resource io/input-stream IOUtils/toByteArray render-img)}]
:src (render-image-with-filename "frontend_client/app/img/pulse_no_results@2x.png")}]
[:div {:style (style {:margin-top :8px
:color color-grey-4})}
"No results"]])
(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)
......@@ -389,7 +390,7 @@
:else :table)))
(defn render-pulse-card
[card data render-img include-title include-buttons]
[card data]
(try
[:a {:href (card-href card)
:target "_blank"
......@@ -398,7 +399,7 @@
:margin-bottom :16px
:display :block
:text-decoration :none})}
(when include-title
(when *include-title*
[:table {:style (style {:margin-bottom :8px
:width :100%})}
[:tbody
......@@ -406,15 +407,16 @@
[: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 (-> (str "frontend_client/app/img/external_link.png") io/resource io/input-stream IOUtils/toByteArray render-img)}])]]]])
(when *include-buttons*
[:img {:style (style {:width :16px})
:width 16
:src (render-image-with-filename "frontend_client/app/img/external_link.png")}])]]]])
(case (detect-pulse-card-type card data)
:empty (render-card-empty card data render-img include-buttons)
:scalar (render-card-scalar card data render-img include-buttons)
:sparkline (render-card-sparkline card data render-img include-buttons)
:bar (render-card-bar card data render-img include-buttons)
:table (render-card-table card data render-img include-buttons)
:empty (render:empty card data)
:scalar (render:scalar card data)
:sparkline (render:sparkline card data)
:bar (render:bar card data)
:table (render:table card data)
[:div {:style (style font-style
{:color "#F9D45C"
:font-weight 700})}
......@@ -429,15 +431,19 @@
(defn render-pulse-section
[render-img include-buttons {:keys [card result]}]
"Render a specific section of a Pulse, i.e. a single Card."
[{: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)"})}
(render-pulse-card card (:data result) render-img true include-buttons)])
(binding [*include-title* true]
(render-pulse-card card (:data result)))])
(defn render-pulse-card-to-png
[card data include-title]
(render-html-to-png (render-pulse-card card data render-img-data-uri include-title false) card-width))
"Render a PULSE-CARD as a PNG. DATA is the `:data` from a QP result (I think...)"
[pulse-card data]
(render-html-to-png (render-pulse-card pulse-card data) card-width))
(ns metabase.task.send-pulses
"Tasks related to running `Pulses`."
(:require [clojure.tools.logging :as log]
[cheshire.core :as cheshire]
[cheshire.core :as json]
(clojurewerkz.quartzite [jobs :as jobs]
[triggers :as triggers])
[clojurewerkz.quartzite.schedule.cron :as cron]
[clj-time.core :as time]
[clj-time.predicates :as timepr]
[metabase.api.common :refer [let-404]]
[metabase.db :as db]
[metabase.driver :as driver]
[metabase.email :as email]
......@@ -23,10 +24,10 @@
[metabase.pulse :as p]))
(declare send-pulses)
(declare send-pulses!)
(def send-pulses-job-key "metabase.task.send-pulses.job")
(def send-pulses-trigger-key "metabase.task.send-pulses.trigger")
(def ^:private ^:const send-pulses-job-key "metabase.task.send-pulses.job")
(def ^:private ^:const send-pulses-trigger-key "metabase.task.send-pulses.trigger")
(defonce ^:private send-pulses-job (atom nil))
(defonce ^:private send-pulses-trigger (atom nil))
......@@ -61,7 +62,7 @@
:id)
curr-monthday (monthday now)
curr-monthweek (monthweek now)]
(send-pulses curr-hour curr-weekday curr-monthday curr-monthweek)))
(send-pulses! curr-hour curr-weekday curr-monthday curr-monthweek)))
(defn- task-init []
;; build our job
......@@ -83,18 +84,18 @@
;; TODO: this is probably something that could live somewhere else and just be reused by us
(defn- ^:private execute-card
(defn- execute-card
"Execute the query for a single card."
[card-id]
{:pre [(integer? card-id)]}
(let [card (db/sel :one Card :id card-id)
{:keys [creator_id dataset_query]} card]
(try
{:card card :result (driver/dataset-query dataset_query {:executed_by creator_id})}
(catch Throwable t
(log/warn (format "Error running card query (%n)" card-id) t)))))
(defn send-pulse-email
(let-404 [card (Card card-id)]
(let [{:keys [creator_id dataset_query]} card]
(try
{:card card :result (driver/dataset-query dataset_query {:executed_by creator_id})}
(catch Throwable t
(log/warn (format "Error running card query (%n)" card-id) t))))))
(defn- send-email-pulse!
"Send a `Pulse` email given a list of card results to render and a list of recipients to send to."
[{:keys [id name] :as pulse} results recipients]
(log/debug (format "Sending Pulse (%d: %s) via Channel :email" id name))
......@@ -106,44 +107,48 @@
:message-type :attachments
:message (messages/render-pulse-email pulse results))))
(defn- create-slack-attachment
(defn- create-and-upload-slack-attachment!
"Create an attachment in Slack for a given Card by rendering its result into an image and uploading it."
[slack-channel {{:keys [id name] :as card} :card, {:keys [data]} :result}]
(let [image-byte-array (p/render-pulse-card-to-png card data false)
slack-file-url (slack/files-upload image-byte-array "image.png" slack-channel)]
{:title name
:title_link (urls/question-url id)
[channel-id {{card-id :id, card-name :name, :as card} :card, {:keys [data]} :result}]
(let [image-byte-array (p/render-pulse-card-to-png card data)
slack-file-url (slack/upload-file! image-byte-array "image.png" channel-id)]
{:title card-name
:title_link (urls/question-url card-id)
:image_url slack-file-url
:fallback name}))
:fallback card-name}))
(defn send-pulse-slack
(defn send-slack-pulse!
"Post a `Pulse` to a slack channel given a list of card results to render and details about the slack destination."
[{:keys [id name]} results details]
(log/debug (format "Sending Pulse (%d: %s) via Channel :slack" id name))
(when-let [metabase-files-channel (slack/get-or-create-files-channel)]
(let [attachments (mapv (partial create-slack-attachment (:id metabase-files-channel)) results)]
(slack/chat-post-message (:channel details)
(str "Pulse: " name)
(cheshire/generate-string attachments)))))
(defn send-pulse
[pulse results channel-id]
{:pre [(string? channel-id)]}
(log/debug (u/format-color 'cyan "Sending Pulse (%d: %s) via Slack" (:id pulse) (:name pulse)))
(when-let [metabase-files-channel (slack/get-or-create-files-channel!)]
(let [attachments (doall (for [result results]
(create-and-upload-slack-attachment! (:id metabase-files-channel) result)))]
(slack/post-chat-message! channel-id
(str "Pulse: " (:name pulse))
attachments))))
(defn send-pulse!
"Execute and Send a `Pulse`, optionally specifying the specific `PulseChannels`. This includes running each
`PulseCard`, formatting the results, and sending the results to any specified destination.
Example:
(send-pulse pulse) Send to all Channels
(send-pulse pulse :channel-ids [312]) Send only to Channel with :id = 312"
(send-pulse! pulse) Send to all Channels
(send-pulse! pulse :channel-ids [312]) Send only to Channel with :id = 312"
[{:keys [cards] :as pulse} & {:keys [channel-ids]}]
{:pre [(map? pulse)]}
(let [results (map execute-card (mapv :id cards))
channels (or channel-ids (mapv :id (:channels pulse)))]
(doseq [channel-id channels]
(let [{:keys [channel_type details recipients]} (first (filter #(= channel-id (:id %)) (:channels pulse)))]
(cond
(= :email (keyword channel_type)) (send-pulse-email pulse results recipients)
(= :slack (keyword channel_type)) (send-pulse-slack pulse results details))))))
(defn send-pulses
{:pre [(map? pulse) (every? map? cards) (every? :id cards)]}
(let [results (for [card cards]
(execute-card (:id card)))
channel-ids (or channel-ids (mapv :id (:channels pulse)))]
(doseq [channel-id channel-ids]
(let [{:keys [channel_type details recipients]} (some #(when (= channel-id (:id %)) %)
(:channels pulse))]
(condp = (keyword channel_type)
:email (send-email-pulse! pulse results recipients)
:slack (send-slack-pulse! pulse results (:channel details)))))))
(defn- send-pulses!
"Send any `Pulses` which are scheduled to run in the current day/hour. We use the current time and determine the
hour of the day and day of the week according to the defined reporting timezone, or UTC. We then find all `Pulses`
that are scheduled to run and send them."
......@@ -158,7 +163,7 @@
(try
(log/debug (format "Starting Pulse Execution: %d" pulse-id))
(when-let [pulse (pulse/retrieve-pulse pulse-id)]
(send-pulse pulse :channel-ids (mapv :id (get channels-by-pulse pulse-id))))
(send-pulse! pulse :channel-ids (mapv :id (get channels-by-pulse pulse-id))))
(log/debug (format "Finished Pulse Execution: %d" pulse-id))
(catch Exception e
(log/error "Error sending pulse:" pulse-id e))))))
......@@ -101,7 +101,7 @@
(dissoc :date_joined :last_login :is_superuser :is_qbnewb)))
;; create a channel then select its details
(defn- create-channel-then-select
(defn- create-channel-then-select!
[channel]
(when-let [new-channel-id (create-pulse-channel channel)]
(-> (db/sel :one PulseChannel :id new-channel-id)
......@@ -110,7 +110,7 @@
(dissoc :id :pulse_id :created_at :updated_at)
(m/dissoc-in [:details :emails]))))
(defn- update-channel-then-select
(defn- update-channel-then-select!
[{:keys [id] :as channel}]
(update-pulse-channel channel)
(-> (db/sel :one PulseChannel :id id)
......@@ -130,11 +130,11 @@
(user-details :rasta)]}
(tu/with-temp Pulse [{:keys [id]} {:creator_id (user->id :rasta)
:name (tu/random-name)}]
(create-channel-then-select {:pulse_id id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 18
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)} {:id (user->id :crowberto)}]})))
(create-channel-then-select! {:pulse_id id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 18
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)} {:id (user->id :crowberto)}]})))
(expect
{:channel_type :slack
......@@ -146,11 +146,11 @@
:details {:something "random"}}
(tu/with-temp Pulse [{:keys [id]} {:creator_id (user->id :rasta)
:name (tu/random-name)}]
(create-channel-then-select {:pulse_id id
:channel_type :slack
:schedule_type schedule-type-hourly
:details {:something "random"}
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)} {:id (user->id :crowberto)}]})))
(create-channel-then-select! {:pulse_id id
:channel_type :slack
:schedule_type schedule-type-hourly
:details {:something "random"}
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)} {:id (user->id :crowberto)}]})))
;; update-pulse-channel
......@@ -169,11 +169,11 @@
:details {}
:schedule_type schedule-type-daily
:schedule_hour 15}]
(update-channel-then-select {:id channel-id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 18
:recipients [{:email "foo@bar.com"}]}))))
(update-channel-then-select! {:id channel-id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 18
:recipients [{:email "foo@bar.com"}]}))))
;; monthly schedules require a schedule_frame and can optionally omit they schedule_day
(expect
......@@ -190,13 +190,13 @@
:details {}
:schedule_type schedule-type-daily
:schedule_hour 15}]
(update-channel-then-select {:id channel-id
:channel_type :email
:schedule_type schedule-type-monthly
:schedule_hour 8
:schedule_day nil
:schedule_frame schedule-frame-mid
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)}]}))))
(update-channel-then-select! {:id channel-id
:channel_type :email
:schedule_type schedule-type-monthly
:schedule_hour 8
:schedule_day nil
:schedule_frame schedule-frame-mid
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)}]}))))
;; weekly schedule should have a day in it, show that we can get full users
(expect
......@@ -213,12 +213,12 @@
:details {}
:schedule_type schedule-type-daily
:schedule_hour 15}]
(update-channel-then-select {:id channel-id
:channel_type :email
:schedule_type schedule-type-weekly
:schedule_hour 8
:schedule_day "mon"
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)}]}))))
(update-channel-then-select! {:id channel-id
:channel_type :email
:schedule_type schedule-type-weekly
:schedule_hour 8
:schedule_day "mon"
:recipients [{:email "foo@bar.com"} {:id (user->id :rasta)}]}))))
;; hourly schedules don't require day/hour settings (should be nil), fully change recipients
(expect
......@@ -236,12 +236,12 @@
:schedule_type schedule-type-daily
:schedule_hour 15}]
(update-recipients! channel-id [(user->id :rasta)])
(update-channel-then-select {:id channel-id
:channel_type :email
:schedule_type schedule-type-hourly
:schedule_hour 12
:schedule_day "tue"
:recipients [{:id (user->id :crowberto)}]}))))
(update-channel-then-select! {:id channel-id
:channel_type :email
:schedule_type schedule-type-hourly
:schedule_hour 12
:schedule_day "tue"
:recipients [{:id (user->id :crowberto)}]}))))
;; custom details for channels that need it
(expect
......@@ -259,13 +259,13 @@
:details {}
:schedule_type schedule-type-daily
:schedule_hour 15}]
(update-channel-then-select {:id channel-id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 12
:schedule_day "tue"
:recipients [{:email "foo@bar.com"} {:email "blah@bar.com"}]
:details {:channel "#metabaserocks"}}))))
(update-channel-then-select! {:id channel-id
:channel_type :email
:schedule_type schedule-type-daily
:schedule_hour 12
:schedule_day "tue"
:recipients [{:email "foo@bar.com"} {:email "blah@bar.com"}]
:details {:channel "#metabaserocks"}}))))
;; update-recipients!
(expect
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment