(ns metabase.api.activity (:require [clojure.string :as str] [compojure.core :refer [GET]] [medley.core :as m] [metabase.api.common :as api :refer [*current-user-id*]] [metabase.db.query :as mdb.query] [metabase.models.card :refer [Card]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.interface :as mi] [metabase.models.query-execution :refer [QueryExecution]] [metabase.models.recent-views :as recent-views] [metabase.models.table :refer [Table]] [metabase.util.honey-sql-2 :as h2x] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) | |
(defn- models-query [model ids] (t2/select (case model "card" [Card :id :name :collection_id :description :display :dataset_query :type :archived :collection.authority_level [:collection.name :collection_name] [:dashboard.name :dashboard_name] :dashboard_id] "dashboard" [Dashboard :id :name :collection_id :description :archived :collection.authority_level [:collection.name :collection_name]] "table" [Table :id :name :db_id :active :display_name [:metabase_database.initial_sync_status :initial-sync-status] [:visibility_type :visibility_type] [:metabase_database.name :database-name]]) (let [model-symb (symbol (str/capitalize model)) self-qualify #(mdb.query/qualify model-symb %)] {:where [:in (self-qualify :id) ids] :left-join (case model "table" [:metabase_database [:= :metabase_database.id (self-qualify :db_id)]] "card" [:collection [:= :collection.id (self-qualify :collection_id)] [:report_dashboard :dashboard] [:= :dashboard.id (self-qualify :dashboard_id)]] "dashboard" [:collection [:= :collection.id (self-qualify :collection_id)]])}))) | |
Returns a map of {model {id instance}} for activity views suitable for looking up by model and id to get a model. | (defn- models-for-views [views] (let [grouped (group-by :model views) ;; We perform selects for each model type separately, but then bring them back into a flat list to hydrate ;; with moderation_reviews data all at once. items (mapcat (fn [[model views']] (when (seq views') (->> (models-query model (map :model_id views')) (mapv #(assoc % :model model))))) grouped) items (->> (t2/hydrate items :moderation_reviews) (map (fn [{:keys [moderation_reviews] :as item}] (let [status (some #(when (:most_recent %) (:status %)) moderation_reviews)] (assoc item :moderated_status status)))))] ;; Now group the flat list of items into a map. (update-vals (group-by :model items) #(m/index-by :id %)))) |
Query implementation for The expected output of the query is a single row per unique model viewed by the current user including a Viewing a Dashboard will add entries to the view log for all cards on that dashboard so all card views are instead derived
from the query_execution table. The query context is always a | (defn- views-and-runs [views-limit card-runs-limit] (let [dashboard-and-table-views (t2/select [:model/RecentViews [[:min :recent_views.user_id] :user_id] :model :model_id [[:max [:coalesce :d.view_count :t.view_count]] :cnt] [:%max.timestamp :max_ts]] {:group-by [:model :model_id] :where [:and [:= :context "view"] [:in :model #{"dashboard" "table"}]] :order-by [[:max_ts :desc] [:model :desc]] :limit views-limit :left-join [[:report_dashboard :d] [:and [:= :model "dashboard"] [:= :d.id :model_id]] [:metabase_table :t] [:and [:= :model "table"] [:= :t.id :model_id]]]}) card-runs (->> (t2/select [QueryExecution [:%min.executor_id :user_id] [(mdb.query/qualify QueryExecution :card_id) :model_id] [:%count.* :cnt] [:%max.started_at :max_ts]] {:group-by [(mdb.query/qualify QueryExecution :card_id) :context] :where [:and [:= :context (h2x/literal :question)]] :order-by [[:max_ts :desc]] :limit card-runs-limit}) (mapv #(-> % (dissoc :row_count) (assoc :model "card"))))] (->> (into card-runs dashboard-and-table-views) (sort-by :max_ts #(compare %2 %1))))) |
(def ^:private views-limit 8) (def ^:private card-runs-limit 8) | |
/recent_views | (api/defendpoint ^:deprecated GET "Get a list of 100 models (cards, models, tables, dashboards, and collections) that the current user has been viewing most recently. Return a maximum of 20 model of each, if they've looked at at least 20." [] {:recent_views (:recents (recent-views/get-recents *current-user-id* [:views]))}) |
/recents | (api/defendpoint GET "Get a list of recent items the current user has been viewing most recently under the `:recents` key. Allows for filtering by context: views or selections" [:as {{:keys [context]} :params}] {context (ms/QueryVectorOf [:enum :selections :views])} (when-not (seq context) (throw (ex-info "context is required." {}))) (recent-views/get-recents *current-user-id* context)) |
/recents | (api/defendpoint POST "Adds a model to the list of recently selected items." [:as {{:keys [model model_id context]} :body}] {model (into [:enum] recent-views/rv-models) model_id ms/PositiveInt context [:enum :selection]} (let [model-id model_id model-type (recent-views/rv-model->model model)] (when-not (t2/exists? model-type :id model-id) (throw (ex-info "Model not found" {:model model :model_id model-id}))) (api/read-check (t2/select-one model-type :id model-id)) (recent-views/update-users-recent-views! *current-user-id* model-type model-id context))) |
/mostrecentlyviewed_dashboard | (api/defendpoint GET "Get the most recently viewed dashboard for the current user. Returns a 204 if the user has not viewed any dashboards in the last 24 hours." [] (if-let [dashboard-id (recent-views/most-recently-viewed-dashboard-id api/*current-user-id*)] (let [dashboard (-> (t2/select-one Dashboard :id dashboard-id) api/check-404 (t2/hydrate [:collection :is_personal]))] (if (mi/can-read? dashboard) dashboard api/generic-204-no-content)) api/generic-204-no-content)) |
Returns true if the item belongs to an official collection. False otherwise. Assumes that | (defn- official? [{:keys [authority_level]}] (boolean (when authority_level (#{"official"} authority_level)))) |
Return true if the item is verified, false otherwise. Assumes that | (defn- verified? [{:keys [moderated_status]}] (= moderated_status "verified")) |
(defn- score-items [items] (when (seq items) (let [n-items (count items) max-count (apply max (map :cnt items))] (map-indexed (fn [recency-pos {:keys [cnt model_object] :as item}] (let [verified-wt 1 official-wt 1 recency-wt 2 views-wt 4 scores (remove nil? [;; cards and dashboards? can be 'verified' in enterprise (when (verified? model_object) verified-wt) ;; items may exist in an 'official' collection in enterprise (when (official? model_object) official-wt) ;; most recent item = 1 * recency-wt, least recent item of 10 items = 1/10 * recency-wt (when-not (zero? n-items) (* (/ (- n-items recency-pos) n-items) recency-wt)) ;; item with highest count = 1 * views-wt, lowest = item-view-count / max-view-count * views-wt ;; NOTE: the query implementation `views-and-runs` has an order-by clause using most recent timestamp ;; this has an effect on the outcomes. Consider an item with a massively high viewcount but a last view by the user ;; a long time ago. This may not even make it into the firs 10 items from the query, even though it might be worth showing (when-not (zero? max-count) (* (/ cnt max-count) views-wt))])] (assoc item :score (double (reduce + scores))))) items)))) | |
(def ^:private model->precedence {"dashboard" 0 "card" 1 "dataset" 2 "metric" 3 "table" 4 "collection" 5}) | |
(mu/defn get-popular-items-model-and-id :- [:sequential recent-views/Item] "Returns the 'popular' items for the current user. This is a list of 5 items that the user has viewed recently. The items are sorted by a weighted score that takes into account the total count of views, the recency of the view, whether the item is 'official' or 'verified', and more." [] ;; we do a weighted score which incorporates: ;; - total count -> higher = higher score ;; - recently viewed -> more recent = higher score ;; - official/verified -> yes = higher score (let [views (views-and-runs views-limit card-runs-limit) model->id->items (models-for-views views) filtered-views (for [{:keys [model model_id] :as view-log} views :let [model-object (-> (get-in model->id->items [model model_id]) (dissoc :dataset_query))] :when (and model-object (mi/can-read? model-object) ;; hidden tables, archived cards/dashboards (not (or (:archived model-object) (= (:visibility_type model-object) :hidden)))) :let [is-dataset? (= (keyword (:type model-object)) :model) is-metric? (= (keyword (:type model-object)) :metric)]] (cond-> (assoc view-log :model_object model-object) is-dataset? (assoc :model "dataset") is-metric? (assoc :model "metric"))) scored-views (score-items filtered-views)] (->> scored-views (sort-by ;; sort by model first, and then score when they are the same model (juxt #(-> % :model model->precedence) #(- (% :score)))) (take 5) (map #(-> % (assoc :timestamp (:max_ts % )) recent-views/fill-recent-view-info))))) | |
/popular_items | (api/defendpoint GET "Get the list of 5 popular things on the instance. Query takes 8 and limits to 5 so that if it finds anything archived, deleted, etc it can usually still get 5. " [] {:popular_items (get-popular-items-model-and-id)}) |
(api/define-routes) | |