From c64ef9096fe960f64dc1ea40c44dddccdbc4d181 Mon Sep 17 00:00:00 2001
From: dpsutton <dan@dpsutton.com>
Date: Mon, 15 Nov 2021 10:42:43 -0600
Subject: [PATCH] Dataset recent activity (#18967)

* Get rid of the n-queries

old logic: select last view logs, and then for each one, select its
associated model (card, table, etc).

new logic: get the last view logs, and do three batch queries, one each
to card, dashboard, table, for the ids from the view logs

* Mark datasets in api/activity/recent_views

* Get rid of `(db/delete! Activity)` in tests

* Handle datasets in activity list
---
 src/metabase/api/activity.clj       | 107 ++++++++++++++++++----------
 test/metabase/api/activity_test.clj |  54 +++++++-------
 2 files changed, 100 insertions(+), 61 deletions(-)

diff --git a/src/metabase/api/activity.clj b/src/metabase/api/activity.clj
index 3b15658caab..de9aafae95c 100644
--- a/src/metabase/api/activity.clj
+++ b/src/metabase/api/activity.clj
@@ -1,6 +1,7 @@
 (ns metabase.api.activity
   (:require [clojure.set :as set]
             [compojure.core :refer [GET]]
+            [medley.core :as m]
             [metabase.api.common :refer [*current-user-id* defendpoint define-routes]]
             [metabase.models.activity :refer [Activity]]
             [metabase.models.card :refer [Card]]
@@ -36,28 +37,47 @@
      (referenced-objects->existing-objects {\"dashboard\" #{41 42 43}, \"card\" #{100 101}, ...})
      ;; -> {\"dashboard\" #{41 43}, \"card\" #{101}, ...}"
   [referenced-objects]
-  (into {} (for [[model ids] referenced-objects
-                 :when       (seq ids)]
-             {model (case model
-                      "card"      (db/select-ids 'Card,      :id [:in ids])
-                      "dashboard" (db/select-ids 'Dashboard, :id [:in ids])
-                      "metric"    (db/select-ids 'Metric,    :id [:in ids], :archived false)
-                      "pulse"     (db/select-ids 'Pulse,     :id [:in ids])
-                      "segment"   (db/select-ids 'Segment,   :id [:in ids], :archived false)
-                      nil)}))) ; don't care about other models
+  (merge
+   (when-let [card-ids (get referenced-objects "card")]
+     (let [id->dataset?                       (db/select-id->field :dataset Card
+                                                                   :id [:in card-ids])
+           {dataset-ids true card-ids' false} (group-by (comp boolean id->dataset?)
+                                                        ;; only existing ids go back
+                                                        (keys id->dataset?))]
+       (cond-> {}
+         (seq dataset-ids) (assoc "dataset" (set dataset-ids))
+         (seq card-ids')   (assoc "card" (set card-ids')))))
+   (into {} (for [[model ids] (dissoc referenced-objects "card")
+                  :when       (seq ids)]
+              {model (case model
+                       "dashboard" (db/select-ids 'Dashboard, :id [:in ids])
+                       "metric"    (db/select-ids 'Metric,    :id [:in ids], :archived false)
+                       "pulse"     (db/select-ids 'Pulse,     :id [:in ids])
+                       "segment"   (db/select-ids 'Segment,   :id [:in ids], :archived false)
+                       nil)})))) ; don't care about other models
 
 (defn- add-model-exists-info
   "Add `:model_exists` keys to `activities`, and `:exists` keys to nested dashcards where appropriate."
   [activities]
-  (let [existing-objects (-> activities activities->referenced-objects referenced-objects->existing-objects)]
-    (for [{:keys [model model_id], :as activity} activities]
-      (let [activity (assoc activity :model_exists (contains? (get existing-objects model) model_id))]
-        (if-not (dashcard-activity? activity)
-          activity
-          (update-in activity [:details :dashcards] (fn [dashcards]
-                                                      (for [dashcard dashcards]
-                                                        (assoc dashcard :exists (contains? (get existing-objects "card")
-                                                                                           (:card_id dashcard)))))))))))
+  (let [existing-objects (-> activities activities->referenced-objects referenced-objects->existing-objects)
+        existing-dataset? (fn [card-id]
+                            (contains? (get existing-objects "dataset") card-id))]
+    (for [{:keys [model_id], :as activity} activities]
+      (let [model (if (and (= (:model activity) "card")
+                           (existing-dataset? (:model_id activity)))
+                    "dataset"
+                    (:model activity))]
+        (cond-> (assoc activity
+                       :model_exists (contains? (get existing-objects model) model_id)
+                       :model model)
+          (dashcard-activity? activity)
+          (update-in [:details :dashcards]
+                     (fn [dashcards]
+                       (for [dashcard dashcards]
+                         (assoc dashcard :exists
+                                (or (existing-dataset? (:card_id dashcard))
+                                    (contains? (get existing-objects "card")
+                                               (:card_id dashcard))))))))))))
 
 (defendpoint GET "/"
   "Get recent activity."
@@ -66,14 +86,25 @@
                            (hydrate :user :table :database)
                            add-model-exists-info)))
 
-(defn- view-log-entry->matching-object [{:keys [model model_id]}]
-  (when (contains? #{"card" "dashboard" "table"} model)
-    (db/select-one
-        (case model
-          "card"      [Card      :id :name :collection_id :description :display :dataset_query]
-          "dashboard" [Dashboard :id :name :collection_id :description]
-          "table"     [Table     :id :name :db_id :display_name])
-        :id model_id)))
+(defn- models-for-views
+  "Returns a map of {model {id instance}} for activity views suitable for looking up by model and id to get a model."
+  [views]
+  (letfn [(select-items! [model ids]
+            (when (seq ids)
+              (db/select
+               (case model
+                 "card"      [Card      :id :name :collection_id :description :display
+                                        :dataset_query :dataset]
+                 "dashboard" [Dashboard :id :name :collection_id :description]
+                 "table"     [Table     :id :name :db_id :display_name])
+               {:where [:in :id ids]})))
+          (by-id [models] (m/index-by :id models))]
+    (into {} (map (fn [[model models]]
+                    [model (->> models
+                                (map :model_id)
+                                (select-items! model)
+                                (by-id))]))
+          (group-by :model views))))
 
 (defendpoint GET "/recent_views"
   "Get the list of 10 things the current user has been viewing most recently."
@@ -81,15 +112,19 @@
   ;; expected output of the query is a single row per unique model viewed by the current user
   ;; including a `:max_ts` which has the most recent view timestamp of the item and `:cnt` which has total views
   ;; and we order the results by most recently viewed then hydrate the basic details of the model
-  (for [view-log (db/select [ViewLog :user_id :model :model_id [:%count.* :cnt] [:%max.timestamp :max_ts]]
-                   :user_id *current-user-id*
-                   {:group-by [:user_id :model :model_id]
-                    :order-by [[:max_ts :desc]]
-                    :limit    5})
-        :let     [model-object (view-log-entry->matching-object view-log)]
-        :when    (and model-object
-                      (mi/can-read? model-object))]
-    (assoc view-log :model_object (dissoc model-object :dataset_query))))
-
+  (let [views (db/select [ViewLog :user_id :model :model_id
+                          [:%count.* :cnt] [:%max.timestamp :max_ts]]
+                         {:group-by [:user_id :model :model_id]
+                          :where    [:and
+                                     [:= :user_id *current-user-id*]
+                                     [:in :model #{"card" "dashboard" "table"}]]
+                          :order-by [[:max_ts :desc]]
+                          :limit    5})
+        model->id->items (models-for-views views)]
+    (for [{:keys [model model_id] :as view-log} views
+          :let [model-object (get-in model->id->items [model model_id])]
+          :when (and model-object (mi/can-read? model-object))]
+      (cond-> (assoc view-log :model_object (dissoc model-object :dataset_query))
+        (:dataset model-object) (assoc :model "dataset")))))
 
 (define-routes)
diff --git a/test/metabase/api/activity_test.clj b/test/metabase/api/activity_test.clj
index 87e3a41cfb0..b7bb26f358b 100644
--- a/test/metabase/api/activity_test.clj
+++ b/test/metabase/api/activity_test.clj
@@ -108,11 +108,12 @@
                                     :description "rand-name"
                                     :creator_id  (mt/user->id :crowberto)}]
                   Table     [table1 {:name        "rand-name"}]
-                  Card      [card2 {:name                   "rand-name"
-                                    :creator_id             (mt/user->id :crowberto)
-                                    :display                "table"
-                                    :visualization_settings {}}]]
-    (create-view! (mt/user->id :crowberto) "card"      (:id card2))
+                  Card      [dataset {:name                   "rand-name"
+                                      :dataset                true
+                                      :creator_id             (mt/user->id :crowberto)
+                                      :display                "table"
+                                      :visualization_settings {}}]]
+    (create-view! (mt/user->id :crowberto) "card"      (:id dataset))
     (create-view! (mt/user->id :crowberto) "dashboard" (:id dash1))
     (create-view! (mt/user->id :crowberto) "card"      (:id card1))
     (create-view! (mt/user->id :crowberto) "card"      36478)
@@ -133,6 +134,7 @@
              :model_object {:id            (:id card1)
                             :name          (:name card1)
                             :collection_id nil
+                            :dataset       false
                             :description   (:description card1)
                             :display       (name (:display card1))}}
             {:cnt          1
@@ -145,13 +147,14 @@
                             :description   (:description dash1)}}
             {:cnt          1
              :user_id      (mt/user->id :crowberto)
-             :model        "card"
-             :model_id     (:id card2)
-             :model_object {:id            (:id card2)
-                            :name          (:name card2)
+             :model        "dataset"
+             :model_id     (:id dataset)
+             :model_object {:id            (:id dataset)
+                            :name          (:name dataset)
+                            :dataset       true
                             :collection_id nil
-                            :description   (:description card2)
-                            :display       (name (:display card2))}}]
+                            :description   (:description dataset)
+                            :display       (name (:display dataset))}}]
            (for [recent-view (mt/user-http-request :crowberto :get 200 "activity/recent_views")]
              (dissoc recent-view :max_ts))))))
 
