diff --git a/.dir-locals.el b/.dir-locals.el index 643d8f497212418f9f6f1caf283555cd82c6d651..20ebf6a8d0d5dce346c9d62b9dce41342af8fa49 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -35,6 +35,7 @@ ;; instead of one call to `define-clojure-indent' (eval . (put-clojure-indent 'c/step 1)) (eval . (put-clojure-indent 'db/insert-many! 1)) + (eval . (put-clojure-indent 'db/update! 2)) (eval . (put-clojure-indent 'impl/test-migrations 2)) (eval . (put-clojure-indent 'let-404 0)) (eval . (put-clojure-indent 'macros/case 0)) @@ -49,6 +50,8 @@ (eval . (put-clojure-indent 'mt/test-drivers 1)) (eval . (put-clojure-indent 'prop/for-all 1)) (eval . (put-clojure-indent 'qp.streaming/streaming-response 1)) + (eval . (put-clojure-indent 'u/select-keys-when 1)) + (eval . (put-clojure-indent 'u/strict-extend 1)) ;; these ones have to be done with `define-clojure-indent' for now because of upstream bug ;; https://github.com/clojure-emacs/clojure-mode/issues/600 once that's resolved we should use `put-clojure-indent' ;; instead. Please don't add new entries unless they don't work with `put-clojure-indent' diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 47d1d04e40ef7f7579a0e75b4986d23e90fd96c6..b9c775bf01193d4e0fe0810808044c8a82f8161a 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -9907,6 +9907,7 @@ databaseChangeLog: - sql: sql: UPDATE metabase_database SET engine = 'bigquery-cloud-sdk' WHERE engine = 'bigquery' + # # The next few migrations replace metabase.db.data-migrations/add-users-to-default-permissions-groups from 0.20.0 # @@ -10265,6 +10266,212 @@ databaseChangeLog: 'type/Title' ); + - changeSet: + id: v43.00-021 + author: adam-james + comment: Added 0.43.0 - Timeline table for Events + changes: + - createTable: + tableName: timeline + remarks: Timeline table to organize events + columns: + - column: + name: id + type: int + autoIncrement: true + constraints: + nullable: false + primaryKey: true + - column: + remarks: Name of the timeline + name: name + type: varchar(255) + constraints: + nullable: false + - column: + remarks: Optional description of the timeline + name: description + type: varchar(255) + constraints: + nullable: true + - column: + name: icon + type: varchar(128) + constraints: + nullable: true + remarks: the icon to use when displaying the event + - column: + remarks: ID of the collection containing the timeline + name: collection_id + type: int + constraints: + nullable: true + references: collection(id) + foreignKeyName: fk_timeline_collection_id + deleteCascade: true + - column: + remarks: Whether or not the timeline has been archived + name: archived + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + - column: + remarks: ID of the user who created the timeline + name: creator_id + type: int + constraints: + nullable: false + references: core_user(id) + foreignKeyName: fk_timeline_creator_id + deleteCascade: true + - column: + remarks: The timestamp of when the timeline was created + name: created_at + type: ${timestamp_type} + defaultValueComputed: current_timestamp + constraints: + nullable: false + - column: + remarks: The timestamp of when the timeline was updated + name: updated_at + type: ${timestamp_type} + defaultValueComputed: current_timestamp + constraints: + nullable: false + + - changeSet: + id: v43.00-022 + author: adam-james + comment: Added 0.43.0 - Events table + changes: + - createTable: + tableName: timeline_event + remarks: Events table + columns: + - column: + name: id + type: int + autoIncrement: true + constraints: + nullable: false + primaryKey: true + - column: + remarks: ID of the timeline containing the event + name: timeline_id + type: int + constraints: + nullable: false + references: timeline(id) + foreignKeyName: fk_events_timeline_id + deleteCascade: true + - column: + remarks: Name of the event + name: name + type: varchar(255) + constraints: + nullable: false + - column: + remarks: Optional markdown description of the event + name: description + type: varchar(255) + constraints: + nullable: true + - column: + name: timestamp + type: ${timestamp_type} + constraints: + nullable: false + remarks: When the event happened + - column: + name: time_matters + type: boolean + constraints: + nullable: false + remarks: >- + Indicate whether the time component matters or if the timestamp should just serve to indicate the + day of the event without any time associated to it. + - column: + name: timezone + constraints: + nullable: false + type: varchar(255) + remarks: Timezone to display the underlying UTC timestamp in for the client + - column: + name: icon + type: varchar(128) + constraints: + nullable: true + remarks: the icon to use when displaying the event + - column: + remarks: Whether or not the event has been archived + name: archived + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + - column: + remarks: ID of the user who created the event + name: creator_id + type: int + constraints: + nullable: false + references: core_user(id) + foreignKeyName: fk_event_creator_id + deleteCascade: true + - column: + remarks: The timestamp of when the event was created + name: created_at + type: ${timestamp_type} + defaultValueComputed: current_timestamp + constraints: + nullable: false + - column: + remarks: The timestamp of when the event was modified + name: updated_at + type: ${timestamp_type} + defaultValueComputed: current_timestamp + constraints: + nullable: false + + - changeSet: + id: v43.00-023 + author: dpsutton + comment: Added 0.43.0 - Index on timeline collection_id + changes: + - createIndex: + tableName: timeline + indexName: idx_timeline_collection_id + columns: + - column: + name: collection_id + + - changeSet: + id: v43.00-024 + author: dpsutton + comment: Added 0.43.0 - Index on timeline_event timeline_id + changes: + - createIndex: + tableName: timeline_event + indexName: idx_timeline_event_timeline_id + columns: + - column: + name: timeline_id + + - changeSet: + id: v43.00-025 + author: dpsutton + comment: Added 0.43.0 - Index on timeline timestamp + changes: + - createIndex: + tableName: timeline_event + indexName: idx_timeline_event_timeline_id_timestamp + columns: + - column: + name: timeline_id + - column: + name: timestamp + # >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<< ######################################################################################################################## diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 571cf2a3b2937836428afa764cdb412b71280410..0d6d48d366edfb76c87083f940dfcf8c988d98df 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -9,6 +9,7 @@ [medley.core :as m] [metabase.api.common :as api] [metabase.api.dataset :as dataset-api] + [metabase.api.timeline :as timeline-api] [metabase.async.util :as async.u] [metabase.email.messages :as messages] [metabase.events :as events] @@ -24,6 +25,7 @@ [metabase.models.query.permissions :as query-perms] [metabase.models.revision.last-edit :as last-edit] [metabase.models.table :refer [Table]] + [metabase.models.timeline :as timeline] [metabase.models.view-log :refer [ViewLog]] [metabase.query-processor.async :as qp.async] [metabase.query-processor.card :as qp.card] @@ -32,6 +34,7 @@ [metabase.related :as related] [metabase.sync.analyze.query-results :as qr] [metabase.util :as u] + [metabase.util.date-2 :as u.date] [metabase.util.i18n :refer [trs tru]] [metabase.util.schema :as su] [schema.core :as s] @@ -173,6 +176,22 @@ (last-edit/with-last-edit-info :card)) (events/publish-event! :card-read (assoc <> :actor_id api/*current-user-id*)))) +(api/defendpoint GET "/:id/timelines" + "Get the timelines for card with ID. Looks up the collection the card is in and uses that." + [id include start end] + {include (s/maybe timeline-api/Include) + start (s/maybe su/TemporalString) + end (s/maybe su/TemporalString)} + (let [{:keys [collection_id] :as _card} (api/read-check Card id)] + ;; subtlety here. timeline access is based on the collection at the moment so this check should be identical. If + ;; we allow adding more timelines to a card in the future, we will need to filter on read-check and i don't think + ;; the read-checks are particularly fast on multiple items + (timeline/timelines-for-collection collection_id + {:timeline/events? (= include "events") + :events/start (when start (u.date/parse start)) + :events/end (when end (u.date/parse end))}))) + + ;;; -------------------------------------------------- Saving Cards -------------------------------------------------- (s/defn ^:private result-metadata-async :- ManyToManyChannel diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index 45cb82183ee030df6d0940ab23f0beb4aae4a338..e12bda63cd5bdd3d0408f2cd35f447eb31f032e6 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -11,6 +11,7 @@ [medley.core :as m] [metabase.api.card :as card-api] [metabase.api.common :as api] + [metabase.api.timeline :as timeline-api] [metabase.db :as mdb] [metabase.models.card :refer [Card]] [metabase.models.collection :as collection :refer [Collection]] @@ -24,6 +25,7 @@ [metabase.models.pulse :as pulse :refer [Pulse]] [metabase.models.pulse-card :refer [PulseCard]] [metabase.models.revision.last-edit :as last-edit] + [metabase.models.timeline :as timeline :refer [Timeline]] [metabase.server.middleware.offset-paging :as offset-paging] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx] @@ -108,13 +110,13 @@ (def ^:private valid-model-param-values "Valid values for the `?model=` param accepted by endpoints in this namespace. `no_models` is for nilling out the set because a nil model set is actually the total model set" - #{"card" "dataset" "collection" "dashboard" "pulse" "snippet" "no_models"}) + #{"card" "dataset" "collection" "dashboard" "pulse" "snippet" "no_models" "timeline"}) (def ^:private ModelString (apply s/enum valid-model-param-values)) ; This is basically a union type. defendpoint splits the string if it only gets one -(def ^:private models-schema (s/conditional #(vector? %) [ModelString] :else ModelString)) +(def ^:private models-schema (s/conditional vector? [ModelString] :else ModelString)) (def ^:private valid-pinned-state-values "Valid values for the `?pinned_state` param accepted by endpoints in this namespace." @@ -158,6 +160,14 @@ :is_not_pinned [:= col nil] [:= 1 1]))) +(defn- poison-when-pinned-clause + "Poison a query to return no results when filtering to pinned items. Use for items that do not have a notion of + pinning so that no results return when asking for pinned items." + [pinned-state] + (if (= pinned-state :is_pinned) + [:= 1 2] + [:= 1 1])) + (defmulti ^:private post-process-collection-children {:arglists '([model rows])} (fn [model _] @@ -188,20 +198,36 @@ (defmethod post-process-collection-children :pulse [_ rows] (for [row rows] - (dissoc row :description :display :authority_level :moderated_status))) + (dissoc row :description :display :authority_level :moderated_status :icon))) (defmethod collection-children-query :snippet [_ collection {:keys [archived?]}] {:select [:id :name [(hx/literal "snippet") :model]] - :from [[NativeQuerySnippet :nqs]] - :where [:and - [:= :collection_id (:id collection)] - [:= :archived (boolean archived?)]]}) + :from [[NativeQuerySnippet :nqs]] + :where [:and + [:= :collection_id (:id collection)] + [:= :archived (boolean archived?)]]}) + +(defmethod collection-children-query :timeline + [_ collection {:keys [archived? pinned-state]}] + {:select [:id :name [(hx/literal "timeline") :model] :description :icon] + :from [[Timeline :timeline]] + :where [:and + (poison-when-pinned-clause pinned-state) + [:= :collection_id (:id collection)] + [:= :archived (boolean archived?)]]}) + +(defmethod post-process-collection-children :timeline + [_ rows] + (for [row rows] + (dissoc row :description :display :collection_position :authority_level :moderated_status))) (defmethod post-process-collection-children :snippet [_ rows] (for [row rows] - (dissoc row :description :collection_position :display :authority_level :moderated_status))) + (dissoc row + :description :collection_position :display :authority_level + :moderated_status :icon))) (defn- card-query [dataset? collection {:keys [archived? pinned-state]}] (-> {:select [:c.id :c.name :c.description :c.collection_position :c.display @@ -253,7 +279,7 @@ (defmethod post-process-collection-children :card [_ rows] - (hydrate (map #(dissoc % :authority_level) rows) :favorite)) + (hydrate (map #(dissoc % :authority_level :icon) rows) :favorite)) (defmethod collection-children-query :dashboard [_ collection {:keys [archived? pinned-state]}] @@ -280,7 +306,7 @@ (defmethod post-process-collection-children :dashboard [_ rows] - (hydrate (map #(dissoc % :display :authority_level :moderated_status) rows) :favorite)) + (hydrate (map #(dissoc % :display :authority_level :moderated_status :icon) rows) :favorite)) (defmethod collection-children-query :collection [_ collection {:keys [archived? collection-namespace pinned-state]}] @@ -305,7 +331,7 @@ ;; don't get models back from ulterior over-query ;; 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 - (assoc (dissoc row :collection_position :display :moderated_status) + (assoc (dissoc row :collection_position :display :moderated_status :icon) :can_write (mi/can-write? Collection (:id row))))) @@ -346,7 +372,8 @@ :dataset Card :dashboard Dashboard :pulse Pulse - :snippet NativeQuerySnippet)) + :snippet NativeQuerySnippet + :timeline Timeline)) (defn- select-name "Takes a honeysql select column and returns a keyword of which column it is. @@ -365,7 +392,7 @@ are optional (not id, but last_edit_user for example) must have a type so that the union-all can unify the nil with the correct column type." [:id :name :description :display :model :collection_position :authority_level - :last_edit_email :last_edit_first_name :last_edit_last_name :moderated_status + :last_edit_email :last_edit_first_name :last_edit_last_name :moderated_status :icon [:last_edit_user :integer] [:last_edit_timestamp :timestamp]]) (defn- add-missing-columns @@ -386,7 +413,8 @@ :dataset 3 :card 4 :snippet 5 - :collection 6}] + :collection 6 + :timeline 7}] (conj select-clause [(get rankings model 100) :model_ranking]))) @@ -477,9 +505,9 @@ (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] - (let [valid-models (for [model-kw [:collection :dataset :card :dashboard :pulse :snippet] + [{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot + {: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) @@ -507,6 +535,24 @@ [id] (collection-detail (api/read-check Collection id))) +(api/defendpoint GET "/root/timelines" + "Fetch the root Collection's timelines." + [include archived] + {include (s/maybe timeline-api/Include) + archived (s/maybe su/BooleanString)} + (let [archived? (Boolean/parseBoolean archived)] + (timeline/timelines-for-collection nil {:timeline/events? (= include "events") + :timeline/archived? archived?}))) + +(api/defendpoint GET "/:id/timelines" + "Fetch a specific Collection's timelines." + [id include archived] + {include (s/maybe timeline-api/Include) + archived (s/maybe su/BooleanString)} + (let [archived? (Boolean/parseBoolean archived)] + (timeline/timelines-for-collection id {:timeline/events? (= include "events") + :timeline/archived? archived?}))) + (api/defendpoint GET "/:id/items" "Fetch a specific Collection's items with the following options: diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj index e422311916a04e69dce40f05cbd3ecac6a574b04..b6c944f4210a883581d578303ddca285c066386a 100644 --- a/src/metabase/api/routes.clj +++ b/src/metabase/api/routes.clj @@ -35,6 +35,8 @@ [metabase.api.task :as task] [metabase.api.testing :as testing] [metabase.api.tiles :as tiles] + [metabase.api.timeline :as timeline] + [metabase.api.timeline-event :as timeline-event] [metabase.api.transform :as transform] [metabase.api.user :as user] [metabase.api.util :as util] @@ -93,6 +95,8 @@ testing/routes (fn [_ respond _] (respond nil)))) (context "/tiles" [] (+auth tiles/routes)) + (context "/timeline" [] (+auth timeline/routes)) + (context "/timeline-event" [] (+auth timeline-event/routes)) (context "/transform" [] (+auth transform/routes)) (context "/user" [] (+auth user/routes)) (context "/util" [] util/routes) diff --git a/src/metabase/api/timeline.clj b/src/metabase/api/timeline.clj new file mode 100644 index 0000000000000000000000000000000000000000..a4b3d0cfc6cc78052ad78f8e49e219196a9a7562 --- /dev/null +++ b/src/metabase/api/timeline.clj @@ -0,0 +1,81 @@ +(ns metabase.api.timeline + "/api/timeline endpoints." + (:require [compojure.core :refer [DELETE GET POST PUT]] + [metabase.api.common :as api] + [metabase.models.collection :as collection] + [metabase.models.timeline :refer [Timeline]] + [metabase.models.timeline-event :as timeline-event :refer [TimelineEvent]] + [metabase.util :as u] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db] + [toucan.hydrate :refer [hydrate]])) + +(def Include + "Events Query Parameters Schema" + (s/enum "events")) + +(api/defendpoint POST "/" + "Create a new [[Timeline]]." + [:as {{:keys [name description icon collection_id archived], :as body} :body}] + {name su/NonBlankString + description (s/maybe s/Str) + ;; todo: there are six valid ones. What are they? + icon (s/maybe s/Str) + collection_id (s/maybe su/IntGreaterThanZero) + archived (s/maybe s/Bool)} + (collection/check-write-perms-for-collection collection_id) + (db/insert! Timeline (assoc body :creator_id api/*current-user-id*))) + +(api/defendpoint GET "/" + "Fetch a list of [[Timelines]]. Can include `archived=true` to return archived timelines." + [archived] + {archived (s/maybe su/BooleanString)} + (let [archived? (Boolean/parseBoolean archived)] + (db/select Timeline [:where [:= :archived archived?]]))) + +(api/defendpoint GET "/:id" + "Fetch the [[Timeline]] with `id`. Include `include=events` to unarchived events included on the timeline. Add + `archived=true` to return all events on the timeline, both archived and unarchived." + [id include archived] + {include (s/maybe Include) + archived (s/maybe su/BooleanString)} + (let [archived? (Boolean/parseBoolean archived) + timeline (api/read-check (Timeline id))] + (cond-> (hydrate timeline :creator) + (= include "events") + (timeline-event/include-events-singular {:events/all? archived?})))) + +(api/defendpoint PUT "/:id" + "Update the [[Timeline]] with `id`. Returns the timeline without events. Archiving a timeline will archive all of the + events in that timeline." + [id :as {{:keys [name description icon collection_id archived] :as timeline-updates} :body}] + {name (s/maybe su/NonBlankString) + description (s/maybe s/Str) + ;; todo: there are six valid ones. What are they? + icon (s/maybe s/Str) + collection_id (s/maybe su/IntGreaterThanZero) + archived (s/maybe s/Bool)} + ;; todo: icon is valid + (let [existing (api/write-check Timeline id) + current-archived (:archived (db/select-one Timeline :id id))] + (collection/check-allowed-to-change-collection existing timeline-updates) + (db/update! Timeline id + (u/select-keys-when timeline-updates + :present #{:description :icon :collection_id :archived} + :non-nil #{:name})) + (when (and (some? archived) (not= current-archived archived)) + (db/update-where! TimelineEvent {:timeline_id id} :archived archived)) + (hydrate (Timeline id) :creator))) + +(api/defendpoint DELETE "/:id" + "Delete a [[Timeline]]. Will cascade delete its events as well." + [id] + (api/write-check Timeline id) + (db/delete! Timeline :id id) + api/generic-204-no-content) + + +;; todo: icons +;; todo: how does updated-at work? +(api/define-routes) diff --git a/src/metabase/api/timeline_event.clj b/src/metabase/api/timeline_event.clj new file mode 100644 index 0000000000000000000000000000000000000000..f7c189b1afd29aeeba6c36c5868d9b7a421fc97e --- /dev/null +++ b/src/metabase/api/timeline_event.clj @@ -0,0 +1,77 @@ +(ns metabase.api.timeline-event + "/api/timeline-event endpoints." + (:require [compojure.core :refer [DELETE GET POST PUT]] + [metabase.api.common :as api] + [metabase.models.collection :as collection] + [metabase.models.timeline :refer [Timeline]] + [metabase.models.timeline-event :refer [TimelineEvent]] + [metabase.util :as u] + [metabase.util.date-2 :as u.date] + [metabase.util.i18n :refer [tru]] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(api/defendpoint POST "/" + "Create a new [[TimelineEvent]]." + [:as {{:keys [name description timestamp time_matters timezone icon timeline_id archived] :as body} :body}] + {name su/NonBlankString + description (s/maybe s/Str) + timestamp (s/maybe su/TemporalString) + time_matters (s/maybe s/Bool) + timezone s/Str + icon (s/maybe s/Str) + timeline_id su/IntGreaterThanZero + archived (s/maybe s/Bool)} + ;; deliberately not using api/check-404 so we can have a useful error message. + (let [timeline (Timeline timeline_id)] + (when-not timeline + (throw (ex-info (tru "Timeline with id {0} not found" timeline_id) + {:status-code 404}))) + (collection/check-write-perms-for-collection (:collection_id timeline))) + ;; todo: revision system + (let [parsed (if (nil? timestamp) + (throw (ex-info (tru "Timestamp cannot be null") {:status-code 400})) + (u.date/parse timestamp))] + (db/insert! TimelineEvent (assoc body + :creator_id api/*current-user-id* + :timestamp parsed)))) + +(api/defendpoint GET "/:id" + "Fetch the [[TimelineEvent]] with `id`." + [id] + (api/read-check TimelineEvent id)) + +(api/defendpoint PUT "/:id" + "Update a [[TimelineEvent]]." + [id :as {{:keys [name description timestamp time_matters timezone icon timeline_id archived] + :as timeline-event-updates} :body}] + {name (s/maybe su/NonBlankString) + description (s/maybe s/Str) + timestamp (s/maybe su/TemporalString) + time_matters (s/maybe s/Bool) + timezone (s/maybe s/Str) + icon (s/maybe s/Str) + timeline_id (s/maybe su/IntGreaterThanZero) + archived (s/maybe s/Bool)} + (let [existing (api/write-check TimelineEvent id)] + (collection/check-allowed-to-change-collection existing timeline-event-updates) + ;; todo: if we accept a new timestamp, must we require a timezone? gut says yes? + (db/update! TimelineEvent id + (u/select-keys-when timeline-event-updates + ;; todo: are there more keys needed in non-nil? timestamp? + :present #{:description :timestamp :time_matters :timezone :icon :timeline_id :archived} + :non-nil #{:name})) + (TimelineEvent id))) + +(api/defendpoint DELETE "/:id" + "Delete a [[TimelineEvent]]." + [id] + (api/write-check TimelineEvent id) + (db/delete! TimelineEvent :id id) + api/generic-204-no-content) + +;; todo: icons +;; collection_id via timeline_id -> slow, how to do this? + +(api/define-routes) diff --git a/src/metabase/cmd/copy.clj b/src/metabase/cmd/copy.clj index 3a46b1fd3305634f9a6ef1b534f7a4e0c6bc9d41..49d6da6875da1c6bd481c19e0cc96f7804319a70 100644 --- a/src/metabase/cmd/copy.clj +++ b/src/metabase/cmd/copy.clj @@ -14,7 +14,7 @@ FieldValues LoginHistory Metric MetricImportantField ModerationReview NativeQuerySnippet Permissions PermissionsGroup PermissionsGroupMembership PermissionsRevision Pulse PulseCard PulseChannel PulseChannelRecipient Revision Secret Segment Session Setting Table - User ViewLog]] + Timeline TimelineEvent User ViewLog]] [metabase.models.permissions-group :as group] [metabase.util :as u] [metabase.util.i18n :refer [trs]] @@ -78,6 +78,8 @@ Dimension NativeQuerySnippet LoginHistory + Timeline + TimelineEvent Secret ;; migrate the list of finished DataMigrations as the very last thing (all models to copy over should be listed ;; above this line) diff --git a/src/metabase/models.clj b/src/metabase/models.clj index 075fb43ead04291c1ea6ee04060468fda81ce551..bfda99e164d499e218faea6224623a222338cb9a 100644 --- a/src/metabase/models.clj +++ b/src/metabase/models.clj @@ -35,6 +35,8 @@ [metabase.models.setting :as setting] [metabase.models.table :as table] [metabase.models.task-history :as task-history] + [metabase.models.timeline :as timeline] + [metabase.models.timeline-event :as timeline-event] [metabase.models.user :as user] [metabase.models.view-log :as view-log] [potemkin :as p])) @@ -76,6 +78,8 @@ setting/keep-me table/keep-me task-history/keep-me + timeline/keep-me + timeline-event/keep-me user/keep-me view-log/keep-me) @@ -116,5 +120,7 @@ [setting Setting] [table Table] [task-history TaskHistory] + [timeline Timeline] + [timeline-event TimelineEvent] [user User] [view-log ViewLog]) diff --git a/src/metabase/models/timeline.clj b/src/metabase/models/timeline.clj new file mode 100644 index 0000000000000000000000000000000000000000..9c2e1198aea859e99521e15cefd6cec9969cdcb2 --- /dev/null +++ b/src/metabase/models/timeline.clj @@ -0,0 +1,31 @@ +(ns metabase.models.timeline + (:require [metabase.models.interface :as i] + [metabase.models.permissions :as perms] + [metabase.models.timeline-event :as timeline-event] + [metabase.util :as u] + [toucan.db :as db] + [toucan.hydrate :refer [hydrate]] + [toucan.models :as models])) + +(models/defmodel Timeline :timeline) + +;;;; functions + +(defn timelines-for-collection + "Load timelines based on `collection-id` passed in (nil means the root collection). Hydrates the events on each + timeline at `:events` on the timeline." + [collection-id {:keys [:timeline/events? :timeline/archived?] :as options}] + (cond-> (hydrate (db/select Timeline + :collection_id collection-id + :archived (boolean archived?)) + :creator) + events? (timeline-event/include-events options))) + +(u/strict-extend (class Timeline) + models/IModel + (merge + models/IModelDefaults + {:properties (constantly {:timestamped? true})}) + + i/IObjectPermissions + perms/IObjectPermissionsForParentCollection) diff --git a/src/metabase/models/timeline_event.clj b/src/metabase/models/timeline_event.clj new file mode 100644 index 0000000000000000000000000000000000000000..0a6e046428483d80d60d135202bd3e09669cabb9 --- /dev/null +++ b/src/metabase/models/timeline_event.clj @@ -0,0 +1,83 @@ +(ns metabase.models.timeline-event + (:require [metabase.models.interface :as i] + [metabase.util :as u] + [metabase.util.honeysql-extensions :as hx] + [toucan.db :as db] + [toucan.hydrate :refer [hydrate]] + [toucan.models :as models])) + +(models/defmodel TimelineEvent :timeline_event) + +;;;; permissions + +(defn- perms-objects-set + [event read-or-write] + (let [timeline (or (:timeline event) + (db/select-one 'Timeline :id (:timeline_id event)))] + (i/perms-objects-set timeline read-or-write))) + +;;;; hydration + +(defn- fetch-events + "Fetch events for timelines in `timeline-ids`. Can include optional `start` and `end` dates in the options map, as + well as `all?`. By default, will return only unarchived events, unless `all?` is truthy and will return all events + regardless of archive state." + [timeline-ids {:events/keys [all? start end]}] + (let [clause {:where [:and + ;; in our collections + [:in :timeline_id timeline-ids] + (when-not all? + [:= :archived false]) + (when (or start end) + [:or + ;; absolute time in bounds + [:and + [:= :time_matters true] + ;; less than or equal? + (when start + [:<= start :timestamp]) + (when end + [:<= :timestamp end])] + ;; non-specic time in bounds + [:and + [:= :time_matters false] + (when start + [:<= (hx/->date start) (hx/->date :timestamp)]) + (when end + [:<= (hx/->date :timestamp) (hx/->date end)])]])]}] + (hydrate (db/select TimelineEvent clause) :creator))) + +(defn include-events + "Include events on `timelines` passed in. Options are optional and include whether to return unarchived events or all + events regardless of archive status (`all?`), and `start` and `end` parameters for events." + [timelines options] + (if-not (seq timelines) + [] + (let [timeline-id->events (->> (fetch-events (map :id timelines) options) + (group-by :timeline_id))] + (for [{:keys [id] :as timeline} timelines] + (let [events (timeline-id->events id)] + (when timeline + (assoc timeline :events (if events events [])))))))) + +(defn include-events-singular + "Similar to [[include-events]] but allows for passing a single timeline not in a collection." + ([timeline] (include-events-singular timeline {})) + ([timeline options] + (first (include-events [timeline] options)))) + +;;;; model + +(u/strict-extend (class TimelineEvent) + models/IModel + (merge + models/IModelDefaults + ;; todo: add hydration keys?? + {:properties (constantly {:timestamped? true})}) + + i/IObjectPermissions + (merge + i/IObjectPermissionsDefaults + {:perms-objects-set perms-objects-set + :can-read? (partial i/current-user-has-full-permissions? :read) + :can-write? (partial i/current-user-has-full-permissions? :write)})) diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj index a9c8a524bc442b7a04ced2fbf371ee12941e85c6..1dad3634baffe9d5a6cf5d1d7164acaf9aceb479 100644 --- a/src/metabase/util/schema.clj +++ b/src/metabase/util/schema.clj @@ -7,6 +7,7 @@ [medley.core :as m] [metabase.types :as types] [metabase.util :as u] + [metabase.util.date-2 :as u.date] [metabase.util.i18n :as i18n :refer [deferred-tru]] [metabase.util.password :as password] [schema.core :as s] @@ -312,6 +313,11 @@ (with-api-error-message (s/constrained s/Str boolean-string?) (deferred-tru "value must be a valid boolean string (''true'' or ''false'')."))) +(def TemporalString + "Schema for a string that can be parsed by date2/parse." + (with-api-error-message (s/constrained s/Str #(u/ignore-exceptions (boolean (u.date/parse %)))) + (deferred-tru "value must be a valid date string"))) + (def JSONString "Schema for a string that is valid serialized JSON." (with-api-error-message (s/constrained s/Str #(try diff --git a/test/metabase/api/collection_test.clj b/test/metabase/api/collection_test.clj index bf9fec21e5855c3568eecd8b7ff68d4ad8e686a0..751e61300576eaa72da17d8a84dd0b57f84435bc 100644 --- a/test/metabase/api/collection_test.clj +++ b/test/metabase/api/collection_test.clj @@ -6,7 +6,7 @@ [metabase.api.collection :as api-coll] [metabase.models :refer [Card Collection Dashboard DashboardCard ModerationReview NativeQuerySnippet PermissionsGroup PermissionsGroupMembership Pulse PulseCard PulseChannel - PulseChannelRecipient Revision User]] + PulseChannelRecipient Revision Timeline User]] [metabase.models.collection :as collection] [metabase.models.collection-test :as collection-test] [metabase.models.collection.graph :as graph] @@ -388,11 +388,26 @@ (testing "check that pinning filtering exists" (mt/with-temp* [Collection [collection] - Card [card3 {:collection_id (u/the-id collection) :collection_position 1}] - Card [card2 {:collection_id (u/the-id collection) :collection_position 1}] - Card [card1 {:collection_id (u/the-id collection)}]] - (is (= 2 (count (:data (mt/user-http-request :crowberto :get 200 (str "collection/" (u/the-id collection) "/items") :pinned_state "is_pinned"))))) - (is (= 1 (count (:data (mt/user-http-request :crowberto :get 200 (str "collection/" (u/the-id collection) "/items") :pinned_state "is_not_pinned"))))))) + Card [card3 {:collection_id (u/the-id collection) + :collection_position 1 + :name "pinned-1"}] + Card [card2 {:collection_id (u/the-id collection) + :collection_position 1 + :name "pinned-2"}] + Card [card1 {:collection_id (u/the-id collection) + :name "unpinned-card"}] + Timeline [timeline {:collection_id (u/the-id collection) + :name "timeline"}]] + (letfn [(fetch [pin-state] + (:data (mt/user-http-request :crowberto :get 200 + (str "collection/" (u/the-id collection) "/items") + :pinned_state pin-state)))] + (is (= #{"pinned-1" "pinned-2"} (->> (fetch "is_pinned") + (map :name) + set))) + (is (= #{"timeline" "unpinned-card"} (->> (fetch "is_not_pinned") + (map :name) + set)))))) (testing "check that you get to see the children as appropriate" (mt/with-temp Collection [collection {:name "Debt Collection"}] diff --git a/test/metabase/api/timeline_event_test.clj b/test/metabase/api/timeline_event_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..0557a16041bfdb158cd54148bf534d19aff2beca --- /dev/null +++ b/test/metabase/api/timeline_event_test.clj @@ -0,0 +1,61 @@ +(ns metabase.api.timeline-event-test + "Tests for /api/timeline-event endpoints" + (:require [clojure.test :refer :all] + [metabase.http-client :as http] + [metabase.models.collection :refer [Collection]] + [metabase.models.timeline :refer [Timeline]] + [metabase.models.timeline-event :refer [TimelineEvent]] + [metabase.server.middleware.util :as middleware.u] + [metabase.test :as mt] + [metabase.util :as u] + [toucan.db :as db])) + +(deftest auth-tests + (testing "Authentication" + (is (= (get middleware.u/response-unauthentic :body) + (http/client :get 401 "/timeline-event"))) + (is (= (get middleware.u/response-unauthentic :body) + (http/client :get 401 "/timeline-event/1"))))) + +(deftest get-timeline-event-test + (testing "GET /api/timeline-event/:id" + (mt/with-temp* [Collection [collection {:name "Important Data"}] + Timeline [timeline {:name "Important Events" + :collection_id (u/the-id collection)}] + TimelineEvent [event {:name "Very Important Event" + :timestamp (java.time.OffsetDateTime/now) + :time_matters false + :timeline_id (u/the-id timeline)}]] + (testing "check that we get the timeline-event with `id`" + (is (= "Very Important Event" + (->> (mt/user-http-request :rasta :get 200 (str "timeline-event/" (u/the-id event))) + :name))))))) + +(deftest create-timeline-event-test + (testing "POST /api/timeline-event" + (mt/with-temp* [Collection [collection {:name "Important Data"}] + Timeline [timeline {:name "Important Events" + :collection_id (u/the-id collection)}]] + (testing "create a timeline event" + ;; make an API call to create a timeline + (mt/user-http-request :rasta :post 200 "timeline-event" {:name "Rasta Migrates to Florida for the Winter" + :timestamp (java.time.OffsetDateTime/now) + :timezone "US/Pacific" + :time_matters false + :timeline_id (u/the-id timeline)})) + ;; check the Timeline to see if the event is there + (is (= "Rasta Migrates to Florida for the Winter" + (-> (db/select-one TimelineEvent :timeline_id (u/the-id timeline)) :name)))))) + +(deftest update-timeline-event-test + (testing "PUT /api/timeline-event/:id" + (testing "Can archive the timeline event" + (mt/with-temp* [Collection [collection {:name "Important Data"}] + Timeline [timeline {:name "Important Events" + :collection_id (u/the-id collection)}] + TimelineEvent [event {:name "Very Important Event" + :timeline_id (u/the-id timeline)}]] + (testing "check that we get the timeline-event with `id`" + (is (true? + (->> (mt/user-http-request :rasta :put 200 (str "timeline-event/" (u/the-id event)) {:archived true}) + :archived)))))))) diff --git a/test/metabase/api/timeline_test.clj b/test/metabase/api/timeline_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..6c46bec123c827840e1c1e9873d600aa0eb1fcd9 --- /dev/null +++ b/test/metabase/api/timeline_test.clj @@ -0,0 +1,137 @@ +(ns metabase.api.timeline-test + "Tests for /api/timeline endpoints." + (:require [clojure.test :refer :all] + [metabase.http-client :as http] + [metabase.models.collection :refer [Collection]] + [metabase.models.timeline :refer [Timeline]] + [metabase.models.timeline-event :refer [TimelineEvent]] + [metabase.server.middleware.util :as middleware.u] + [metabase.test :as mt] + [metabase.util :as u] + [toucan.db :as db])) + +(deftest auth-tests + (testing "Authentication" + (is (= (get middleware.u/response-unauthentic :body) + (http/client :get 401 "/timeline"))) + (is (= (get middleware.u/response-unauthentic :body) + (http/client :get 401 "/timeline/1"))))) + +(deftest list-timelines-test + (testing "GET /api/timeline" + (mt/with-temp Collection [collection {:name "Important Data"}] + (let [id (u/the-id collection) + events-of (fn [tls] + (into #{} (comp (filter (comp #{id} :collection_id)) + (map :name)) + tls))] + (mt/with-temp* [Timeline [tl-a {:name "Timeline A", :collection_id id}] + Timeline [tl-b {:name "Timeline B", :collection_id id}] + Timeline [tl-c {:name "Timeline C", :collection_id id + :archived true}]] + (testing "check that we only get un-archived timelines" + (is (= #{"Timeline A" "Timeline B"} + (events-of (mt/user-http-request :rasta :get 200 "timeline"))))) + (testing "check that we only get archived timelines when `archived=true`" + (is (= #{"Timeline C"} + (events-of (mt/user-http-request :rasta :get 200 "timeline" :archived true)))))))))) + +(deftest get-timeline-test + (testing "GET /api/timeline/:id" + (mt/with-temp* [Timeline [tl-a {:name "Timeline A"}] + Timeline [tl-b {:name "Timeline B" :archived true}]] + (testing "check that we get the timeline with the id specified" + (is (= "Timeline A" + (->> (mt/user-http-request :rasta :get 200 (str "timeline/" (u/the-id tl-a))) + :name)))) + (testing "check that we get the timeline with the id specified, even if the timeline is archived" + (is (= "Timeline B" + (->> (mt/user-http-request :rasta :get 200 (str "timeline/" (u/the-id tl-b))) + :name))))))) + +(deftest create-timeline-test + (testing "POST /api/timeline" + (mt/with-model-cleanup [Timeline] + (mt/with-temp Collection [collection {:name "Important Data"}] + (let [id (u/the-id collection)] + (testing "Create a new timeline" + ;; make an API call to create a timeline + (mt/user-http-request :rasta :post 200 "timeline" + {:name "Rasta's TL" + :creator_id (u/the-id (mt/fetch-user :rasta)) + :collection_id id}) + ;; check the collection to see if the timeline is there + (is (= "Rasta's TL" + (-> (db/select-one Timeline :collection_id id) :name))))))))) + +(deftest update-timeline-test + (testing "PUT /api/timeline/:id" + (mt/with-temp Collection [collection {:name "Important Data"}] + (mt/with-temp* [Timeline [tl-a {:name "Timeline A" :archived true}] + Timeline [tl-b {:name "Timeline B"}] + TimelineEvent [event-a {:name "event-a" + :timeline_id (u/the-id tl-b)}] + TimelineEvent [event-b {:name "event-b" + :timeline_id (u/the-id tl-b)}]] + (testing "check that we successfully updated a timeline" + (is (false? + (->> (mt/user-http-request :rasta :put 200 (str "timeline/" (u/the-id tl-a)) {:archived false}) + :archived)))) + (testing "check that we archive all events in a timeline when the timeline is archived" + ;; update the timeline to be archived + (mt/user-http-request :rasta :put 200 (str "timeline/" (u/the-id tl-b)) {:archived true}) + (is (true? + (->> (db/select TimelineEvent :timeline_id (u/the-id tl-b)) + (map :archived) + (every? true?))))) + (testing "check that we un-archive all events in a timeline when the timeline is un-archived" + ;; since we archived in the previous step, we unarchive the same timeline here. + (mt/user-http-request :rasta :put 200 (str "timeline/" (u/the-id tl-b)) {:archived false}) + (is (true? + (->> (db/select TimelineEvent :timeline_id (u/the-id tl-b)) + (map :archived) + (every? false?))))))))) + +(defn- include-events-request + [timeline archived?] + (mt/user-http-request :rasta :get 200 (str "timeline/" (u/the-id timeline)) + :include "events" :archived archived?)) + +(defn- event-names [timeline] + (->> timeline :events (map :name) set)) + +(deftest timeline-hydration-test + (testing "GET /api/timeline/:id?include=events" + (mt/with-temp Collection [collection {:name "Important Data"}] + (mt/with-temp* [Timeline [empty-tl {:name "Empty TL" + :collection_id (u/the-id collection)}] + Timeline [unarchived-tl {:name "Un-archived Events TL" + :collection_id (u/the-id collection)}] + Timeline [archived-tl {:name "Archived Events TL" + :collection_id (u/the-id collection)}] + Timeline [timeline {:name "All Events TL" + :collection_id (u/the-id collection)}]] + (mt/with-temp* [TimelineEvent [event-a {:name "event-a" + :timeline_id (u/the-id unarchived-tl)}] + TimelineEvent [event-b {:name "event-b" + :timeline_id (u/the-id unarchived-tl)}] + TimelineEvent [event-c {:name "event-c" + :timeline_id (u/the-id archived-tl) + :archived true}] + TimelineEvent [event-d {:name "event-d" + :timeline_id (u/the-id archived-tl) + :archived true}] + TimelineEvent [event-e {:name "event-e" + :timeline_id (u/the-id timeline)}] + TimelineEvent [event-f {:name "event-f" + :timeline_id (u/the-id timeline) + :archived true}]] + (testing "a timeline with no events returns an empty list" + (is (= #{} (event-names (include-events-request empty-tl false))))) + (testing "a timeline with both (un-)archived events" + (testing "Returns only unarchived events when archived is false" + (is (= #{"event-e"} + (event-names (include-events-request timeline false))))) + (testing "Returns all events when archived is true" + (is (= #{"event-e" "event-f"} + (event-names (include-events-request timeline true))))))))))) diff --git a/test/metabase/models/timeline_event_test.clj b/test/metabase/models/timeline_event_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..569953574605c2fbaad7c7d6d2a5f891087eab6c --- /dev/null +++ b/test/metabase/models/timeline_event_test.clj @@ -0,0 +1,29 @@ +(ns metabase.models.timeline-event-test + "Tests for TimelineEvent model namespace." + (:require [clojure.test :refer :all] + [metabase.models.collection :refer [Collection]] + [metabase.models.timeline :refer [Timeline]] + [metabase.models.timeline-event :as te :refer [TimelineEvent]] + [metabase.test :as mt] + [metabase.util :as u])) + +(defn- names [timelines] + (into #{} (comp (mapcat :events) (map :name)) timelines)) + +(deftest hydrate-events-test + (testing "hydrate-events function hydrates all timelines events" + (mt/with-temp* [Collection [collection {:name "Rasta's Collection"}] + Timeline [tl-a {:name "tl-a"}] + Timeline [tl-b {:name "tl-b"}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-a) :name "un-1"}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-a) :name "archived-1" + :archived true}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-b) :name "un-2"}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-b) :name "archived-2" + :archived true}]] + (testing "only unarchived events by default" + (is (= #{"un-1" "un-2"} + (names (te/include-events [tl-a tl-b] {}))))) + (testing "all events when specified" + (is (= #{"un-1" "un-2" "archived-1" "archived-2"} + (names (te/include-events [tl-a tl-b] {:events/all? true})))))))) diff --git a/test/metabase/models/timeline_test.clj b/test/metabase/models/timeline_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..cc1e2e8af71974e0d3674fa58a84738413883968 --- /dev/null +++ b/test/metabase/models/timeline_test.clj @@ -0,0 +1,36 @@ +(ns metabase.models.timeline-test + "Tests for the Timeline model." + (:require [clojure.test :refer :all] + [metabase.models.collection :refer [Collection]] + [metabase.models.timeline :as tl :refer [Timeline]] + [metabase.models.timeline-event :refer [TimelineEvent]] + [metabase.test :as mt] + [metabase.util :as u])) + +(deftest timelines-for-collection-test + (mt/with-temp Collection [collection {:name "Rasta's Collection"}] + (let [coll-id (u/the-id collection) + event-names (fn [timelines] + (into #{} (comp (mapcat :events) (map :name)) timelines))] + (mt/with-temp* [Timeline [tl-a {:name "tl-a" :collection_id coll-id}] + Timeline [tl-b {:name "tl-b" :collection_id coll-id}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-a) :name "e-a"}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-a) :name "e-b" :archived true}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-b) :name "e-c"}] + TimelineEvent [e-a {:timeline_id (u/the-id tl-b) :name "e-d" :archived true}]] + (testing "Fetching timelines" + (testing "don't include events by default" + (is (= #{} + (->> (tl/timelines-for-collection (u/the-id collection) {}) + event-names)))) + (testing "include only unarchived events by default" + (is (= #{"e-a" "e-c"} + (->> (tl/timelines-for-collection (u/the-id collection) + {:timeline/events? true}) + event-names)))) + (testing "can load all events if specify `:events/all?`" + (is (= #{"e-a" "e-b" "e-c" "e-d"} + (->> (tl/timelines-for-collection (u/the-id collection) + {:timeline/events? true + :events/all? true}) + event-names))))))))) diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index 9bb39dc0ed1ec3e1f67b5aa952e03b6a9ac3a868..5e4c84faa41bcce3b064fdead76619c2190256a3 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -13,7 +13,7 @@ [metabase.driver :as driver] [metabase.models :refer [Card Collection Dashboard DashboardCardSeries Database Dimension Field FieldValues LoginHistory Metric NativeQuerySnippet Permissions PermissionsGroup Pulse PulseCard - PulseChannel Revision Segment Table TaskHistory User]] + PulseChannel Revision Segment Table TaskHistory Timeline TimelineEvent User]] [metabase.models.collection :as collection] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as group] @@ -108,8 +108,8 @@ :color "#ABCDEF"}) Dashboard - (fn [_] {:creator_id (rasta-id) - :name (random-name)}) + (fn [_] {:creator_id (rasta-id) + :name (random-name)}) DashboardCardSeries (constantly {:position 0}) @@ -172,7 +172,7 @@ :is_reversion false}) Segment - (fn [_] {:creator_id (rasta-id) + (fn [_] {:creator_id (rasta-id) :definition {} :description "Lookin' for a blueberry" :name "Toucans in the rainforest" @@ -195,6 +195,19 @@ :ended_at ended :duration (.toMillis (t/duration started ended))})) + Timeline + (fn [_] + {:name "Timeline of bird squawks" + :creator_id (rasta-id)}) + + TimelineEvent + (fn [_] + {:name "default timeline event" + :timestamp (t/zoned-date-time) + :timezone "US/Pacific" + :time_matters true + :creator_id (rasta-id)}) + User (fn [_] {:first_name (random-name) :last_name (random-name)