/api/pulse endpoints. These are all authenticated. For unauthenticated /api/pulse/unsubscribe endpoints, see [[metabase.pulse.api.unsubscribe]].

Deprecated: will soon be migrated to notification APIs.

(ns ^:deprecated metabase.pulse.api.pulse
  (:require
   [clojure.set :refer [difference]]
   [hiccup.core :refer [html]]
   [hiccup.page :refer [html5]]
   [medley.core :as m]
   [metabase.api.common :as api]
   [metabase.api.common.validation :as validation]
   [metabase.api.macros :as api.macros]
   [metabase.api.routes.common]
   [metabase.channel.email :as email]
   [metabase.channel.render.core :as channel.render]
   [metabase.config :as config]
   [metabase.events :as events]
   [metabase.integrations.slack :as slack]
   [metabase.models.collection :as collection]
   [metabase.models.interface :as mi]
   [metabase.notification.core :as notification]
   [metabase.permissions.core :as perms]
   [metabase.plugins.classloader :as classloader]
   [metabase.premium-features.core :as premium-features]
   [metabase.pulse.models.pulse :as models.pulse]
   [metabase.pulse.models.pulse-channel :as pulse-channel]
   [metabase.pulse.send :as pulse.send]
   [metabase.query-processor :as qp]
   [metabase.query-processor.middleware.permissions :as qp.perms]
   [metabase.util :as u]
   [metabase.util.i18n :refer [tru]]
   [metabase.util.malli.schema :as ms]
   [metabase.util.urls :as urls]
   [toucan2.core :as t2])
  (:import
   (java.io ByteArrayInputStream)))
