From 77a64cbb2da94850c568bebe5da60cd472f529dd Mon Sep 17 00:00:00 2001
From: Cam Saul <cam@geotip.com>
Date: Tue, 31 Mar 2015 17:24:35 -0700
Subject: [PATCH] EmailReports handle recipients

---
 .dir-locals.el                                |  1 +
 .../emailreport/emailreport.controllers.js    |  6 +--
 src/metabase/api/emailreport.clj              | 48 ++++++++++-------
 src/metabase/middleware/log_api_call.clj      |  2 +-
 src/metabase/models/emailreport.clj           | 45 +++++++++++++---
 src/metabase/models/user.clj                  |  7 ++-
 test/metabase/api/emailreport_test.clj        | 53 +++++++++++++++++--
 test/metabase/models/emailreport_test.clj     | 37 +++++++++++++
 test/metabase/test/util.clj                   | 35 +++++++++++-
 test/metabase/test_data.clj                   |  8 +++
 10 files changed, 203 insertions(+), 39 deletions(-)
 create mode 100644 test/metabase/models/emailreport_test.clj

diff --git a/.dir-locals.el b/.dir-locals.el
index 0bb401a0c28..778036b06af 100644
--- a/.dir-locals.el
+++ b/.dir-locals.el
@@ -27,5 +27,6 @@
                               (match-$ 1)
                               (macrolet 1)
                               (org-perms-case 1)
+                              (symbol-macrolet 1)
                               (upd 2)
                               (with-credentials 1)))))))