@@ -181,47 +184,48 @@
 
 (deftest referenced-objects->existing-objects-test
   (mt/with-temp Dashboard [{dashboard-id :id}]
-    (is (= {"dashboard" #{dashboard-id}, "card" nil}
+    (is (= {"dashboard" #{dashboard-id}}
            (#'activity-api/referenced-objects->existing-objects {"dashboard" #{dashboard-id 0}
                                                                  "card"      #{0}})))))
 (deftest add-model-exists-info-test
   (mt/with-temp* [Dashboard [{dashboard-id :id}]
-                  Card      [{card-id :id}]]
+                  Card      [{card-id :id}]
+                  Card      [{dataset-id :id} {:dataset true}]]
     (is (= [{:model "dashboard", :model_id dashboard-id, :model_exists true}
             {:model "card", :model_id 0, :model_exists false}
+            {:model "dataset", :model_id dataset-id, :model_exists true}
             {:model        "dashboard"
              :model_id     0
              :model_exists false
              :topic        :dashboard-remove-cards
              :details      {:dashcards [{:card_id card-id, :exists true}
-                                        {:card_id 0, :exists false}]}}]
+                                        {:card_id 0, :exists false}
+                                        {:card_id dataset-id, :exists true}]}}]
            (#'activity-api/add-model-exists-info [{:model "dashboard", :model_id dashboard-id}
                                                   {:model "card", :model_id 0}
+                                                  {:model "card", :model_id dataset-id}
                                                   {:model    "dashboard"
                                                    :model_id 0
                                                    :topic    :dashboard-remove-cards
                                                    :details  {:dashcards [{:card_id card-id}
-                                                                          {:card_id 0}]}}])))))
+                                                                          {:card_id 0}
+                                                                          {:card_id dataset-id}]}}])))))
 
 (deftest activity-visibility-test
-  ;; clear out all existing Activity entries
-  ;;
-  ;; TODO -- this is a bad pattern -- test shouldn't wipe out a bunch of other stuff like this. Just fetch all the
-  ;; activities and look for the presence of this specific one.
-  (db/delete! Activity)
   (mt/with-temp Activity [activity {:topic     "user-joined"
                                     :details   {}
-                                    :timestamp #t "2019-02-15T11:55:00.000Z"}]
-    (letfn [(user-can-see-user-joined-activity? [user]
-              (seq (mt/user-http-request user :get 200 "activity")))]
+                                    :timestamp (java.time.ZonedDateTime/now)}]
+    (letfn [(activity-topics [user]
+              (into #{} (map :topic)
+                    (mt/user-http-request user :get 200 "activity")))]
       (testing "Only admins should get to see user-joined activities"
         (testing "admin should see `:user-joined` activities"
           (testing "Sanity check: admin should be able to read the activity"
             (mt/with-test-user :crowberto
               (is (models/can-read? activity))))
-          (is (user-can-see-user-joined-activity? :crowberto)))
+          (is (contains? (activity-topics :crowberto) "user-joined")))
         (testing "non-admin should *not* see `:user-joined` activities"
           (testing "Sanity check: non-admin should *not* be able to read the activity"
             (mt/with-test-user :rasta
               (is (not (models/can-read? activity)))))
-          (is (not (user-can-see-user-joined-activity? :rasta))))))))
+          (is (not (contains? (activity-topics :rasta) "user-joined"))))))))
-- 
GitLab