diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index 57d3e7b5b8e8354c7b88b4e5d7558874c34d4084..6fa57812f85b3c1d4a05b59f0eb9d29b3d96c002 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -37,13 +37,42 @@ [metabase.util.schema :as su] [schema.core :as s] [toucan.db :as db] - [toucan.hydrate :refer [hydrate]])) + [toucan.hydrate :refer [hydrate]] + [toucan.models :as models])) ;;; when alias defined for namespaced keywords is run through kondo macro, ns should be regarded as used (comment collection.root/keep-me) (declare root-collection) +(defn- coll-query + "Generate a query to return collections viewable by the current user. + If provided, additional and clauses will be added to the final where clause. + Note that and clauses should be aliased as c (for collection). Note also that + MySQL 5.7 does not support CTEs so this was all inlined. A much more readable + solution existed with CTEs, but we needed to inline for backwards compatibility." + [owner-id & and-clauses] + {:select [:c.*] + :from [[:collection :c]] + :left-join [[{:select [[:cwo.id :id] + [true :visible]] + :from [[{:select [[:c.id :id] + [[:coalesce + :c.personal_owner_id + :pr.personal_owner_id] :owner]] + :from [[:collection :c]] + :left-join [[{:select [:c.personal_owner_id + [[:concat "/" :c.id "/%"] :pc_prefix]] + :from [[:collection :c]] + :where [:not= :c.personal_owner_id nil]} :pr] + [:like :c.location :pr.pc_prefix]]} :cwo]] + :where [:or + [:= :cwo.owner owner-id] + [:= :cwo.owner nil]]} :a] + [:= :a.id :c.id]] + :where (into [:and [:= :a.visible true]] and-clauses) + :order-by [[:%lower.name :asc]]}) + #_{:clj-kondo/ignore [:deprecated-var]} (api/defendpoint-schema GET "/" "Fetch a list of all Collections that the current user has read permissions for (`:can_write` is returned as an @@ -54,29 +83,30 @@ [archived namespace] {archived (s/maybe su/BooleanString) namespace (s/maybe su/NonBlankString)} - (let [archived? (Boolean/parseBoolean archived)] - (as-> (db/select Collection - {:where [:and - [:= :archived archived?] - [:= :namespace namespace] - (collection/visible-collection-ids->honeysql-filter-clause - :id - (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*))] - :order-by [[:%lower.name :asc]]}) collections - ;; include Root Collection at beginning or results if archived isn't `true` - (if archived? - collections - (let [root (root-collection namespace)] - (cond->> collections - (mi/can-read? root) - (cons root)))) - (hydrate collections :can_write) - ;; remove the :metabase.models.collection.root/is-root? tag since FE doesn't need it - ;; and for personal collections we translate the name to user's locale - (for [collection collections] - (-> collection - (dissoc ::collection.root/is-root?) - collection/personal-collection-with-ui-details))))) + (let [archived? (Boolean/parseBoolean archived) + q (coll-query + api/*current-user-id* + [:= :c.archived archived?] + [:= :c.namespace namespace] + (collection/visible-collection-ids->honeysql-filter-clause + :c.id + (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*)))] + (as-> (->> (mdb.query/query q) + (map (partial models/do-post-select Collection))) collections + ;; include Root Collection at beginning or results if archived isn't `true` + (if archived? + collections + (let [root (root-collection namespace)] + (cond->> collections + (mi/can-read? root) + (cons root)))) + (hydrate collections :can_write) + ;; remove the :metabase.models.collection.root/is-root? tag since FE doesn't need it + ;; and for personal collections we translate the name to user's locale + (for [collection collections] + (-> collection + (dissoc ::collection.root/is-root?) + collection/personal-collection-with-ui-details))))) #_{:clj-kondo/ignore [:deprecated-var]} (api/defendpoint-schema GET "/tree" @@ -110,14 +140,16 @@ (mdb.query/reducible-query {:select-distinct [:collection_id :dataset] :from [:report_card] :where [:= :archived false]})) - colls (db/select Collection - {:where [:and - (when exclude-archived - [:= :archived false]) - [:= :namespace namespace] - (collection/visible-collection-ids->honeysql-filter-clause - :id - (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*))]}) + q (coll-query + api/*current-user-id* + (when exclude-archived + [:= :c.archived false]) + [:= :c.namespace namespace] + (collection/visible-collection-ids->honeysql-filter-clause + :c.id + (collection/permissions-set->visible-collection-ids @api/*current-user-permissions-set*))) + colls (->> (mdb.query/query q) + (map (partial models/do-post-select Collection))) colls (map collection/personal-collection-with-ui-details colls)] (collection/collections->tree coll-type-ids colls))) @@ -185,8 +217,8 @@ (pinned-state->clause pinned-state :collection_position)) ([pinned-state col] (case pinned-state - :all always-true-hsql-expr - :is_pinned [:<> col nil] + :all always-true-hsql-expr + :is_pinned [:<> col nil] :is_not_pinned [:= col nil] always-true-hsql-expr))) @@ -217,10 +249,10 @@ :from [[:pulse :p]] :left-join [[:pulse_card :pc] [:= :p.id :pc.pulse_id]] :where [:and - [:= :p.collection_id (:id collection)] - [:= :p.archived (boolean archived?)] + [:= :p.collection_id (:id collection)] + [:= :p.archived (boolean archived?)] ;; exclude alerts - [:= :p.alert_condition nil] + [:= :p.alert_condition nil] ;; exclude dashboard subscriptions [:= :p.dashboard_id nil]]} (sql.helpers/where (pinned-state->clause pinned-state :p.collection_position)))) @@ -407,15 +439,15 @@ collection [:= :archived archived?] [:= :namespace (u/qualified-name collection-namespace)]) - ;; We get from the effective-children-query a normal set of columns selected: - ;; want to make it fit the others to make UNION ALL work - :select [:id - :name - :description - :entity_id - :personal_owner_id - [(h2x/literal "collection") :model] - :authority_level]) + ;; We get from the effective-children-query a normal set of columns selected: + ;; want to make it fit the others to make UNION ALL work + :select [:id + :name + :description + :entity_id + :personal_owner_id + [(h2x/literal "collection") :model] + :authority_level]) ;; the nil indicates that collections are never pinned. (sql.helpers/where (pinned-state->clause pinned-state nil)))) @@ -431,11 +463,11 @@ ;; Previous examination with logging to DB says that there's no N+1 query for this. ;; However, this was only tested on H2 and Postgres (cond-> row - ;; when fetching root collection, we might have personal collection - (:personal_owner_id row) (assoc :name (collection/user->personal-collection-name (:personal_owner_id row) :user)) - true (assoc :can_write (mi/can-write? Collection (:id row))) - true (dissoc :collection_position :display :moderated_status :icon :personal_owner_id - :collection_preview :dataset_query)))) + ;; when fetching root collection, we might have personal collection + (:personal_owner_id row) (assoc :name (collection/user->personal-collection-name (:personal_owner_id row) :user)) + true (assoc :can_write (mi/can-write? Collection (:id row))) + true (dissoc :collection_position :display :moderated_status :icon :personal_owner_id + :collection_preview :dataset_query)))) (s/defn ^:private coalesce-edit-info :- last-edit/MaybeAnnotated "Hoist all of the last edit information into a map under the key :last-edit-info. Considers this information present @@ -451,8 +483,8 @@ :last_edit_email :email :last_edit_timestamp :timestamp}] (cond-> (apply dissoc row :model_ranking (keys mapping)) - ;; don't use contains as they all have the key, we care about a value present - (:last_edit_user row) (assoc :last-edit-info (select-as row mapping)))))) + ;; don't use contains as they all have the key, we care about a value present + (:last_edit_user row) (assoc :last-edit-info (select-as row mapping)))))) (defn- post-process-rows "Post process any data. Have a chance to process all of the same type at once using @@ -470,12 +502,12 @@ (defn- model-name->toucan-model [model-name] (case (keyword model-name) :collection Collection - :card Card - :dataset Card - :dashboard Dashboard - :pulse Pulse - :snippet NativeQuerySnippet - :timeline Timeline)) + :card Card + :dataset Card + :dashboard Dashboard + :pulse Pulse + :snippet NativeQuerySnippet + :timeline Timeline)) (defn- select-name "Takes a honeysql select column and returns a keyword of which column it is. @@ -534,56 +566,56 @@ treatment of nulls in the different app db types." [sort-info db-type] (case sort-info - nil [[:%lower.name :asc]] - [:name :asc] [[:%lower.name :asc]] - [:name :desc] [[:%lower.name :desc]] - [:last-edited-at :asc] [(if (= db-type :mysql) - [:%isnull.last_edit_timestamp] - [:last_edit_timestamp :nulls-last]) - [:last_edit_timestamp :asc] - [:%lower.name :asc]] + nil [[:%lower.name :asc]] + [:name :asc] [[:%lower.name :asc]] + [:name :desc] [[:%lower.name :desc]] + [:last-edited-at :asc] [(if (= db-type :mysql) + [:%isnull.last_edit_timestamp] + [:last_edit_timestamp :nulls-last]) + [:last_edit_timestamp :asc] + [:%lower.name :asc]] [:last-edited-at :desc] (remove nil? [(case db-type - :mysql [:%isnull.last_edit_timestamp] + :mysql [:%isnull.last_edit_timestamp] :postgres [:last_edit_timestamp :desc-nulls-last] - :h2 nil) + :h2 nil) [:last_edit_timestamp :desc] [:%lower.name :asc]]) - [:last-edited-by :asc] [(if (= db-type :mysql) - [:%isnull.last_edit_last_name] - [:last_edit_last_name :nulls-last]) - [:last_edit_last_name :asc] - (if (= db-type :mysql) - [:%isnull.last_edit_first_name] - [:last_edit_first_name :nulls-last]) - [:last_edit_first_name :asc] - [:%lower.name :asc]] + [:last-edited-by :asc] [(if (= db-type :mysql) + [:%isnull.last_edit_last_name] + [:last_edit_last_name :nulls-last]) + [:last_edit_last_name :asc] + (if (= db-type :mysql) + [:%isnull.last_edit_first_name] + [:last_edit_first_name :nulls-last]) + [:last_edit_first_name :asc] + [:%lower.name :asc]] [:last-edited-by :desc] (remove nil? [(case db-type - :mysql [:%isnull.last_edit_last_name] + :mysql [:%isnull.last_edit_last_name] :postgres [:last_edit_last_name :desc-nulls-last] - :h2 nil) + :h2 nil) [:last_edit_last_name :desc] (case db-type - :mysql [:%isnull.last_edit_first_name] + :mysql [:%isnull.last_edit_first_name] :postgres [:last_edit_last_name :desc-nulls-last] - :h2 nil) + :h2 nil) [:last_edit_first_name :desc] [:%lower.name :asc]]) - [:model :asc] [[:model_ranking :asc] [:%lower.name :asc]] - [:model :desc] [[:model_ranking :desc] [:%lower.name :asc]])) + [:model :asc] [[:model_ranking :asc] [:%lower.name :asc]] + [:model :desc] [[:model_ranking :desc] [:%lower.name :asc]])) (defn- collection-children* [collection models {:keys [sort-info] :as options}] (let [sql-order (children-sort-clause sort-info (mdb/db-type)) models (sort (map keyword models)) queries (for [model models - :let [query (collection-children-query model collection options) - select-clause-type (some - (fn [k] - (when (get query k) - k)) - [:select :select-distinct])]] + :let [query (collection-children-query model collection options) + select-clause-type (some + (fn [k] + (when (get query k) + k)) + [:select :select-distinct])]] (-> query (update select-clause-type add-missing-columns all-select-columns) (update select-clause-type add-model-ranking model))) @@ -600,14 +632,14 @@ (= (:collection-namespace options) "snippets")) rows-query (assoc rows-query - :limit mw.offset-paging/*limit* - :offset mw.offset-paging/*offset*)) + :limit mw.offset-paging/*limit* + :offset mw.offset-paging/*offset*)) res {:total (->> (mdb.query/query total-query) first :count) :data (->> (mdb.query/query limit-query) post-process-rows) :models models} limit-res (assoc res - :limit mw.offset-paging/*limit* - :offset mw.offset-paging/*offset*)] + :limit mw.offset-paging/*limit* + :offset mw.offset-paging/*offset*)] (if (= (:collection-namespace options) "snippets") res limit-res))) @@ -615,14 +647,14 @@ (s/defn ^:private collection-children "Fetch a sequence of 'child' objects belonging to a Collection, filtered using `options`." [{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot - {:keys [models], :as options} :- CollectionChildrenOptions] + {:keys [models], :as options} :- CollectionChildrenOptions] (let [valid-models (for [model-kw [:collection :dataset :card :dashboard :pulse :snippet :timeline] ;; only fetch models that are specified by the `model` param; or everything if it's empty - :when (or (empty? models) (contains? models 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)))] + :when (or (empty? models) (contains? models 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)] (if (seq valid-models) (collection-children* collection valid-models (assoc options :collection-namespace collection-namespace)) @@ -744,12 +776,12 @@ model-set (set (map keyword (u/one-or-many models))) model-kwds (visible-model-kwds root-collection model-set)] (collection-children - root-collection - {:models model-kwds - :archived? (Boolean/parseBoolean archived) - :pinned-state (keyword pinned_state) - :sort-info [(or (some-> sort_column normalize-sort-choice) :name) - (or (some-> sort_direction normalize-sort-choice) :asc)]}))) + root-collection + {:models model-kwds + :archived? (Boolean/parseBoolean archived) + :pinned-state (keyword pinned_state) + :sort-info [(or (some-> sort_column normalize-sort-choice) :name) + (or (some-> sort_direction normalize-sort-choice) :asc)]}))) ;;; ----------------------------------------- Creating/Editing a Collection ------------------------------------------ @@ -761,7 +793,7 @@ (api/write-check (if collection-id (db/select-one Collection :id collection-id) (cond-> collection/root-collection - collection-namespace (assoc :namespace collection-namespace))))) + collection-namespace (assoc :namespace collection-namespace))))) (defn create-collection! "Create a new collection." @@ -773,11 +805,11 @@ (and api/*is-superuser?* authority_level))) (db/insert! Collection (merge - {:name name - :color color - :description description + {:name name + :color color + :description description :authority_level authority_level - :namespace namespace} + :namespace namespace} (when parent_id {:location (collection/children-location (db/select-one [Collection :location :id] :id parent_id))})))) @@ -814,7 +846,7 @@ ;; ok, make sure we have perms to do this operation (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* - (collection/perms-for-moving collection-before-update new-parent))) + (collection/perms-for-moving collection-before-update new-parent))) ;; ok, we're good to move! (collection/move-collection! collection-before-update new-location))))) @@ -828,7 +860,7 @@ ;; Check that we have approprate perms (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* - (collection/perms-for-archiving collection-before-update))))) + (collection/perms-for-archiving collection-before-update))))) (defn- maybe-send-archived-notificaitons! "When a collection is archived, all of it's cards are also marked as archived, but this is down in the model layer @@ -844,12 +876,12 @@ (api/defendpoint-schema PUT "/:id" "Modify an existing Collection, including archiving or unarchiving it, or moving it." [id, :as {{:keys [name color description archived parent_id authority_level], :as collection-updates} :body}] - {name (s/maybe su/NonBlankString) - color (s/maybe collection/hex-color-regex) - description (s/maybe su/NonBlankString) - archived (s/maybe s/Bool) - parent_id (s/maybe su/IntGreaterThanZero) - authority_level collection/AuthorityLevel} + {name (s/maybe su/NonBlankString) + color (s/maybe collection/hex-color-regex) + description (s/maybe su/NonBlankString) + archived (s/maybe s/Bool) + parent_id (s/maybe su/IntGreaterThanZero) + authority_level collection/AuthorityLevel} ;; do we have perms to edit this Collection? (let [collection-before-update (api/write-check Collection id)] ;; if we're trying to *archive* the Collection, make sure we're allowed to do that diff --git a/test/metabase/api/collection_test.clj b/test/metabase/api/collection_test.clj index ff98db022363debedf9eeef32e5c1c6af52d720d..9d689a05ed1b52937c89cfe55dc1a025173a3f9e 100644 --- a/test/metabase/api/collection_test.clj +++ b/test/metabase/api/collection_test.clj @@ -29,6 +29,7 @@ [metabase.models.permissions-group :as perms-group] [metabase.models.revision :as revision] [metabase.test :as mt] + [metabase.test.data.users :as test.users] [metabase.test.fixtures :as fixtures] [metabase.util :as u] [metabase.util.schema :as su] @@ -94,16 +95,33 @@ (filter :personal_owner_id) (map :name)))) - (testing "...unless we are *admins*" - (is (= ["Crowberto Corv's Personal Collection" - "Lucky Pigeon's Personal Collection" - "Rasta Toucan's Personal Collection" - "Trash Bird's Personal Collection"] + (testing "...even if we are *admins*" + (is (= ["Crowberto Corv's Personal Collection"] (->> (mt/user-http-request :crowberto :get 200 "collection") (filter #((set (map mt/user->id [:crowberto :lucky :rasta :trashbird])) (:personal_owner_id %))) (map :name) sort))))) + (testing "You should only see your collection and public collections" + (let [user-id (u/the-id (test.users/fetch-user :crowberto)) + crowberto-root (db/select-one Collection :personal_owner_id user-id)] + (mt/with-temp* [Collection [collection] + Collection [{collection-id :id} {:name "Collection with Items"}] + Collection [_ {:name "subcollection" + :location (format "/%d/" collection-id) + :authority_level "official"}] + Collection [_ {:name "Crowberto's Child Collection" + :location (collection/location-path crowberto-root)}]] + (let [public-collections #{"Our analytics" (:name collection) "Collection with Items" "subcollection"} + crowbertos (set (map :name (mt/user-http-request :crowberto :get 200 "collection"))) + luckys (set (map :name (mt/user-http-request :lucky :get 200 "collection")))] + (is (= (into public-collections #{"Crowberto Corv's Personal Collection" "Crowberto's Child Collection"}) + crowbertos)) + (is (false? (contains? crowbertos "Lucky Pigeon's Personal Collection"))) + (is (= (conj public-collections (:name collection) "Lucky Pigeon's Personal Collection") + luckys)) + (is (false? (contains? luckys "Crowberto Corv's Personal Collection"))))))) + (testing "Personal Collection's name and slug should be returned in user's locale" (with-french-user-and-personal-collection user _collection (is (= [{:name "Collection personnelle de Taco Bell"