diff --git a/resources/frontend_client/app/admin/emailreport/emailreport.controllers.js b/resources/frontend_client/app/admin/emailreport/emailreport.controllers.js
index de7f93d4f15..43fb0eef095 100644
--- a/resources/frontend_client/app/admin/emailreport/emailreport.controllers.js
+++ b/resources/frontend_client/app/admin/emailreport/emailreport.controllers.js
@@ -106,11 +106,7 @@ EmailReportControllers.controller('EmailReportDetail', ['$scope', '$routeParams'
             // we need to ensure our recipients list is properly set on the report
             var recipients = [];
             $scope.form_input.users.forEach(function(user) {
-                if (user.incl) {
-                    recipients.push({
-                        'id': user.id
-                    });
-                }
+                if (user.incl) recipients.push(user.id);
             });
             reportDetail.recipients = recipients;
 
diff --git a/src/metabase/api/emailreport.clj b/src/metabase/api/emailreport.clj
index 38cd85bb31c..7fb7b461496 100644
--- a/src/metabase/api/emailreport.clj
+++ b/src/metabase/api/emailreport.clj
@@ -54,38 +54,48 @@
 
 (defendpoint POST "/"
   "Create a new `EmailReport`."
-  [:as {{:keys [dataset_query description email_addresses mode name organization public_perms schedule] :as body} :body}]
+  [:as {{:keys [dataset_query description email_addresses mode name organization public_perms schedule recipients] :as body} :body}]
   {dataset_query  Required
    name           Required
    organization   Required
    schedule       Required
    mode           EmailReportMode
-   public_perms   PublicPerms}
+   public_perms   PublicPerms
+   recipients     ArrayOfIntegers}
   (read-check Org organization)
-  (check-500 (ins EmailReport
-               :creator_id *current-user-id*
-               :dataset_query dataset_query
-               :description description
-               :email_addresses email_addresses
-               :mode mode
-               :name name
-               :organization_id organization
-               :public_perms public_perms
-               :schedule schedule))) ; TODO - deal with recipients
-
-
-(defendpoint GET "/:id" [id]
+  (let-500 [report (ins EmailReport
+                     :creator_id *current-user-id*
+                     :dataset_query dataset_query
+                     :description description
+                     :email_addresses email_addresses
+                     :mode mode
+                     :name name
+                     :organization_id organization
+                     :public_perms public_perms
+                     :schedule schedule)]
+    (model/update-recipients report recipients)
+    (hydrate report :recipients)))
+
+
+(defendpoint GET "/:id"
+  "Fetch `EmailReport` with ID."
+  [id]
   (->404 (sel :one EmailReport :id id)
          read-check
-         (hydrate :creator :organization :can_read :can_write)))
+         (hydrate :creator :organization :can_read :can_write :recipients)))
 
 
-(defendpoint PUT "/:id" [id :as {{:keys [dataset_query description email_addresses mode name public_perms schedule] :as body} :body}]
+(defendpoint PUT "/:id"
+  "Update an `EmailReport`."
+  [id :as {{:keys [dataset_query description email_addresses mode name public_perms schedule recipients] :as body} :body}]
   {name         NonEmptyString
    mode         EmailReportMode
-   public_perms PublicPerms}
+   public_perms PublicPerms
+   recipients   ArrayOfIntegers}
+  (clojure.pprint/pprint recipients)
   (let-404 [report (sel :one EmailReport :id id)]
     (write-check report)
+    (model/update-recipients report recipients)
     (check-500 (upd-non-nil-keys EmailReport id
                                  :dataset_query   dataset_query
                                  :description     description
@@ -102,7 +112,7 @@
 
 (defendpoint DELETE "/:id" [id]
   (write-check EmailReport id)
-  (del EmailReport :id id))
+  (cascade-delete EmailReport :id id))
 
 
 (defendpoint POST "/:id" [id]
diff --git a/src/metabase/middleware/log_api_call.clj b/src/metabase/middleware/log_api_call.clj
index 816e6a026e6..59d8f948337 100644
--- a/src/metabase/middleware/log_api_call.clj
+++ b/src/metabase/middleware/log_api_call.clj
@@ -27,7 +27,7 @@
 
 (def ^:private only-display-output-on-error
   "Set this to `false` to see all API responses."
-  true)
+  false)
 
 (defn log-api-call
   "Middleware to log `:request` and/or `:response` by passing corresponding OPTIONS."
diff --git a/src/metabase/models/emailreport.clj b/src/metabase/models/emailreport.clj
index 98124f838da..b04c1a2c78c 100644
--- a/src/metabase/models/emailreport.clj
+++ b/src/metabase/models/emailreport.clj
@@ -1,5 +1,6 @@
 (ns metabase.models.emailreport
   (:require [clojure.data.json :as json]
+            [clojure.set :as set]
             [korma.core :refer :all]
             [metabase.api.common :refer [check]]
             [metabase.db :refer :all]
@@ -91,14 +92,11 @@
         (assoc :dataset_query (json/write-str dataset_query)
                :schedule (json/write-str schedule)))))
 
-(defmethod pre-update EmailReport [_ {:keys [version dataset_query schedule id] :as report}]
-  (let [version (or version
-                    (sel :one :field [EmailReport :version] :id id))]
-    (assoc report
-           :updated_at    (util/new-sql-timestamp)
-           :dataset_query (json/write-str dataset_query)
-           :schedule      (json/write-str schedule)
-           :version       (inc version))))
+(defmethod pre-update EmailReport [_ {:keys [dataset_query schedule id] :as report}]
+  (assoc report                                        ; don't increment `version` here.
+         :updated_at    (util/new-sql-timestamp)       ; we do that in the API endpoint
+         :dataset_query (json/write-str dataset_query)
+         :schedule      (json/write-str schedule)))
 
 
 (defmethod post-select EmailReport [_ {:keys [id creator_id organization_id] :as report}]
@@ -112,3 +110,34 @@
       :recipients   (delay (sel :many User
                                 (where {:id [in (subselect EmailReportRecipients (fields :user_id) (where {:emailreport_id id}))]}))))
     assoc-permissions-sets))
+
+(defmethod pre-cascade-delete EmailReport [_ {:keys [id]}]
+  (cascade-delete EmailReportRecipients :emailreport_id id))
+
+
+;; ## Related Functions
+
+(defn update-recipients
+  "Update the `EmailReportRecipients` for EMAIL-REPORT.
+   USER-IDS should be a definitive collection of *all* IDs of users who should receive the report.
+
+   *  If an ID in USER-IDS has no corresponding existing `EmailReportRecipients` object, one will be created.
+   *  If an existing `EmailReportRecipients` has no corresponding ID in USER-IDs, it will be deleted."
+  {:arglists '([email-report user-ids])}
+  [{:keys [id]} user-ids]
+  {:pre [(integer? id)
+         (coll? user-ids)
+         (every? integer? user-ids)]}
+  (let [recipients-old (set (sel :many :field [EmailReportRecipients :user_id] :emailreport_id id))
+        recipients-new (set user-ids)
+        recipients+    (set/difference recipients-new recipients-old)
+        recipients-    (set/difference recipients-old recipients-new)]
+    (when (seq recipients+)
+      (let [vs (map #(assoc {:emailreport_id id} :user_id %)
+                    recipients+)]
+        (insert EmailReportRecipients
+                (values vs))))
+    (when (seq recipients-)
+      (delete EmailReportRecipients
+              (where {:emailreport_id id
+                      :user_id [in recipients-]})))))
diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj
index 9dd92436868..3b3546fbfb9 100644
--- a/src/metabase/models/user.clj
+++ b/src/metabase/models/user.clj
@@ -6,6 +6,8 @@
             (metabase.models [org-perm :refer [OrgPerm]])
             [metabase.util :as util]))
 