(set! *warn-on-reflection* true)
(when config/ee-available?
  (classloader/require 'metabase-enterprise.sandbox.api.util
                       'metabase-enterprise.advanced-permissions.common))

Get email channel from an alert.

(defn email-channel
  [alert]
  (m/find-first #(= :email (keyword (:channel_type %))) (:channels alert)))

If the current user is sandboxed, remove all Metabase users from the pulses recipient lists that are not the user themselves. Recipients that are plain email addresses are preserved.

(defn- maybe-filter-pulses-recipients
  [pulses]
  (if (perms/sandboxed-or-impersonated-user?)
    (for [pulse pulses]
      (assoc pulse :channels
             (for [channel (:channels pulse)]
               (assoc channel :recipients
                      (filter (fn [recipient] (or (not (:id recipient))
                                                  (= (:id recipient) api/*current-user-id*)))
                              (:recipients channel))))))
    pulses))
(defn- maybe-filter-pulse-recipients
  [pulse]
  (first (maybe-filter-pulses-recipients [pulse])))

If the current user does not have collection read permissions for the pulse, but can still read the pulse due to being the creator or a recipient, we return it with some metadata removed.

(defn- maybe-strip-sensitive-metadata
  [pulse]
  (if (mi/current-user-has-full-permissions? :read pulse)
    pulse
    (-> (dissoc pulse :cards)
        (update :channels
                (fn [channels]
                  (map #(dissoc % :recipients) channels))))))
(api.macros/defendpoint :get "/"
  "Fetch all dashboard subscriptions. By default, returns only subscriptions for which the current user has write
  permissions. For admins, this is all subscriptions; for non-admins, it is only subscriptions that they created.

  If `dashboard_id` is specified, restricts results to subscriptions for that dashboard.

  If `created_or_receive` is `true`, it specifically returns all subscriptions for which the current user
  created *or* is a known recipient of. Note that this is a superset of the default items returned for non-admins,
  and a subset of the default items returned for admins. This is used to power the /account/notifications page.
  This may include subscriptions which the current user does not have collection permissions for, in which case
  some sensitive metadata (the list of cards and recipients) is stripped out."
  [_route-params
   {:keys                [archived]
    dashboard-id         :dashboard_id
    creator-or-recipient :creator_or_recipient}
   :- [:map
       [:archived             {:default false} [:maybe ms/BooleanValue]]
       [:dashboard_id         {:optional true} [:maybe ms/PositiveInt]]
       [:creator_or_recipient {:default false} [:maybe ms/BooleanValue]]]]
  (let [creator-or-recipient creator-or-recipient
        archived?            archived
        pulses               (->> (models.pulse/retrieve-pulses {:archived?    archived?
                                                                 :dashboard-id dashboard-id
                                                                 :user-id      (when creator-or-recipient api/*current-user-id*)})
                                  (filter (if creator-or-recipient mi/can-read? mi/can-write?))
                                  maybe-filter-pulses-recipients)
        pulses               (if creator-or-recipient
                               (map maybe-strip-sensitive-metadata pulses)
                               pulses)]
    (t2/hydrate pulses :can_write)))

Users can only create a pulse for cards they have access to.

(defn check-card-read-permissions
  [cards]
  (doseq [card cards
          :let [card-id (u/the-id card)]]
    (assert (integer? card-id))
    (api/read-check :model/Card card-id)))
(api.macros/defendpoint :post "/"
  "Create a new `Pulse`."
  [_route-params
   _query-params
   {:keys               [name cards channels parameters]
    skip-if-empty       :skip_if_empty
    collection-id       :collection_id
    collection-position :collection_position
    dashboard-id        :dashboard_id}
   :- [:map
       [:name                ms/NonBlankString]
       [:cards               [:+ models.pulse/CoercibleToCardRef]]
       [:channels            [:+ :map]]
       [:skip_if_empty       {:default false} [:maybe :boolean]]
       [:collection_id       {:optional true} [:maybe ms/PositiveInt]]
       [:collection_position {:optional true} [:maybe ms/PositiveInt]]
       [:dashboard_id        {:optional true} [:maybe ms/PositiveInt]]
       [:parameters          {:optional true} [:maybe [:sequential :map]]]]]
  (validation/check-has-application-permission :subscription false)
  ;; make sure we are allowed to *read* all the Cards we want to put in this Pulse
  (check-card-read-permissions cards)
  ;; if we're trying to create this Pulse inside a Collection, and it is not a dashboard subscription,
  ;; make sure we have write permissions for that collection
  (when-not dashboard-id
    (collection/check-write-perms-for-collection collection-id))
  ;; prohibit creating dashboard subs if the the user doesn't have at least read access for the dashboard
  (when dashboard-id
    (api/read-check :model/Dashboard dashboard-id))
  (let [pulse-data {:name                name
                    :creator_id          api/*current-user-id*
                    :skip_if_empty       skip-if-empty
                    :collection_id       collection-id
                    :collection_position collection-position
                    :dashboard_id        dashboard-id
                    :parameters          parameters}]
    (t2/with-transaction [_conn]
      ;; Adding a new pulse at `collection_position` could cause other pulses in this collection to change position,
      ;; check that and fix it if needed
      (api/maybe-reconcile-collection-position! pulse-data)
      ;; ok, now create the Pulse
      (let [pulse (api/check-500
                   (models.pulse/create-pulse! (map models.pulse/card->ref cards) channels pulse-data))]
        (events/publish-event! :event/pulse-create {:object pulse :user-id api/*current-user-id*})
        pulse))))
(api.macros/defendpoint :get "/:id"
  "Fetch `Pulse` with ID. If the user is a recipient of the Pulse but does not have read permissions for its collection,
  we still return it but with some sensitive metadata removed."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (api/let-404 [pulse (models.pulse/retrieve-pulse id)]
    (api/check-403 (mi/can-read? pulse))
    (-> pulse
        maybe-filter-pulse-recipients
        maybe-strip-sensitive-metadata
        (t2/hydrate :can_write))))

Sandboxed users and users using connection impersonation can't read the full recipient list for a pulse, so we need to merge in existing recipients before writing the pulse updates to avoid them being deleted unintentionally. We only merge in recipients that are Metabase users, not raw email addresses, which these users can still view and modify.

(defn- maybe-add-recipients
  [pulse-updates pulse-before-update]
  (if (perms/sandboxed-or-impersonated-user?)
    (let [recipients-to-add (filter
                             (fn [{id :id}] (and id (not= id api/*current-user-id*)))
                             (:recipients (email-channel pulse-before-update)))]
      (assoc pulse-updates :channels
             (for [channel (:channels pulse-updates)]
               (if (= "email" (:channel_type channel))
                 (assoc channel :recipients
                        (concat (:recipients channel) recipients-to-add))
                 channel))))
    pulse-updates))
(api.macros/defendpoint :put "/:id"
  "Update a Pulse with `id`."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]
   _query-params
   {:keys [cards], :as pulse-updates} :- [:map
                                          [:name          {:optional true} [:maybe ms/NonBlankString]]
                                          [:cards         {:optional true} [:maybe [:+ models.pulse/CoercibleToCardRef]]]
                                          [:channels      {:optional true} [:maybe [:+ :map]]]
                                          [:skip_if_empty {:default false} [:maybe :boolean]]
                                          [:collection_id {:optional true} [:maybe ms/PositiveInt]]
                                          [:archived      {:default false} [:maybe :boolean]]
                                          [:parameters    {:optional true} [:maybe [:sequential ms/Map]]]]]
  ;; do various perms checks
  (try
    (validation/check-has-application-permission :monitoring)
    (catch clojure.lang.ExceptionInfo _e
      (validation/check-has-application-permission :subscription false)))

  (let [pulse-before-update (api/write-check (models.pulse/retrieve-pulse id))]
    (check-card-read-permissions cards)
    (collection/check-allowed-to-change-collection pulse-before-update pulse-updates)

    ;; if advanced-permissions is enabled, only superuser or non-admin with subscription permission can
    ;; update pulse's recipients
    (when (premium-features/enable-advanced-permissions?)
      (let [to-add-recipients (difference (set (map :id (:recipients (email-channel pulse-updates))))
                                          (set (map :id (:recipients (email-channel pulse-before-update)))))
            current-user-has-application-permissions?
            (and (premium-features/enable-advanced-permissions?)
                 (resolve 'metabase-enterprise.advanced-permissions.common/current-user-has-application-permissions?))
            has-subscription-perms?
            (and current-user-has-application-permissions?
                 (current-user-has-application-permissions? :subscription))]
        (api/check (or api/*is-superuser?*
                       has-subscription-perms?
                       (empty? to-add-recipients))
                   [403 (tru "Non-admin users without subscription permissions are not allowed to add recipients")])))

    (let [pulse-updates (maybe-add-recipients pulse-updates pulse-before-update)]
      (t2/with-transaction [_conn]
        ;; If the collection or position changed with this update, we might need to fixup the old and/or new collection,
        ;; depending on what changed.
        (api/maybe-reconcile-collection-position! pulse-before-update pulse-updates)
        ;; ok, now update the Pulse
        (models.pulse/update-pulse!
         (assoc (select-keys pulse-updates [:name :cards :channels :skip_if_empty :collection_id :collection_position
                                            :archived :parameters])
                :id id)))))
  ;; return updated Pulse
  (models.pulse/retrieve-pulse id))
(api.macros/defendpoint :get "/form_input"
  "Provides relevant configuration information and user choices for creating/updating Pulses."
  []
  (validation/check-has-application-permission :subscription false)
  (let [chan-types (-> pulse-channel/channel-types
                       (assoc-in [:slack :configured] (slack/slack-configured?))
                       (assoc-in [:email :configured] (email/email-configured?))
                       (assoc-in [:http :configured] (t2/exists? :model/Channel :type :channel/http :active true)))]
    {:channels (cond
                 (perms/sandboxed-or-impersonated-user?)
                 (dissoc chan-types :slack)

                 ;; no Slack integration, so we are g2g
                 (not (get-in chan-types [:slack :configured]))
                 chan-types

                 ;; if we have Slack enabled return cached channels and users
                 :else
                 (try
                   (future (slack/refresh-channels-and-usernames-when-needed!))
                   (assoc-in chan-types
                             [:slack :fields 0 :options]
                             (->> (slack/slack-cached-channels-and-usernames)
                                  :channels
                                  (map :display-name)))
                   (catch Throwable e
                     (assoc-in chan-types [:slack :error] (.getMessage e)))))}))
(defn- pulse-card-query-results
  {:arglists '([card])}
  [{query :dataset_query, card-id :id}]
  (binding [qp.perms/*card-id* card-id]
    (qp/process-query
     (qp/userland-query
      (assoc query
             :middleware {:process-viz-settings? true
                          :js-int-to-string?     false})
      {:executed-by api/*current-user-id*
       :context     :pulse
       :card-id     card-id}))))
(api.macros/defendpoint :get "/preview_card/:id"
  "Get HTML rendering of a Card with `id`."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (let [card   (api/read-check :model/Card id)
        result (pulse-card-query-results card)]
    {:status 200
     :body   (html5
              [:html
               [:body {:style "margin: 0;"}
                (channel.render/render-pulse-card-for-display (channel.render/defaulted-timezone card)
                                                              card
                                                              result
                                                              {:channel.render/include-title? true, :channel.render/include-buttons? true})]])}))
(api.macros/defendpoint :get "/preview_dashboard/:id"
  "Get HTML rendering of a Dashboard with `id`.

  This endpoint relies on a custom middleware defined in `metabase.channel.render.core/style-tag-nonce-middleware` to
  allow the style tag to render properly, given our Content Security Policy setup. This middleware is attached to these
  routes at the bottom of this namespace using `metabase.api.common/define-routes`."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (api/read-check :model/Dashboard id)
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    (channel.render/style-tag-from-inline-styles
             (html5
              [:head
               [:meta {:charset "utf-8"}]
               [:link {:nonce "%NONCE%" ;; this will be str/replaced by 'style-tag-nonce-middleware
                       :rel  "stylesheet"
                       :href "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"}]]
              [:body [:h2 (format "Backend Artifacts Preview for Dashboard %s" id)]
               (channel.render/render-dashboard-to-html id)]))})
(api.macros/defendpoint :get "/preview_card_info/:id"
  "Get JSON object containing HTML rendering of a Card with `id` and other information."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (let [card      (api/read-check :model/Card id)
        result    (pulse-card-query-results card)
        data      (:data result)
        card-type (channel.render/detect-pulse-chart-type card nil data)
        card-html (html (channel.render/render-pulse-card-for-display (channel.render/defaulted-timezone card)
                                                                      card
                                                                      result
                                                                      {:channel.render/include-title? true}))]
    {:id              id
     :pulse_card_type card-type
     :pulse_card_html card-html
     :pulse_card_name (:name card)
     :pulse_card_url  (urls/card-url (:id card))
     :row_count       (:row_count result)
     :col_count       (count (:cols (:data result)))}))
(def ^:private preview-card-width 400)
(api.macros/defendpoint :get "/preview_card_png/:id"
  "Get PNG rendering of a Card with `id`."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (let [card   (api/read-check :model/Card id)
        result (pulse-card-query-results card)
        ba     (channel.render/render-pulse-card-to-png (channel.render/defaulted-timezone card)
                                                        card
                                                        result
                                                        preview-card-width
                                                        {:channel.render/include-title? true})]
    {:status 200, :headers {"Content-Type" "image/png"}, :body (ByteArrayInputStream. ba)}))
(api.macros/defendpoint :post "/test"
  "Test send an unsaved pulse."
  [_route-params
   _query-params
   {:keys [cards channels] :as body} :- [:map
                                         [:name                ms/NonBlankString]
                                         [:cards               [:+ models.pulse/CoercibleToCardRef]]
                                         [:channels            [:+ :map]]
                                         [:skip_if_empty       {:default false} [:maybe :boolean]]
                                         [:collection_id       {:optional true} [:maybe ms/PositiveInt]]
                                         [:collection_position {:optional true} [:maybe ms/PositiveInt]]
                                         [:dashboard_id        {:optional true} [:maybe ms/PositiveInt]]]]
  ;; Check permissions on cards that exist. Placeholders and iframes don't matter.
  (check-card-read-permissions
   (remove (fn [{:keys [id display]}]
             (and (nil? id)
                  (or (= "placeholder" display)
                      (= "iframe" display)))) cards))
  ;; make sure any email addresses that are specified are allowed before sending the test Pulse.
  (doseq [channel channels]
    (pulse-channel/validate-email-domains channel))
  (binding [notification/*default-options* {:notification/sync? true}]
    (pulse.send/send-pulse! (assoc body :creator_id api/*current-user-id*)))
  {:ok true})
(api.macros/defendpoint :delete "/:id/subscription"
  "For users to unsubscribe themselves from a pulse subscription."
  [{:keys [id]} :- [:map
                    [:id ms/PositiveInt]]]
  (api/let-404 [pulse-id (t2/select-one-pk :model/Pulse :id id)
                pc-id    (t2/select-one-pk :model/PulseChannel :pulse_id pulse-id :channel_type "email")
                pcr-id   (t2/select-one-pk :model/PulseChannelRecipient :pulse_channel_id pc-id :user_id api/*current-user-id*)]
    (t2/delete! :model/PulseChannelRecipient :id pcr-id))
  api/generic-204-no-content)
(def ^:private ^{:arglists '([handler])} style-nonce-middleware
  (metabase.api.routes.common/wrap-middleware-for-open-api-spec-generation
   (partial channel.render/style-tag-nonce-middleware "/api/pulse/preview_dashboard")))

/api/pulse endpoints.

(def ^{:arglists '([request respond raise])} routes
  (api.macros/ns-handler *ns* style-nonce-middleware))