diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index f2525ac10505a60471c8154bd2ee87ccb6d4ed14..02e878cd225704a3762441808d66203d1df4a413 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -19,6 +19,7 @@ [metabase.models.pulse :as pulse :refer [Pulse]] [metabase.models.query :as query] [metabase.models.query.permissions :as query-perms] + [metabase.models.revision.last-edit :as last-edit] [metabase.models.table :refer [Table]] [metabase.models.view-log :refer [ViewLog]] [metabase.public-settings :as public-settings] @@ -152,16 +153,22 @@ (case f :database (api/read-check Database model_id) :table (api/read-check Database (db/select-one-field :db_id Table, :id model_id)))) - (->> (cards-for-filter-option f model_id) - ;; filterv because we want make sure all the filtering is done while current user perms set is still bound - (filterv mi/can-read?)))) + (let [cards (filter mi/can-read? (cards-for-filter-option f model_id)) + last-edit-info (:card (last-edit/fetch-last-edited-info {:card-ids (map :id cards)}))] + (into [] + (map (fn [{:keys [id] :as card}] + (if-let [edit-info (get last-edit-info id)] + (assoc card :last-edit-info edit-info) + card))) + cards)))) (api/defendpoint GET "/:id" "Get `Card` with ID." [id] (u/prog1 (-> (Card id) (hydrate :creator :dashboard_count :can_write :collection) - api/read-check) + api/read-check + (last-edit/with-last-edit-info :card)) (events/publish-event! :card-read (assoc <> :actor_id api/*current-user-id*)))) ;;; -------------------------------------------------- Saving Cards -------------------------------------------------- @@ -211,7 +218,7 @@ (defn- save-new-card-async! "Save `card-data` as a new Card on a separate thread. Returns a channel to fetch the response; closing this channel will cancel the save." - [card-data] + [card-data user] (async.u/cancelable-thread (let [card (db/transaction ;; Adding a new card at `collection_position` could cause other cards in this @@ -221,7 +228,9 @@ (events/publish-event! :card-create card) ;; include same information returned by GET /api/card/:id since frontend replaces the Card it ;; currently has with returned one -- See #4283 - (hydrate card :creator :dashboard_count :can_write :collection)))) + (-> card + (hydrate :creator :dashboard_count :can_write :collection) + (assoc :last-edit-info (last-edit/edit-information-for-user user)))))) (defn- create-card-async! "Create a new Card asynchronously. Returns a channel for fetching the newly created Card, or an Exception if one was @@ -241,7 +250,7 @@ (a/close! result-metadata-chan) ;; now do the actual saving on a separate thread so we don't tie up our precious core.async thread. Pipe the ;; result into `out-chan`. - (async.u/promise-pipe (save-new-card-async! card-data) out-chan)) + (async.u/promise-pipe (save-new-card-async! card-data @api/*current-user*) out-chan)) (catch Throwable e (a/put! out-chan e) (a/close! out-chan)))) @@ -424,7 +433,9 @@ (publish-card-update! card archived) ;; include same information returned by GET /api/card/:id since frontend replaces the Card it currently ;; has with returned one -- See #4142 - (hydrate card :creator :dashboard_count :can_write :collection)))) + (-> card + (hydrate :creator :dashboard_count :can_write :collection) + (assoc :last-edit-info (last-edit/edit-information-for-user @api/*current-user*)))))) (api/defendpoint ^:returns-chan PUT "/:id" "Update a `Card`." diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index 2a4b940f3b2827469bcb22bf9382e27fedc611fb..b846c92cf454e81c931b05cb2bcb29d044a17290 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -18,6 +18,7 @@ [metabase.models.permissions :as perms] [metabase.models.pulse :as pulse :refer [Pulse]] [metabase.models.pulse-card :refer [PulseCard]] + [metabase.models.revision.last-edit :as last-edit] [metabase.util :as u] [metabase.util.schema :as su] [schema.core :as s] @@ -149,17 +150,30 @@ "Fetch a sequence of 'child' objects belonging to a Collection, filtered using `options`." [{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot {:keys [model collections-only?], :as options} :- CollectionChildrenOptions] - (->> (for [model-kw [:collection :card :dashboard :pulse :snippet] - ;; only fetch models that are specified by the `model` param; or everything if it's `nil` - :when (or (not model) (= model model-kw)) - :let [toucan-model (model-name->toucan-model model-kw) - allowed-namespaces (collection/allowed-namespaces toucan-model)] - :when (or (= model-kw :collection) - (contains? allowed-namespaces (keyword collection-namespace))) - item (fetch-collection-children model-kw collection (assoc options :collection-namespace collection-namespace))] - (assoc item :model model-kw)) - ;; sorting by name should be fine for now. - (sort-by (comp str/lower-case :name)))) + (let [item-groups (into {} + (for [model-kw [:collection :card :dashboard :pulse :snippet] + ;; only fetch models that are specified by the `model` param; or everything if it's `nil` + :when (or (not model) (= model model-kw)) + :let [toucan-model (model-name->toucan-model model-kw) + allowed-namespaces (collection/allowed-namespaces toucan-model)] + :when (or (= model-kw :collection) + (contains? allowed-namespaces (keyword collection-namespace)))] + [model-kw (fetch-collection-children model-kw collection (assoc options :collection-namespace collection-namespace))])) + last-edited (last-edit/fetch-last-edited-info + {:card-ids (->> item-groups :card (map :id)) + :dashboard-ids (->> item-groups :dashboard (map :id))})] + (sort-by (comp str/lower-case :name) ;; sorting by name should be fine for now. + (into [] + ;; items are grouped by model, needed for last-edit lookup. put model on each one, cat them, then + ;; plop edit information on them if present + (comp (map (fn [[model items]] + (map #(assoc % :model model) items))) + cat + (map (fn [{:keys [model id] :as item}] + (if-let [edit-info (get-in last-edited [model id])] + (assoc item :last-edit-info edit-info) + item)))) + item-groups)))) (s/defn ^:private collection-detail "Add a standard set of details to `collection`, including things like `effective_location`. diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 3460b98ebe3b4da5f9baf8453df985112402a2bc..d70ef16ce40fd5fb4caa31eda835b525ac4ebab2 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -18,6 +18,7 @@ [metabase.models.params.chain-filter :as chain-filter] [metabase.models.query :as query :refer [Query]] [metabase.models.revision :as revision] + [metabase.models.revision.last-edit :as last-edit] [metabase.query-processor.error-type :as qp.error-type] [metabase.query-processor.middleware.constraints :as constraints] [metabase.query-processor.util :as qp-util] @@ -59,7 +60,14 @@ * `archived` - Return Dashboards that have been archived. (By default, these are *excluded*.)" [f] {f (s/maybe (s/enum "all" "mine" "archived"))} - (dashboards-list f)) + (let [dashboards (dashboards-list f) + edit-infos (:dashboard (last-edit/fetch-last-edited-info {:dashboard-ids (map :id dashboards)}))] + (into [] + (map (fn [{:keys [id] :as dashboard}] + (if-let [edit-info (get edit-infos id)] + (assoc dashboard :last-edit-info edit-info) + dashboard))) + dashboards))) (api/defendpoint POST "/" @@ -78,14 +86,15 @@ :creator_id api/*current-user-id* :collection_id collection_id :collection_position collection_position}] - (db/transaction - ;; Adding a new dashboard at `collection_position` could cause other dashboards in this collection to change - ;; position, check that and fix up if needed - (api/maybe-reconcile-collection-position! dashboard-data) - ;; Ok, now save the Dashboard - (->> (db/insert! Dashboard dashboard-data) - ;; publish an event and return the newly created Dashboard - (events/publish-event! :dashboard-create))))) + (let [dash (db/transaction + ;; Adding a new dashboard at `collection_position` could cause other dashboards in this collection to change + ;; position, check that and fix up if needed + (api/maybe-reconcile-collection-position! dashboard-data) + ;; Ok, now save the Dashboard + (db/insert! Dashboard dashboard-data))] + ;; publish event after the txn so that lookup can succeed + (events/publish-event! :dashboard-create dash) + (assoc dash :last-edit-info (last-edit/edit-information-for-user @api/*current-user*))))) ;;; -------------------------------------------- Hiding Unreadable Cards --------------------------------------------- @@ -249,8 +258,9 @@ (api/defendpoint GET "/:id" "Get Dashboard with ID." [id] - (u/prog1 (get-dashboard id) - (events/publish-event! :dashboard-read (assoc <> :actor_id api/*current-user-id*)))) + (let [dashboard (get-dashboard id)] + (events/publish-event! :dashboard-read (assoc dashboard :actor_id api/*current-user-id*)) + (last-edit/with-last-edit-info dashboard :dashboard))) (defn- check-allowed-to-change-embedding @@ -302,8 +312,9 @@ :non-nil #{:name :parameters :caveats :points_of_interest :show_in_getting_started :enable_embedding :embedding_params :archived}))))) ;; now publish an event and return the updated Dashboard - (u/prog1 (Dashboard id) - (events/publish-event! :dashboard-update (assoc <> :actor_id api/*current-user-id*)))) + (let [dashboard (Dashboard id)] + (events/publish-event! :dashboard-update (assoc dashboard :actor_id api/*current-user-id*)) + (assoc dashboard :last-edit-info (last-edit/edit-information-for-user @api/*current-user*)))) ;; TODO - We can probably remove this in the near future since it should no longer be needed now that we're going to ;; be setting `:archived` to `true` via the `PUT` endpoint instead diff --git a/src/metabase/models/revision/last_edit.clj b/src/metabase/models/revision/last_edit.clj new file mode 100644 index 0000000000000000000000000000000000000000..b283976624a7a9403b539068ced5937121fe06f3 --- /dev/null +++ b/src/metabase/models/revision/last_edit.clj @@ -0,0 +1,95 @@ +(ns metabase.models.revision.last-edit + "A namespace to handle getting the last edited information about items that satisfy the revision system. The revision + system is a 'reversion' system, built to easily revert to previous states and can compute on demand a changelog. The + revision system works through events and so when editing something, you should construct the last-edit-info + yourself (using `edit-information-for-user`) rather looking at the revision table which might not be updated yet. + + This constructs `:last-edit-info`, a map with keys `:timestamp`, `:id`, `:first_name`, `:last_name`, and + `:email`. It is not a full User object (missing some superuser metadata, last login time, and a common name). This + was done to prevent another db call and hooking up timestamps to users but this can be added if preferred." + (:require [clj-time.core :as time] + [clojure.set :as set] + [honeysql.core :as hsql] + [medley.core :as m] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private model->db-model {:card "Card" :dashboard "Dashboard"}) + +;; these are all maybes as sometimes revisions don't exist, or users might be missing the names, etc +(def LastEditInfo + "Schema of the `:last-edit-info` map. A subset of a user with a timestamp indicating when the last edit was." + {:timestamp (s/maybe s/Any) + :id (s/maybe su/IntGreaterThanZero) + :first_name (s/maybe s/Str) + :last_name (s/maybe s/Str) + :email (s/maybe s/Str)}) + +(def MaybeAnnotated + "Spec for an item annotated with last-edit-info. Items are cards or dashboards. Optional because we may not always + have revision history for all cards/dashboards." + {(s/optional-key :last-edit-info) LastEditInfo s/Any s/Any}) + +(s/defn with-last-edit-info :- MaybeAnnotated + "Add the last edited information to a card. Will add a key `:last-edit-info`. Model should be one of `:dashboard` or + `:card`. Gets the last edited information from the revisions table. If you need this information from a put route, + use `@api/*current-user*` and a current timestamp since revisions are events and asynchronous." + [{:keys [id] :as item} model :- (s/enum :dashboard :card)] + (if-let [[updated-info] (seq (db/query {:select [:u.id :u.email :u.first_name :u.last_name :r.timestamp] + :from [[:revision :r]] + :left-join [[:core_user :u] [:= :u.id :r.user_id]] + :where [:and + [:= :r.model (model->db-model model)] + [:= :r.model_id id]] + :order-by [[:u.id :desc]] + :limit 1}))] + (assoc item :last-edit-info updated-info) + item)) + +(s/defn edit-information-for-user :- LastEditInfo + "Construct the `:last-edit-info` map given a user. Useful for editing routes. Most edit info information comes from + the revisions table. But this table is populated from events asynchronously so when editing and wanting + last-edit-info, you must construct it from `@api/*current-user*` and the current timestamp rather than checking the + revisions table as those revisions may not be present yet." + [user] + (merge {:timestamp (time/now)} + (select-keys user [:id :first_name :last_name :email]))) + +(def CollectionLastEditInfo + "Schema for the map of bulk last-item-info. A map of two keys, `:card` and `:dashboard`, each of which is a map from + id to a LastEditInfo." + {(s/optional-key :card) {s/Int LastEditInfo} + (s/optional-key :dashboard) {s/Int LastEditInfo}}) + +(s/defn fetch-last-edited-info :- (s/maybe CollectionLastEditInfo) + "Fetch edited info from the revisions table. Revision information is timestamp, user id, email, first and last + name. Takes card-ids and dashboard-ids and returns a map structured like + + {:card {model_id {:id :email :first_name :last_name :timestamp}} + :dashboard {model_id {:id :email :first_name :last_name :timestamp}}}" + [{:keys [card-ids dashboard-ids]}] + (when (seq (concat card-ids dashboard-ids)) + ;; [:in :model_id []] generates bad sql so need to conditionally add it + (let [where-clause (into [:or] + (keep (fn [[model-name ids]] + (when (seq ids) + [:and [:= :model model-name] [:in :model_id ids]]))) + [["Card" card-ids] + ["Dashboard" dashboard-ids]]) + latest-changes (db/query {:select [:u.id :u.email :u.first_name :u.last_name + :r.model :r.model_id :r.timestamp] + :from [[:revision :r]] + :left-join [[:core_user :u] [:= :u.id :r.user_id]] + :where [:in :r.id + ;; subselect for the max revision id for each item + {:select [[(hsql/call :max :id) :latest-revision-id]] + :from [:revision] + :where where-clause + :group-by [:model :model_id]}]})] + (->> latest-changes + (group-by :model) + (m/map-vals (fn [model-changes] + (into {} (map (juxt :model_id #(dissoc % :model :model_id))) model-changes))) + ;; keys are "Card" and "Dashboard" (model in revision table) back to keywords + (m/map-keys (set/map-invert model->db-model)))))) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index ae1d8e80382b38e051e8e3ff5b805e2387dcc7d1..b633e52522d19dd760d41f360e6fce4edaf3534a 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -14,6 +14,8 @@ PulseChannelRecipient Table ViewLog]] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] + [metabase.models.revision :as revision :refer [Revision]] + [metabase.models.user :refer [User]] [metabase.query-processor :as qp] [metabase.query-processor.async :as qp.async] [metabase.query-processor.middleware.constraints :as constraints] @@ -293,6 +295,8 @@ :can_write true :dashboard_count 0 :result_metadata true + :last-edit-info {:timestamp true :id true :first_name "Rasta" + :last_name "Toucan" :email "rasta@metabase.com"} :creator (merge (select-keys (mt/fetch-user :rasta) [:id :date_joined :last_login :locale]) {:common_name "Rasta Toucan" @@ -308,7 +312,11 @@ (update :dataset_query map?) (update :collection map?) (update :result_metadata (partial every? map?)) - (update :creator dissoc :is_qbnewb))))))))))) + (update :creator dissoc :is_qbnewb) + (update :last-edit-info (fn [edit-info] + (-> edit-info + (update :id boolean) + (update :timestamp boolean)))))))))))))) (deftest save-empty-card-test (testing "POST /api/card" @@ -584,7 +592,17 @@ :collection_id (u/the-id collection) :collection (into {} collection) :result_metadata (mt/obj->json->obj (:result_metadata card))}) - (mt/user-http-request :rasta :get 200 (str "card/" (u/the-id card)))))))))) + (mt/user-http-request :rasta :get 200 (str "card/" (u/the-id card)))))) + (testing "Card should include last edit info if available" + (mt/with-temp* [User [{user-id :id} {:first_name "Test" :last_name "User" :email "user@test.com"}] + Revision [_ {:model "Card" + :model_id (:id card) + :user_id user-id + :object (revision/serialize-instance card (:id card) card)}]] + (is (= {:id true :email "user@test.com" :first_name "Test" :last_name "User" :timestamp true} + (-> (mt/user-http-request :rasta :get 200 (str "card/" (u/the-id card))) + mt/boolean-ids-and-timestamps + :last-edit-info))))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | UPDATING A CARD | @@ -600,7 +618,10 @@ (with-cards-in-writeable-collection card (is (= "Original Name" (db/select-one-field :name Card, :id (u/the-id card)))) - (mt/user-http-request :rasta :put 202 (str "card/" (u/the-id card)) {:name "Updated Name"}) + (is (= {:timestamp true, :first_name "Rasta", :last_name "Toucan", :email "rasta@metabase.com", :id true} + (-> (mt/user-http-request :rasta :put 202 (str "card/" (u/the-id card)) {:name "Updated Name"}) + mt/boolean-ids-and-timestamps + :last-edit-info))) (is (= "Updated Name" (db/select-one-field :name Card, :id (u/the-id card))))))) diff --git a/test/metabase/api/collection_test.clj b/test/metabase/api/collection_test.clj index 1ee886944d37b17d6f361412e80822eafa4abb6e..5d022de1d56bc133a3043351740230acd9616643 100644 --- a/test/metabase/api/collection_test.clj +++ b/test/metabase/api/collection_test.clj @@ -3,13 +3,15 @@ (:require [clojure.string :as str] [clojure.test :refer :all] [metabase.models :refer [Card Collection Dashboard DashboardCard NativeQuerySnippet PermissionsGroup - PermissionsGroupMembership Pulse PulseCard PulseChannel PulseChannelRecipient]] + PermissionsGroupMembership Pulse PulseCard PulseChannel PulseChannelRecipient + Revision User]] [metabase.models.collection :as collection] [metabase.models.collection-test :as collection-test] [metabase.models.collection.graph :as graph] [metabase.models.collection.graph-test :as graph.test] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as group] + [metabase.models.revision :as revision] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] [metabase.util :as u] @@ -333,7 +335,27 @@ (db/update-where! Dashboard {:collection_id (u/the-id collection)} :archived true) (is (= [(default-item {:name "Dine & Dashboard", :description nil, :model "dashboard"})] (mt/boolean-ids-and-timestamps - (mt/user-http-request :rasta :get 200 (str "collection/" (u/the-id collection) "/items?archived=true")))))))))) + (mt/user-http-request :rasta :get 200 (str "collection/" (u/the-id collection) "/items?archived=true")))))))) + (testing "Results include last edited information from the `Revision` table" + (mt/with-temp* [Collection [{collection-id :id} {:name "Collection with Items"}] + User [{user-id :id} {:first_name "Test" :last_name "User" :email "testuser@example.com"}] + Card [{card-id :id :as card} + {:name "Card with history" :collection_id collection-id}] + Revision [_revision {:model "Card" + :model_id card-id + :user_id user-id + :object (revision/serialize-instance card card-id card)}]] + (is (= [{:name "Card with history", + :last-edit-info + {:id true, + :email "testuser@example.com", + :first_name "Test", + :last_name "User", + ;; timestamp collapsed to true, ordinarily a OffsetDateTime + :timestamp true}}] + (->> (mt/user-http-request :rasta :get 200 (str "collection/" collection-id "/items")) + mt/boolean-ids-and-timestamps + (map #(select-keys % [:name :last-edit-info]))))))))) (deftest snippet-collection-items-test (testing "GET /api/collection/:id/items" diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 2c495d4953af16a240cd544269b94d2f2d7e88a5..6fc3a9201bf491bf7a78ff14b6a1496b1f1d1e3a 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -18,8 +18,9 @@ [metabase.models.permissions :as perms] [metabase.models.permissions-group :as group] [metabase.models.pulse :refer [Pulse]] - [metabase.models.revision :refer [Revision]] + [metabase.models.revision :as revision :refer [Revision]] [metabase.models.table :refer [Table]] + [metabase.models.user :refer [User]] [metabase.server.middleware.util :as middleware.u] [metabase.test :as mt] [metabase.util :as u] @@ -60,12 +61,18 @@ (update :collection_id boolean))))) (defn- dashboard-response [{:keys [creator ordered_cards created_at updated_at] :as dashboard}] + ;; todo: should be udpated to use mt/boolean-ids-and-timestamps (let [dash (-> (into {} dashboard) (dissoc :id) (assoc :created_at (boolean created_at) :updated_at (boolean updated_at)) (update :collection_id boolean))] (cond-> dash + (contains? dash :last-edit-info) + (update :last-edit-info (fn [info] + (-> info + (update :id boolean) + (update :timestamp boolean)))) creator (update :creator #(into {} %)) ordered_cards (update :ordered_cards #(mapv dashcard-response %))))) @@ -136,12 +143,14 @@ (try (is (= (merge dashboard-defaults - {:name test-dashboard-name - :creator_id (mt/user->id :rasta) - :parameters [{:id "abc123", :name "test", :type "date"}] - :updated_at true - :created_at true - :collection_id true}) + {:name test-dashboard-name + :creator_id (mt/user->id :rasta) + :parameters [{:id "abc123", :name "test", :type "date"}] + :updated_at true + :created_at true + :collection_id true + :last-edit-info {:timestamp true :id true :first_name "Rasta" + :last_name "Toucan" :email "rasta@metabase.com"}}) (-> (mt/user-http-request :rasta :post 200 "dashboard" {:name test-dashboard-name :parameters [{:id "abc123", :name "test", :type "date"}] :collection_id (u/get-id collection)}) @@ -184,9 +193,18 @@ (deftest fetch-dashboard-test (testing "GET /api/dashboard/:id" (testing "fetch a dashboard WITH a dashboard card on it" - (mt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] + (mt/with-temp* [Dashboard [{dashboard-id :id + :as dashboard} {:name "Test Dashboard"}] Card [{card-id :id} {:name "Dashboard Test Card"}] - DashboardCard [_ {:dashboard_id dashboard-id, :card_id card-id}]] + DashboardCard [_ {:dashboard_id dashboard-id, :card_id card-id}] + User [{user-id :id} {:first_name "Test" :last_name "User" + :email "test@example.com"}] + Revision [_ {:user_id user-id + :model "Dashboard" + :model_id dashboard-id + :object (revision/serialize-instance dashboard + dashboard-id + dashboard)}]] (with-dashboards-in-readable-collection [dashboard-id] (card-api-test/with-cards-in-readable-collection [card-id] (is (= (merge @@ -197,6 +215,7 @@ :can_write false :param_values nil :param_fields nil + :last-edit-info {:timestamp true :id true :first_name "Test" :last_name "User" :email "test@example.com"} :ordered_cards [{:sizeX 2 :sizeY 2 :col 0 @@ -296,9 +315,11 @@ (dashboard-response (Dashboard dashboard-id))))) (testing "PUT response" - (is (= (merge dashboard-defaults {:name "My Cool Dashboard" - :description "Some awesome description" - :creator_id (mt/user->id :rasta) + (is (= (merge dashboard-defaults {:name "My Cool Dashboard" + :description "Some awesome description" + :creator_id (mt/user->id :rasta) + :last-edit-info {:timestamp true :id true :first_name "Rasta" + :last_name "Toucan" :email "rasta@metabase.com"} :collection_id true}) (dashboard-response (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) @@ -324,6 +345,9 @@ :collection_id true :caveats "" :points_of_interest "" + :last-edit-info + {:timestamp true, :id true, :first_name "Rasta", + :last_name "Toucan", :email "rasta@metabase.com"} :show_in_getting_started true}) (dashboard-response (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) {:caveats "" diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index d585d124f719f5329e32310f551d7b1207eb0320..6fff0264cd70bd65ab77c68ddcf91c70b38e6bc5 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -89,7 +89,7 @@ (boolean-ids-and-timestamps (every-pred (some-fn keyword? string?) (some-fn #{:id :created_at :updated_at :last_analyzed :created-at :updated-at :field-value-id :field-id - :date_joined :date-joined :last_login :dimension-id :human-readable-field-id} + :date_joined :date-joined :last_login :dimension-id :human-readable-field-id :timestamp} #(str/ends-with? % "_id") #(str/ends-with? % "_at"))) data))