Skip to content
Snippets Groups Projects
Unverified Commit b7180693 authored by dpsutton's avatar dpsutton Committed by GitHub
Browse files

Collections metadata backend work (#15718)

* Provide `:last-edit-info` information on collection items

only provided for cards and dashboards. At the moment, collections can
have:
- cards (:ballot_box_with_check:)
- dashboards (:ballot_box_with_check:)
- snippets (:x:)(not in UI)
- pulses (:x:)(deprecated)
- other collection (:muscle: to be done. needs a table for these
changes. they are revisioned)

* Send edit info along with cards

* Last-edit-info on dashboards

* Move last-edit-info functions into a bespoke namespace under revision

Under revision as the source of changes all come from revisions at the
moment. This might be ill-considered once we add collections into the
mix since they are not good candidates for revisions.

* Renames and docstring update on the last-edit namespace

* Add types

* Add `:last-edit-info` on card and dashboard creation

* Expect last-edit-info in creation repsonse for cards/dashboard

* Docstring on last-edit types

* Make the namespace checker happy

* namespace checker

* Remove moved function

* Add edit info to api/card/ and api/dashboard/

* Move event emission outside of transaction
parent 69a8eff9
No related branches found
No related tags found
No related merge requests found
......@@ -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`."
......
......@@ -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`.
......
......@@ -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
......
(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))))))
......@@ -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)))))))
......
......@@ -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"
......
......@@ -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 ""
......
......@@ -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))
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment