From a1a6652133f39dab02bdf9ae04cc12b278a41a3e Mon Sep 17 00:00:00 2001 From: adam-james <21064735+adam-james-v@users.noreply.github.com> Date: Fri, 8 Apr 2022 12:10:14 -0700 Subject: [PATCH] Start page be simplified popular items (#21553) * Add `:is_installer` key to users returned from api/user/current * Add `:has_question_and_dashboard` Key to current users via hydration This is also part of the starting page project, it's a key that helps the frontend serve more specific data to the user. * Moved `permissions-set` function up so I can use it in hydration * Adjust recents query and first pass at popular items query Recents: Before, the recent query would look at the ViewLog, which records a view on cards even when it was only 'viewed' via a dashboard. For dashboards and tables, we don't need to make a change. For cards, we now query the QueryExecution table and use started_at as a proxy for the last viewed timestamp, and can then only grab cards with an execution context of 'question'. Popular: We now want to have a notion of 'popular' items as well, which can look different than 'recents'. This is the first attempt at a reasonable query with scoring system. The score takes into account: - recency of view - count of views total - if the card/dashboard is 'verified' (enterprise) - if the cad/dashboard is inside an 'official' Collection (enterprise) The popular score currently uses only the *current-user-id* to fetch items, so popularity is (in this commit, at least) a per-user concept. * Fixed mistake and renamed endpoint * Typo fix * Clean up QueryExecution in tests * Indent, naming, and some simple defs * try to reduce db calls for :has_question_and_dashboard I've moved the fn out of the hydration system to guarantee that it only runs for a single user, and is only used in `GET /api/user/current` (and not accidentally used in other spots via hydration mechanism). Still don't know how to check Card and Dashboard tables more efficiently * ViewLog and QueryExecution have different timestamps. Can't compare Pushing this for some review, but might want to build a proper way to compare, so that recent cards and recent dashboards/tables are sorted equally * fix namespace sorting * Fix the endpoint name * Sorting mixed date time formats 'works' but not ideal This formats the timestamps into strings and uses those for comparison. Not perfect. Pushing in case people are trying the branch * Use simpler db function * Let views and runs query work with one user or all users Popular_items are an all-users notion, but recent_views applies only to the current user. * Unify view_log.timestamp to query_execution.started_at type these used to both be `DATETIME` in the migration file. Then migration 168 bumped up the type of the view_log.timestamp column: ``` - changeSet: id: 168 author: camsaul comment: Added 0.36.0 changes: - modifyDataType: tableName: query_execution columnName: started_at newDataType: ${timestamp_type} ``` So these were no longer the same on h2 (and possibly mysql). But sorting viewlogs and query_executions would fail. ```clojure activity=> (->> (ViewLog) first :timestamp #_type) "0x6a33e42e" "2022-04-04T21:57:07.471"] activity=> (->> (ViewLog) first :timestamp type) java.time.LocalDateTime activity=> (->> (QueryExecution) first :started_at #_type) "0x7af249ac" "2022-04-04T21:57:07.625738Z"] activity=> (->> (QueryExecution) first :started_at type) java.time.OffsetDateTime ``` The LocalDateTime and OffsetDateTime were not comparable. So make them identical types again. * Bookmarked results should not show up in recents/ popular. This is done in a db query to still try fetch enough items from the db to present to the user. Filtering bookmarked items later may result in an empty list. It's possible that the firt N results are all bookmarked, and then the frontend would have no items to show. Filtering bookmarked results out from the beginning increases the chances of a non-empty result. * :first_login populated with the earliest login timestamp. If there is a first login timestamp, that is used. If one does not exist, we assume it's the first time logging in and use an OffsetDateTime (now). This is possible since the login_history is done off thread, so on a real first login, we might hit the db before the login is logged, resulting in an empty list returned. On subsequent checks, we should see a proper timestamp from the db. Since this is used to check if a user is 'new' (within 7 days of first logging in), the accuracy of the timestamp matters on the order of days, not milliseconds, so this should be ok. * Passing test * Popular_items test Tweak the create-views! function to more consistently order items. And creates views for dashboards/tables (ViewLog) and cards (QueryExecution) in a unified way, meaning we can reliably order views, and write tests more easily. Note that the popular_items test may have to change if the scoring math changes. * Fix e2e test * Fix nit and bug - forgot to remove '0' from the and clause, so we didn't get the expected boolean - popular_items not WIP * another nit * Fix popular items on the frontend Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com> Co-authored-by: dan sutton <dan@dpsutton.com> --- .../src/metabase/entities/popular-items.js | 1 - .../components/HomeContent/HomeContent.tsx | 16 +- .../HomeContent/HomeContent.unit.spec.tsx | 4 + .../containers/HomeContent/HomeContent.tsx | 2 + .../HomePopularSection/HomePopularSection.tsx | 2 +- .../onboarding/home/homepage.cy.spec.js | 14 +- .../search/recently-viewed.cy.spec.js | 4 +- .../smoketest/admin_setup.cy.spec.js | 9 +- resources/migrations/000_migrations.yaml | 10 + src/metabase/api/activity.clj | 171 +++++++++++++++--- src/metabase/api/user.clj | 28 ++- src/metabase/models/user.clj | 45 +++-- test/metabase/api/activity_test.clj | 114 ++++++------ test/metabase/api/user_test.clj | 5 +- 14 files changed, 311 insertions(+), 114 deletions(-) diff --git a/frontend/src/metabase/entities/popular-items.js b/frontend/src/metabase/entities/popular-items.js index a6b5805c69a..6e1a5d70dd0 100644 --- a/frontend/src/metabase/entities/popular-items.js +++ b/frontend/src/metabase/entities/popular-items.js @@ -21,7 +21,6 @@ const PopularItems = createEntity({ nameOne: "popularItem", path: "/api/activity/popular_items", schema: PopularItemSchema, - wrapEntity(item, dispatch = null) { const entity = getEntity(item); return entity.wrapEntity(item, dispatch); diff --git a/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.tsx b/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.tsx index 41139c5e97c..255304d1582 100644 --- a/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.tsx +++ b/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.tsx @@ -2,7 +2,7 @@ import React from "react"; import moment from "moment"; import { parseTimestamp } from "metabase/lib/time"; import { isSyncCompleted } from "metabase/lib/syncing"; -import { Database, RecentItem, User } from "metabase-types/api"; +import { Database, PopularItem, RecentItem, User } from "metabase-types/api"; import HomePopularSection from "../../containers/HomePopularSection"; import HomeRecentSection from "../../containers/HomeRecentSection"; import HomeXraySection from "../../containers/HomeXraySection"; @@ -11,6 +11,7 @@ export interface HomeContentProps { user: User; databases: Database[]; recentItems: RecentItem[]; + popularItems: PopularItem[]; } const HomeContent = (props: HomeContentProps): JSX.Element | null => { @@ -29,23 +30,28 @@ const HomeContent = (props: HomeContentProps): JSX.Element | null => { return null; }; -const isPopularSection = ({ user, recentItems }: HomeContentProps) => { +const isPopularSection = ({ + user, + recentItems, + popularItems, +}: HomeContentProps): boolean => { return ( !user.is_installer && user.has_question_and_dashboard && + popularItems.length > 0 && (isWithinWeek(user.first_login) || !recentItems.length) ); }; -const isRecentSection = ({ recentItems }: HomeContentProps) => { +const isRecentSection = ({ recentItems }: HomeContentProps): boolean => { return recentItems.length > 0; }; -const isXraySection = ({ databases }: HomeContentProps) => { +const isXraySection = ({ databases }: HomeContentProps): boolean => { return databases.some(isSyncCompleted); }; -const isWithinWeek = (timestamp: string) => { +const isWithinWeek = (timestamp: string): boolean => { const date = parseTimestamp(timestamp); const weekAgo = moment().subtract(1, "week"); return date.isAfter(weekAgo); diff --git a/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.unit.spec.tsx b/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.unit.spec.tsx index 4856f0426c7..f0dc05f60d5 100644 --- a/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.unit.spec.tsx +++ b/frontend/src/metabase/home/homepage/components/HomeContent/HomeContent.unit.spec.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { createMockDatabase, + createMockPopularItem, createMockRecentItem, createMockUser, } from "metabase-types/api/mocks"; @@ -35,6 +36,7 @@ describe("HomeContent", () => { }), databases: [createMockDatabase()], recentItems: [createMockRecentItem()], + popularItems: [createMockPopularItem()], }); render(<HomeContent {...props} />); @@ -51,6 +53,7 @@ describe("HomeContent", () => { }), databases: [createMockDatabase()], recentItems: [], + popularItems: [createMockPopularItem()], }); render(<HomeContent {...props} />); @@ -111,5 +114,6 @@ const getProps = (opts?: Partial<HomeContentProps>): HomeContentProps => ({ user: createMockUser(), databases: [], recentItems: [], + popularItems: [], ...opts, }); diff --git a/frontend/src/metabase/home/homepage/containers/HomeContent/HomeContent.tsx b/frontend/src/metabase/home/homepage/containers/HomeContent/HomeContent.tsx index 3e8119cfb42..413e0d4d411 100644 --- a/frontend/src/metabase/home/homepage/containers/HomeContent/HomeContent.tsx +++ b/frontend/src/metabase/home/homepage/containers/HomeContent/HomeContent.tsx @@ -2,6 +2,7 @@ import { connect } from "react-redux"; import _ from "underscore"; import Databases from "metabase/entities/databases"; import RecentItems from "metabase/entities/recent-items"; +import PopularItems from "metabase/entities/popular-items"; import { getUser } from "metabase/selectors/user"; import { State } from "metabase-types/store"; import HomeContent from "../../components/HomeContent"; @@ -13,5 +14,6 @@ const mapStateToProps = (state: State) => ({ export default _.compose( Databases.loadList(), RecentItems.loadList({ reload: true }), + PopularItems.loadList({ reload: true }), connect(mapStateToProps), )(HomeContent); diff --git a/frontend/src/metabase/home/homepage/containers/HomePopularSection/HomePopularSection.tsx b/frontend/src/metabase/home/homepage/containers/HomePopularSection/HomePopularSection.tsx index 636ced3e075..cd38a635049 100644 --- a/frontend/src/metabase/home/homepage/containers/HomePopularSection/HomePopularSection.tsx +++ b/frontend/src/metabase/home/homepage/containers/HomePopularSection/HomePopularSection.tsx @@ -1,4 +1,4 @@ import PopularItems from "metabase/entities/popular-items"; import HomePopularSection from "../../components/HomePopularSection"; -export default PopularItems.loadList({ reload: true })(HomePopularSection); +export default PopularItems.loadList()(HomePopularSection); diff --git a/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js b/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js index 97b414cca4b..f91a9990074 100644 --- a/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js +++ b/frontend/test/metabase/scenarios/onboarding/home/homepage.cy.spec.js @@ -58,8 +58,8 @@ describe("scenarios > home > homepage", () => { it("should display recent items", () => { restore("default"); - cy.signInAsAdmin(); + cy.signInAsAdmin(); visitDashboard(1); cy.findByText("Orders in a dashboard"); @@ -72,14 +72,18 @@ describe("scenarios > home > homepage", () => { cy.findByText("Orders"); }); - it.skip("should display popular items for a new user", () => { + it("should display popular items for a new user", () => { restore("default"); - cy.signInAsNormalUser(); + cy.signInAsAdmin(); + visitDashboard(1); + cy.findByText("Orders in a dashboard"); + cy.signOut(); + + cy.signInAsNormalUser(); cy.visit("/"); cy.wait("@getPopularItems"); - cy.findByText("Here are some popular items"); - + cy.findByText("Here are some popular dashboards"); cy.findByText("Orders in a dashboard").click(); cy.wait("@getDashboard"); cy.findByText("Orders"); diff --git a/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js b/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js index b78e12d7eda..75d3705b93e 100644 --- a/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js +++ b/frontend/test/metabase/scenarios/onboarding/search/recently-viewed.cy.spec.js @@ -34,13 +34,13 @@ describe(`search > recently viewed`, () => { cy.findByPlaceholderText("Search…").click(); cy.findByTestId("loading-spinner").should("not.exist"); - assertRecentlyViewedItem(0, "Orders", "Question", "/question/1-orders"); assertRecentlyViewedItem( - 1, + 0, "Orders in a dashboard", "Dashboard", "/dashboard/1-orders-in-a-dashboard", ); + assertRecentlyViewedItem(1, "Orders", "Question", "/question/1-orders"); assertRecentlyViewedItem(2, "People", "Table", "/question#?db=1&table=3"); }); }); diff --git a/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js b/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js index 099e0517f0d..6380ba8765e 100644 --- a/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js +++ b/frontend/test/metabase/scenarios/smoketest/admin_setup.cy.spec.js @@ -525,8 +525,8 @@ describe("smoketest > admin_setup", () => { // Check table names and visibility - cy.contains("People"); - cy.contains("Test"); + cy.findByText("Here are some popular items"); + cy.findByText("Test Table"); cy.findByText("Reviews").should("not.exist"); // Check question names and descriptions @@ -596,9 +596,7 @@ describe("smoketest > admin_setup", () => { cy.signIn("nocollection"); cy.visit("/"); - cy.wait(2000).findByText( - "Try out these sample x-rays to see what Metabase can do.", - ); + cy.wait(2000).findByText("Here are some popular tables"); cy.contains("Test Table"); cy.contains("Reviews").should("not.exist"); @@ -808,7 +806,6 @@ describe("smoketest > admin_setup", () => { // No collection user sees Test Table and People table cy.contains("Test Table"); - cy.contains("People"); cy.contains("Reviews").should("not.exist"); }); diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 065beb88e9c..5a6497354b6 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -11330,6 +11330,16 @@ databaseChangeLog: AND p.object = '/general/subscription/' WHERE p.object IS NULL; + - changeSet: + id: v43.00-049 + author: dpsutton + comment: Added 0.43.0 - Unify datatype with query_execution.started_at so comparable (see 168). + changes: + - modifyDataType: + tableName: view_log + columnName: timestamp + newDataType: ${timestamp_type} + - changeSet: id: v43.00-051 author: adam-james diff --git a/src/metabase/api/activity.clj b/src/metabase/api/activity.clj index cb97160746c..a6806f51a71 100644 --- a/src/metabase/api/activity.clj +++ b/src/metabase/api/activity.clj @@ -1,14 +1,19 @@ (ns metabase.api.activity (:require [clojure.set :as set] + [clojure.string :as str] [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.bookmark :refer [CardBookmark DashboardBookmark]] [metabase.models.card :refer [Card]] + [metabase.models.collection :refer [Collection]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.interface :as mi] + [metabase.models.query-execution :refer [QueryExecution]] [metabase.models.table :refer [Table]] [metabase.models.view-log :refer [ViewLog]] + [metabase.util.honeysql-extensions :as hx] [toucan.db :as db] [toucan.hydrate :refer [hydrate]])) @@ -86,19 +91,33 @@ (hydrate :user :table :database) add-model-exists-info))) +(defn- models-query + [model ids] + (db/select + (case model + "card" [Card + :id :name :collection_id :description :display + :dataset_query :dataset :archived + (db/qualify Collection :authority_level)] + "dashboard" [Dashboard + :id :name :collection_id :description + :archived + (db/qualify Collection :authority_level)] + "table" [Table + :id :name :db_id + :display_name :initial_sync_status + :visibility_type]) + (cond-> {:where [:in (db/qualify (symbol (str/capitalize model)) :id) ids]} + (not= model "table") + (merge {:left-join [Collection [:= (db/qualify Collection :id) :collection_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 :archived] - "dashboard" [Dashboard :id :name :collection_id :description :archived] - "table" [Table :id :name :db_id :display_name :initial_sync_status - :visibility_type]) - {:where [:in :id ids]}))) + (-> (models-query model ids) + (hydrate :moderation_reviews)))) (by-id [models] (m/index-by :id models))] (into {} (map (fn [[model models]] [model (->> models @@ -107,31 +126,137 @@ (by-id))])) (group-by :model views)))) +(defn- views-and-runs + "Common query implementation for `recent_views` and `popular_items`. Tables and Dashboards have a query limit of `views-limit`. + Cards have a query limit of `card-runs-limit`. + + The 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. We order the results by most recently + viewed then hydrate the basic details of the model. Bookmarked cards and dashboards are *not* included in the result. + + 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 `:question`. The results are normalized and concatenated to the + query results for dashboard and table views." + [views-limit card-runs-limit all-users?] + (let [dashboard-and-table-views (db/select [ViewLog :user_id :model :model_id + [:%count.* :cnt] [:%max.timestamp :max_ts]] + {:group-by [(db/qualify ViewLog :user_id) :model :model_id] + :where [:and + (when-not all-users? [:= (db/qualify ViewLog :user_id) *current-user-id*]) + [:in :model #{"dashboard" "table"}] + [:= :bm.id nil]] + :order-by [[:max_ts :desc]] + :limit views-limit + :left-join [[DashboardBookmark :bm] + [:and + [:not [:= :model "table"]] + [:= :bm.user_id *current-user-id*] + [:= :model_id :bm.dashboard_id]]]}) + card-runs (->> (db/select [QueryExecution [:executor_id :user_id] [(db/qualify QueryExecution :card_id) :model_id] + [:%count.* :cnt] [:%max.started_at :max_ts]] + {:group-by [:executor_id (db/qualify QueryExecution :card_id) :context] + :where [:and + (when-not all-users? [:= :executor_id *current-user-id*]) + [:= :context (hx/literal :question)] + [:= :bm.id nil]] + :order-by [[:max_ts :desc]] + :limit card-runs-limit + :left-join [[CardBookmark :bm] + [:and + [:= :bm.user_id *current-user-id*] + [:= (db/qualify QueryExecution :card_id) :bm.card_id]]]}) + (map #(dissoc % :row_count)) + (map #(assoc % :model "card")))] + (->> (concat card-runs dashboard-and-table-views) + (sort-by :max_ts) + reverse))) + +(def ^:private views-limit 8) +(def ^:private card-runs-limit 8) + (defendpoint GET "/recent_views" - "Get the list of 5 things the current user has been viewing most recently. Query takes 8 and limits to 5 so that if it - finds anything archived, deleted, etc it can hopefully still get 5." + "Get the list of 5 things the current user has been viewing most recently." [] - ;; 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 - (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 8}) + (let [views (views-and-runs views-limit card-runs-limit false) 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])] + :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))))] - (cond-> (assoc view-log :model_object (dissoc model-object :dataset_query)) + (cond-> (assoc view-log :model_object model-object) (:dataset model-object) (assoc :model "dataset"))) (take 5)))) +(defn- official? + "Returns true if the item belongs to an official collection. False otherwise. Assumes that `:authority_level` exists + if the item can be placed in a collection." + [{:keys [authority_level]}] + (boolean + (when authority_level + (#{"official"} authority_level)))) + +(defn- verified? + "Return true if the item is verified, false otherwise. Assumes that `:moderation_reviews` is hydrated. + Assumes that moderation reviews are ordered so that the most recent is the first. This is the case + from the hydration function for moderation_reviews." + [{:keys [moderation_reviews]}] + (boolean + (when moderation_reviews + (-> moderation_reviews first :status #{"verified"})))) + +(defn- score-items + [items] + (when (seq items) + (let [n-items (count items) + max-count (apply max (map :cnt items))] + (for [[recency-pos {:keys [cnt model_object] :as item}] (zipmap (range) items)] + (let [verified-wt 1 + official-wt 1 + recency-wt 2 + views-wt 4 + scores [;; cards and dashboards? can be 'verified' in enterprise + (if (verified? model_object) verified-wt 0) + ;; items may exist in an 'official' collection in enterprise + (if (official? model_object) official-wt 0) + ;; most recent item = 1 * recency-wt, least recent item of 10 items = 1/10 * recency-wt + (* (/ (- 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 + (* (/ cnt max-count) views-wt)]] + (assoc item :score (double (reduce + scores)))))))) + +(defendpoint GET "/popular_items" + "Get the list of 5 popular things for the current user. Query takes 8 and limits to 5 so that if it + finds anything archived, deleted, etc it can hopefully still get 5." + [] + ;; we can 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 true) + 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))))] + (cond-> (assoc view-log :model_object model-object) + (:dataset model-object) (assoc :model "dataset"))) + scored-views (score-items filtered-views)] + (->> scored-views + (sort-by :score) + reverse + (take 5) + (map #(dissoc % :score))))) + (define-routes) diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index 1ebbc4cbd33..8d2863b5dfe 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -4,12 +4,14 @@ [clojure.string :as str] [compojure.core :refer [DELETE GET POST PUT]] [honeysql.helpers :as hh] + [java-time :as t] [metabase.analytics.snowplow :as snowplow] [metabase.api.common :as api] [metabase.email.messages :as email] [metabase.integrations.google :as google] [metabase.integrations.ldap :as ldap] [metabase.models.collection :as collection :refer [Collection]] + [metabase.models.login-history :refer [LoginHistory]] [metabase.models.permissions-group :as group] [metabase.models.user :as user :refer [User]] [metabase.plugins.classloader :as classloader] @@ -161,11 +163,35 @@ user ((resolve 'metabase-enterprise.advanced-permissions.common/with-advanced-permissions) user))) +(defn- add-has-question-and-dashboard + "True when the user has permissions for at least one un-archived question and one un-archived dashboard." + [user] + (let [coll-ids-filter (collection/visible-collection-ids->honeysql-filter-clause + :collection_id + (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*)) + perms-query {:where [:and + [:= :archived false] + coll-ids-filter]}] + (assoc user :has_question_and_dashboard (and (db/exists? 'Card (perms-query user)) + (db/exists? 'Dashboard (perms-query user)))))) + +(defn- add-first-login + "Adds `first_login` key to the `User` with a timestamp value." + [{:keys [user_id] :as user}] + (let [ts (or + (:timestamp (db/select-one [LoginHistory :timestamp] :user_id user_id + {:limit 1 + :order-by [[:timestamp :desc]]})) + (t/offset-date-time))] + (assoc user :first_login ts))) + (api/defendpoint GET "/current" "Fetch the current `User`." [] (-> (api/check-404 @api/*current-user*) - (hydrate :personal_collection_id :group_ids :has_invited_second_user) + (hydrate :personal_collection_id :group_ids :is_installer :has_invited_second_user) + add-has-question-and-dashboard + add-first-login maybe-add-general-permissions)) (api/defendpoint GET "/:id" diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index 09f475aad7a..70c513331dc 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -143,6 +143,25 @@ (when user-or-id (db/select-field :group_id PermissionsGroupMembership :user_id (u/the-id user-or-id)))) + +;;; -------------------------------------------------- Permissions --------------------------------------------------- + +(defn permissions-set + "Return a set of all permissions object paths that `user-or-id` has been granted access to. (2 DB Calls)" + [user-or-id] + (set (when-let [user-id (u/the-id user-or-id)] + (concat + ;; Current User always gets readwrite perms for their Personal Collection and for its descendants! (1 DB Call) + (map perms/collection-readwrite-path (collection/user->personal-collection-and-descendant-ids user-or-id)) + ;; include the other Perms entries for any Group this User is in (1 DB Call) + (map :object (db/query {:select [:p.object] + :from [[:permissions_group_membership :pgm]] + :join [[:permissions_group :pg] [:= :pgm.group_id :pg.id] + [:permissions :p] [:= :p.group_id :pg.id]] + :where [:= :pgm.user_id user-id]})))))) + +;;; --------------------------------------------------- Hydration ---------------------------------------------------- + (defn add-group-ids "Efficiently add PermissionsGroup `group_ids` to a collection of `users`." {:batched-hydrate :group_ids} @@ -165,6 +184,15 @@ (assoc user :has_invited_second_user (and (= (:id user) 1) (> user-count 1))))))) +(defn add-is-installer + "Adds the `is_installer` flag to a collection of `users`. This should be `true` for only the user who + underwent the initial app setup flow (with an ID of 1). This is used to modify the experience of the + starting page for users." + {:batched-hydrate :is_installer} + [users] + (when (seq users) + (for [user users] + (assoc user :is_installer (= (:id user) 1))))) ;;; --------------------------------------------------- Helper Fns --------------------------------------------------- @@ -277,20 +305,3 @@ (doseq [group-id to-add] (db/insert! PermissionsGroupMembership {:user_id user-id, :group_id group-id}))) true))) - - -;;; -------------------------------------------------- Permissions --------------------------------------------------- - -(defn permissions-set - "Return a set of all permissions object paths that `user-or-id` has been granted access to. (2 DB Calls)" - [user-or-id] - (set (when-let [user-id (u/the-id user-or-id)] - (concat - ;; Current User always gets readwrite perms for their Personal Collection and for its descendants! (1 DB Call) - (map perms/collection-readwrite-path (collection/user->personal-collection-and-descendant-ids user-or-id)) - ;; include the other Perms entries for any Group this User is in (1 DB Call) - (map :object (db/query {:select [:p.object] - :from [[:permissions_group_membership :pgm]] - :join [[:permissions_group :pg] [:= :pgm.group_id :pg.id] - [:permissions :p] [:= :p.group_id :pg.id]] - :where [:= :pgm.user_id user-id]})))))) diff --git a/test/metabase/api/activity_test.clj b/test/metabase/api/activity_test.clj index 62b056d48de..09081a6f0b7 100644 --- a/test/metabase/api/activity_test.clj +++ b/test/metabase/api/activity_test.clj @@ -7,8 +7,10 @@ [metabase.models.card :refer [Card]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.interface :as models] + [metabase.models.query-execution :refer [QueryExecution]] [metabase.models.table :refer [Table]] [metabase.models.view-log :refer [ViewLog]] + [metabase.query-processor.util :as qputil] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] [metabase.util :as u] @@ -87,14 +89,25 @@ ;; 4. we filter out entries where `:model_object` is nil (object doesn't exist) (defn- create-views! - "Insert views [user-id model model-id]. Reviews are entered a second apart with last review as most recent." + "Insert views [user-id model model-id]. Views are entered a second apart with last view as most recent." [views] - (let [views (map (fn [[user model model-id] hours-ago] - {:user_id user, :model model, :model_id model-id - :timestamp (t/plus (t/local-date-time) (t/seconds (- hours-ago)))}) - (reverse views) - (range))] - (db/insert-many! ViewLog views))) + (let [start-time (t/offset-date-time) + views (->> (map (fn [[user model model-id] seconds-ago] + (case model + "card" {:executor_id user :card_id model-id + :context :question + :hash (qputil/query-hash {}) + :running_time 1 + :result_rows 1 + :native false + :started_at (t/plus start-time (t/seconds (- seconds-ago)))} + {:user_id user, :model model, :model_id model-id + :timestamp (t/plus start-time (t/seconds (- seconds-ago)))})) + (reverse views) + (range)) + (group-by #(if (:card_id %) :card :other)))] + (db/insert-many! ViewLog (:other views)) + (db/insert-many! QueryExecution (:card views)))) (deftest recent-views-test (mt/with-temp* [Card [card1 {:name "rand-name" @@ -109,7 +122,7 @@ Dashboard [dash1 {:name "rand-name" :description "rand-name" :creator_id (mt/user->id :crowberto)}] - Table [table1 {:name "rand-name"}] + Table [table1 {:name "rand-name"}] Table [hidden-table {:name "hidden table" :visibility_type "hidden"}] Card [dataset {:name "rand-name" @@ -117,7 +130,7 @@ :creator_id (mt/user->id :crowberto) :display "table" :visualization_settings {}}]] - (mt/with-model-cleanup [ViewLog] + (mt/with-model-cleanup [ViewLog QueryExecution] (create-views! [[(mt/user->id :crowberto) "card" (:id dataset)] [(mt/user->id :crowberto) "dashboard" (:id dash1)] [(mt/user->id :crowberto) "card" (:id card1)] @@ -127,50 +140,49 @@ [(mt/user->id :crowberto) "card" (:id archived)] [(mt/user->id :crowberto) "table" (:id hidden-table)] [(mt/user->id :rasta) "card" (:id card1)]]) - (is (= [{:cnt 1, - :model "table", - :model_id (:id table1), - :model_object {:db_id (:db_id table1), - :id (:id table1), - :visibility_type nil - :name (:name table1) - :display_name (:display_name table1) - :initial_sync_status "incomplete"}, - :user_id (mt/user->id :crowberto)} - {:cnt 1 - :user_id (mt/user->id :crowberto) - :model "card" - :model_id (:id card1) - :model_object {:id (:id card1) - :name (:name card1) - :archived false - :collection_id nil - :dataset false - :description (:description card1) - :display (name (:display card1))}} - {:cnt 1 - :user_id (mt/user->id :crowberto) - :model "dashboard" - :model_id (:id dash1) - :model_object {:id (:id dash1) - :name (:name dash1) - :archived false - :collection_id nil - :description (:description dash1)}} - {:cnt 1 - :user_id (mt/user->id :crowberto) - :model "dataset" - :model_id (:id dataset) - :model_object {:id (:id dataset) - :name (:name dataset) - :archived false - :dataset true - :collection_id nil - :description (:description dataset) - :display (name (:display dataset))}}] + (is (= [["table" (:id table1)] + ["card" (:id card1)] + ["dashboard" (:id dash1)] + ["dataset" (:id dataset)]] (for [recent-view (mt/user-http-request :crowberto :get 200 "activity/recent_views")] - (dissoc recent-view :max_ts))))))) + ((juxt :model :model_id) recent-view))))))) +(deftest popular-items-test + (mt/with-temp* [Card [card1 {:name "rand-name" + :creator_id (mt/user->id :crowberto) + :display "table" + :visualization_settings {}}] + Card [archived {:name "archived-card" + :creator_id (mt/user->id :crowberto) + :display "table" + :archived true + :visualization_settings {}}] + Dashboard [dash1 {:name "rand-name" + :description "rand-name" + :creator_id (mt/user->id :crowberto)}] + Table [table1 {:name "rand-name"}] + Table [hidden-table {:name "hidden table" + :visibility_type "hidden"}] + Card [dataset {:name "rand-name" + :dataset true + :creator_id (mt/user->id :crowberto) + :display "table" + :visualization_settings {}}]] + (mt/with-model-cleanup [ViewLog QueryExecution] + (create-views! (concat + ;; one item with many views is considered more popular + (repeat 10 [(mt/user->id :rasta) "card" (:id dataset)]) + [[(mt/user->id :rasta) "dashboard" (:id dash1)] + [(mt/user->id :rasta) "card" (:id card1)] + [(mt/user->id :rasta) "table" (:id table1)] + [(mt/user->id :rasta) "card" (:id card1)]])) + (is (= [["dataset" (:id dataset)] + ["card" (:id card1)] + ["table" (:id table1)] + ["dashboard" (:id dash1)]] + ;; all views are from :rasta, but :crowberto can still see popular items + (for [popular-item (mt/user-http-request :crowberto :get 200 "activity/popular_items")] + ((juxt :model :model_id) popular-item))))))) ;;; activities->referenced-objects, referenced-objects->existing-objects, add-model-exists-info diff --git a/test/metabase/api/user_test.clj b/test/metabase/api/user_test.clj index a4ca247f92a..9a2be40661d 100644 --- a/test/metabase/api/user_test.clj +++ b/test/metabase/api/user_test.clj @@ -253,11 +253,12 @@ :common_name "Rasta Toucan" :group_ids [(u/the-id (group/all-users))] :personal_collection_id true + :is_installer (= 1 (mt/user->id :rasta)) :has_invited_second_user (= 1 (mt/user->id :rasta))}) - (dissoc :is_qbnewb :last_login)) + (dissoc :is_qbnewb :last_login :has_question_and_dashboard)) (-> (mt/user-http-request :rasta :get 200 "user/current") mt/boolean-ids-and-timestamps - (dissoc :is_qbnewb :last_login))))))) + (dissoc :is_qbnewb :first_login :last_login :has_question_and_dashboard))))))) (deftest get-user-test (testing "GET /api/user/:id" -- GitLab