+;; ## Enity + DB Multimethods
+
 (defentity User
   (table :core_user)
   (has-many OrgPerm {:fk :user_id})
@@ -68,8 +70,11 @@
 
 (defmethod pre-cascade-delete User [_ {:keys [id]}]
   (cascade-delete 'metabase.models.org-perm/OrgPerm :user_id id)
-  (cascade-delete 'metabase.models.session/Session :user_id id))
+  (cascade-delete 'metabase.models.session/Session :user_id id)
+  (cascade-delete 'metabase.models.emailreport-recipients/EmailReportRecipients :user_id id))
+
 
+;; ## Related Functions
 
 (defn create-user
   "Convenience function for creating a new `User` and sending out the welcome email."
diff --git a/test/metabase/api/emailreport_test.clj b/test/metabase/api/emailreport_test.clj
index 842d60e5406..508cc1b5285 100644
--- a/test/metabase/api/emailreport_test.clj
+++ b/test/metabase/api/emailreport_test.clj
@@ -1,13 +1,15 @@
 (ns metabase.api.emailreport-test
   "Tests for /api/emailreport endpoints."
-  (:require [expectations :refer :all]
+  (:require [clojure.tools.macro :refer [symbol-macrolet]]
+            [expectations :refer :all]
             [korma.core :refer :all]
             [metabase.db :refer :all]
             (metabase.models [common :as common]
                              [database :refer [Database]]
                              [emailreport :refer [EmailReport] :as emailreport])
-            [metabase.test.util :refer [match-$ expect-eval-actual-first random-name]]
-            [metabase.test-data :refer :all]))
+            [metabase.test.util :refer [match-$ expect-eval-actual-first random-name with-temp]]
+            [metabase.test-data :refer :all]
+            metabase.test-setup))
 
 ;; ## Helper Fns
 
@@ -17,7 +19,7 @@
            :mode (emailreport/mode->id :active)
            :public_perms common/perms-readwrite
            :email_addresses ""
-           :recipients [{:id (user->id :lucky)}]
+           :recipients [(user->id :lucky)]
            :dataset_query {:type "query"
                            :query {:source_table (table->id :venues)
                                    :filter [nil nil]
@@ -92,6 +94,15 @@
                                  :sun true}
                   :timezone ""
                   :time_of_day "morning"}
+       :recipients [(match-$ (fetch-user :lucky)
+                      {:email "lucky@metabase.com"
+                       :first_name "Lucky"
+                       :last_login $
+                       :is_superuser false
+                       :id $
+                       :last_name "Pigeon"
+                       :date_joined $
+                       :common_name "Lucky Pigeon"})]
        :organization_id @org-id
        :name "My Cool Email Report"
        :mode (emailreport/mode->id :active)
@@ -134,6 +145,15 @@
                    :last_login $
                    :first_name "Rasta"
                    :email "rasta@metabase.com"})
+       :recipients [(match-$ (fetch-user :lucky)
+                      {:email "lucky@metabase.com"
+                       :first_name "Lucky"
+                       :last_login $
+                       :is_superuser false
+                       :id $
+                       :last_name "Pigeon"
+                       :date_joined $
+                       :common_name "Lucky Pigeon"})]
        :can_write true
        :organization_id @org-id
        :name "My Cool Email Report"
@@ -173,3 +193,28 @@
      (let [{id :id} (sel :one EmailReport :name er-name)]
        ((user->client :rasta) :delete 204 (format "emailreport/%d" id))
        (er-exists?))]))
+
+
+;; ## RECPIENTS-RELATED TESTS
+;; *  Check that recipients are returned by GET /api/emailreport/:id
+;; *  Check that we can set them via PUT /api/emailreport/:id
+(expect
+    [#{}
+     #{:rasta}
+     #{:crowberto :lucky}
+     #{}]
+  (with-temp EmailReport [{:keys [id]} {:creator_id      (user->id :rasta)
+                                        :name            (random-name)
+                                        :organization_id @org-id}]
+    (symbol-macrolet [get-recipients (->> ((user->client :rasta) :get 200 (format "emailreport/%d" id))
+                                          :recipients
+                                          (map :id)
+                                          (map id->user)
+                                          set)]
+      (let [put-recipients (fn [& user-kws]
+                             ((user->client :rasta) :put 200 (format "emailreport/%d" id) {:recipients (map user->id user-kws)})
+                             get-recipients)]
+        [get-recipients
+         (put-recipients :rasta)
+         (put-recipients :lucky :crowberto)
+         (put-recipients)]))))
diff --git a/test/metabase/models/emailreport_test.clj b/test/metabase/models/emailreport_test.clj
new file mode 100644
index 00000000000..aec6fd63914
--- /dev/null
+++ b/test/metabase/models/emailreport_test.clj
@@ -0,0 +1,37 @@
+(ns metabase.models.emailreport-test
+  (:require [clojure.tools.macro :refer [symbol-macrolet]]
+            [expectations :refer :all]
+            [medley.core :as m]
+            [metabase.db :refer :all]
+            (metabase.models [emailreport :refer :all]
+                             [emailreport-recipients :refer :all])
+            [metabase.test-data :refer :all]
+            metabase.test-setup
+            [metabase.test.util :as tu]))
+
+;; ## UPDATE-RECIPIENTS
+;; Check that update-recipients inserts/deletes EmailReportRecipeints as we expect
+(expect
+    [#{}
+     #{:rasta :lucky}
+     #{:rasta :lucky :trashbird}
+     #{:trashbird}
+     #{:crowberto :lucky}
+     #{}
+     #{:rasta}]
+  (tu/with-temp EmailReport [report {:creator_id      (user->id :rasta)
+                                     :name            (tu/random-name)
+                                     :organization_id @org-id}]
+    (symbol-macrolet [recipients (->> (sel :many :field [EmailReportRecipients :user_id] :emailreport_id (:id report))
+                                      (map id->user)
+                                      set)]
+      (let [upd-recipients (fn [& recipient-ids]
+                             (update-recipients report (map user->id recipient-ids))
+                             recipients)]
+        [recipients
+         (upd-recipients :rasta :lucky)
+         (upd-recipients :rasta :lucky :trashbird)
+         (upd-recipients :trashbird)
+         (upd-recipients :crowberto :lucky)
+         (upd-recipients)
+         (upd-recipients :rasta)]))))
diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj
index a7b6fac9318..46abed99bc3 100644
--- a/test/metabase/test/util.clj
+++ b/test/metabase/test/util.clj
@@ -1,7 +1,9 @@
 (ns metabase.test.util
   "Helper functions and macros for writing unit tests."
   (:require [expectations :refer :all]
-            [metabase.util :as u]))
+            [medley.core :as m]
+            (metabase [db :refer :all]
+                      [util :as u])))
 
 (declare $->prop)
 
@@ -64,9 +66,40 @@
     `(def ~(vary-meta fn-name assoc :expectation true)
        (fn [] (-doexpect ~expected ~actual)))))
 
+
 ;; ## random-name
 (defn random-name
   "Generate a random string of 20 uppercase letters."
   []
   (->> (repeatedly 20 #(-> (rand-int 26) (+ (int \A)) char))
        (apply str)))
+
+
+;; ## with-temp
+;;
+(defmacro with-temp
+  "Create a temporary instance of ENTITY bound to BINDING-FORM, execute BODY,
+   then delete it via `cascade-delete`.
+
+   Our unit tests rely a heavily on the test data and make some assumptions about the
+   DB staying in the same *clean* state. This allows us to write very conscise tests.
+   Generally this means tests should \"clean up after themselves\" and leave things the
+   way they found them.
+
+   `with-temp` should be preferrable going forward over creating random objects *without*
+   deleting them afterward.
+
+    (with-temp EmailReport [report {:creator_id      (user->id :rasta)
+                                    :name            (random-name)
+                                    :organization_id @org-id}]
+      ...)"
+  [entity [binding-form & [options-map]] & body]
+  `(let [object# (m/mapply ins ~entity ~options-map)
+         ~binding-form object#
+         delete-fn# (fn [] (cascade-delete ~entity :id (:id object#)))]
+     (let [result# (try (do ~@body)
+                        (catch Throwable e#
+                          (delete-fn#)
+                          (throw e#)))]
+       (delete-fn#)
+       result#)))
diff --git a/test/metabase/test_data.clj b/test/metabase/test_data.clj
index 6d825813c6d..6572481cca5 100644
--- a/test/metabase/test_data.clj
+++ b/test/metabase/test_data.clj
@@ -123,6 +123,14 @@
      {:pre [(contains? usernames username)]}
      (:id (fetch-user username)))))
 
+(def id->user
+  "Reverse of `user->id`.
+
+    (id->user 4) -> :rasta"
+  (let [m (delay (zipmap (map user->id usernames) usernames))]
+    (fn [id]
+      (@m id))))
+
 (let [tokens (atom {})
       user->token (fn [user]
                     (or (@tokens user)
-- 
GitLab