diff --git a/resources/frontend_client/app/admin/databases/databases.controllers.js b/resources/frontend_client/app/admin/databases/databases.controllers.js index 85559d894b3bfaef118757a04f9852a78684d8c1..c9e7d72e770a4d832dc671f18077b97ccdcfa5b5 100644 --- a/resources/frontend_client/app/admin/databases/databases.controllers.js +++ b/resources/frontend_client/app/admin/databases/databases.controllers.js @@ -9,6 +9,15 @@ DatabasesControllers.controller('DatabaseList', ['$scope', 'Metabase', 'Metabase $scope.ENGINES = MetabaseCore.ENGINES; $scope.databases = []; + $scope.hasSampleDataset = false; + + function hasSampleDataset(databases) { + for (let i=0; i < databases.length; i++) { + if (databases[i].is_sample) return true; + } + + return false; + } $scope.delete = function(databaseId) { if ($scope.databases) { @@ -19,14 +28,27 @@ DatabasesControllers.controller('DatabaseList', ['$scope', 'Metabase', 'Metabase $scope.databases = _.filter($scope.databases, function(database) { return database.id != databaseId; }); + $scope.hasSampleDataset = hasSampleDataset($scope.databases); }, function(error) { console.log('error deleting database', error); }); } }; + $scope.addSampleDataset = function() { + if (!hasSampleDataset($scope.databases)) { + Metabase.db_add_sample_dataset().$promise.then(function(result) { + $scope.databases.push(result); + $scope.hasSampleDataset = true; + }, function(error) { + console.log('error adding sample dataset', error); + }); + } + }; + Metabase.db_list(function(databases) { $scope.databases = databases; + $scope.hasSampleDataset = hasSampleDataset(databases); }, function(error) { console.log('error getting database list', error); }); diff --git a/resources/frontend_client/app/admin/databases/partials/database_list.html b/resources/frontend_client/app/admin/databases/partials/database_list.html index e0e2c2997dd599c7669acc2c5612aca62dd5f2c6..3a6c5740c1ad5b3aa351ed8b7cb0a157b7177714 100644 --- a/resources/frontend_client/app/admin/databases/partials/database_list.html +++ b/resources/frontend_client/app/admin/databases/partials/database_list.html @@ -34,5 +34,8 @@ </tr> </tbody> </table> + <div class="pt4" ng-if="!hasSampleDataset"> + <span class="p2 text-italic" ng-class="{'border-top': databases.length > 0}"><a class="text-grey-2 text-brand-hover no-decoration" href="" ng-click="addSampleDataset()">Bring the sample dataset back</a></span> + </div> </section> </div> diff --git a/resources/frontend_client/app/components/form/FormLabel.react.js b/resources/frontend_client/app/components/form/FormLabel.react.js index cd4b0b278d3023a0475d0a0bf22ff4d6ce6752f2..68f9c56fabf139661d16ffeadaa3ae17ab703f36 100644 --- a/resources/frontend_client/app/components/form/FormLabel.react.js +++ b/resources/frontend_client/app/components/form/FormLabel.react.js @@ -19,9 +19,12 @@ export default class FormLabel extends Component { } } +FormLabel.defaultProps = { + offset: true +}; + FormLabel.propTypes = { fieldName: PropTypes.string.isRequired, formError: PropTypes.object, message: PropTypes.string, - offset: true, -} +}; diff --git a/resources/frontend_client/app/metabase/metabase.services.js b/resources/frontend_client/app/metabase/metabase.services.js index 3a55bb40c619bf372a9567952e5282bc994786ed..78c97f57fd63755471129687381e6e18e330ad3c 100644 --- a/resources/frontend_client/app/metabase/metabase.services.js +++ b/resources/frontend_client/app/metabase/metabase.services.js @@ -44,6 +44,10 @@ MetabaseServices.factory('Metabase', ['$resource', '$cookies', 'MetabaseCore', f return angular.toJson(data); } }, + db_add_sample_dataset: { + url: '/api/database/sample_dataset', + method: 'POST' + }, db_get: { url: '/api/database/:dbId', method: 'GET', diff --git a/resources/migrations/017_add_database_is_sample_field.yaml b/resources/migrations/017_add_database_is_sample_field.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d99263a36d327b6476def89d245f76ff9271d09b --- /dev/null +++ b/resources/migrations/017_add_database_is_sample_field.yaml @@ -0,0 +1,16 @@ +databaseChangeLog: + - changeSet: + id: 17 + author: agilliland + changes: + - addColumn: + tableName: metabase_database + columns: + - column: + name: is_sample + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + - sql: + sql: update metabase_database set is_sample = true where name = 'Sample Dataset' diff --git a/resources/migrations/liquibase.json b/resources/migrations/liquibase.json index 490df4885c8af7a709e1a423a92661f6c0f11ed3..815d1513dc9eed314ee400bf161496b6d2afd20f 100644 --- a/resources/migrations/liquibase.json +++ b/resources/migrations/liquibase.json @@ -14,6 +14,7 @@ {"include": {"file": "migrations/013_add_activity_table.yaml"}}, {"include": {"file": "migrations/014_add_view_log_table.yaml"}}, {"include": {"file": "migrations/015_add_revision_is_creation_field.yaml"}}, - {"include": {"file": "migrations/016_user_last_login_allow_null.yaml"}} + {"include": {"file": "migrations/016_user_last_login_allow_null.yaml"}}, + {"include": {"file": "migrations/017_add_database_is_sample_field.yaml"}} ] } diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index df4b999f6d6d3e341c795d55b27a25a617de0b8b..7fa4e2a8f06d841a0489868a5d9bab011571f127 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -11,6 +11,7 @@ [database :refer [Database]] [field :refer [Field]] [table :refer [Table]]) + [metabase.sample-data :as sample-data] [metabase.util :as u])) (defannotation DBEngine @@ -34,6 +35,13 @@ (let-500 [new-db (ins Database :name name :engine engine :details details)] (events/publish-event :database-create new-db))) +(defendpoint POST "/sample_dataset" + "Add the sample dataset as a new `Database`." + [] + (check-superuser) + (sample-data/add-sample-dataset!) + (sel :one Database :is_sample true)) + (defendpoint GET "/form_input" "Values of options for the create/edit `Database` UI." [] diff --git a/src/metabase/core.clj b/src/metabase/core.clj index 0bd1eb1d6f4851a2cb954c8b28fc3d7b0941e983..ef5156a431ff4aec37044e3c8f943227c1bc83b8 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -1,8 +1,7 @@ ;; -*- comment-column: 35; -*- (ns metabase.core (:gen-class) - (:require [clojure.java.io :as io] - [clojure.string :as s] + (:require [clojure.string :as s] [clojure.tools.logging :as log] [colorize.core :as color] [ring.adapter.jetty :as ring-jetty] @@ -16,10 +15,10 @@ [medley.core :as m] (metabase [config :as config] [db :as db] - [driver :as driver] [events :as events] [middleware :as mb-middleware] [routes :as routes] + [sample-data :as sample-data] [setup :as setup] [task :as task] [util :as u]) @@ -69,6 +68,10 @@ wrap-session ; reads in current HTTP session and sets :session/key wrap-gzip)) ; GZIP response if client can handle it + +;;; ## ---------------------------------------- LIFECYCLE ---------------------------------------- + + (defn- -init-create-setup-token "Create and set a new setup token, and open the setup URL on the user's system." [] @@ -97,29 +100,40 @@ (log/info (format "Starting Metabase version %s..." ((config/mb-version-info) :long))) ;; First of all, lets register a shutdown hook that will tidy things up for us on app exit (.addShutdownHook (Runtime/getRuntime) (Thread. ^Runnable destroy)) - (log/debug "Using Config:\n" (with-out-str (clojure.pprint/pprint config/config-all))) - - ;; Bootstrap the event system - (events/initialize-events!) ;; startup database. validates connection & runs any necessary migrations (db/setup-db :auto-migrate (config/config-bool :mb-db-automigrate)) ;; run a very quick check to see if we are doing a first time installation ;; the test we are using is if there is at least 1 User in the database - (when-not (db/sel :one :fields [User :id]) - (log/info "Looks like this is a new installation ... preparing setup wizard") - (-init-create-setup-token) - (events/publish-event :install {})) + (let [new-install (nil? (db/sel :one :fields [User :id]))] + + ;; Bootstrap the event system + (events/initialize-events!) - ;; Now start the task runner - (task/start-scheduler!) + ;; Now start the task runner + (task/start-scheduler!) + + (when new-install + (log/info "Looks like this is a new installation ... preparing setup wizard") + ;; create setup token + (-init-create-setup-token) + ;; publish install event + (events/publish-event :install {})) + + ;; deal with our sample dataset as needed + (if new-install + ;; add the sample dataset DB for fresh installs + (sample-data/add-sample-dataset!) + ;; otherwise update if appropriate + (sample-data/update-sample-dataset-if-needed!))) (log/info "Metabase Initialization COMPLETE") true) -;; ## Jetty (Web) Server +;;; ## ---------------------------------------- Jetty (Web) Server ---------------------------------------- + (def ^:private jetty-instance (atom nil)) @@ -148,43 +162,15 @@ (.stop ^org.eclipse.jetty.server.Server @jetty-instance) (reset! jetty-instance nil))) -(def ^:private ^:const sample-dataset-name "Sample Dataset") -(def ^:private ^:const sample-dataset-filename "sample-dataset.db.mv.db") - -(defsetting sample-dataset-id - "The string-serialized integer ID of the `Database` entry for the Sample Dataset. If this is `nil`, the Sample Dataset - hasn't been loaded yet, and we should do so; otherwise we've already loaded it, and should not do so again. Keep in - mind the user may delete the Sample Dataset's DB, so this ID is not guaranteed to correspond to an existent object." - nil - :internal true) ; don't expose in the UI - -(defn- add-sample-dataset! [] - (when-not (sample-dataset-id) - (try - (log/info "Loading sample dataset...") - (let [resource (io/resource sample-dataset-filename)] - (if-not resource - (log/error (format "Can't load sample dataset: the DB file '%s' can't be found." sample-dataset-filename)) - (let [h2-file (-> (.getPath resource) - (s/replace #"^file:" "zip:") ; to connect to an H2 DB inside a JAR just replace file: with zip: - (s/replace #"\.mv\.db$" "") ; strip the .mv.db suffix from the path - (str ";USER=GUEST;PASSWORD=guest")) ; specify the GUEST user account created for the DB - db (db/ins Database - :name sample-dataset-name - :details {:db h2-file} - :engine :h2)] - (driver/sync-database! db) - (sample-dataset-id (str (:id db)))))) - (catch Throwable e - (log/error (format "Failed to load sample dataset: %s" (.getMessage e))))))) + +;;; ## ---------------------------------------- App Main ---------------------------------------- + (defn- start-normally [] (log/info "Starting Metabase in STANDALONE mode") (try ;; run our initialization process (init) - ;; add the sample dataset DB if applicable - (add-sample-dataset!) ;; launch embedded webserver (start-jetty) (catch Exception e diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj index 6ba0d0d881e1bdf7124919f14c48e4ab14a8b562..792098c84c88fbdc2f8e8b5ac1cae9e50b997bc7 100644 --- a/src/metabase/db/migrations.clj +++ b/src/metabase/db/migrations.clj @@ -2,7 +2,10 @@ (:require [clojure.tools.logging :as log] [korma.core :as k] [metabase.db :as db] - [metabase.models.card :refer [Card]])) + (metabase.models [card :refer [Card]] + [database :refer [Database]] + [setting :as setting]) + [metabase.sample-data :as sample-data])) (defn- set-card-database-and-table-ids "Upgrade for the `Card` model when `:database_id`, `:table_id`, and `:query_type` were added and needed populating. diff --git a/src/metabase/events.clj b/src/metabase/events.clj index 7b260a2138b72cf903b5c2825b8d45d56743a49c..42b5e84cd7dd56b2ff770a57dfb58e79d1234ce1 100644 --- a/src/metabase/events.clj +++ b/src/metabase/events.clj @@ -31,7 +31,8 @@ (log/info "\tloading events namespace: " events-ns) (require events-ns) ;; look for `events-init` function in the namespace and call it if it exists - ((ns-resolve events-ns 'events-init)))) + (when-let [init-fn (ns-resolve events-ns 'events-init)] + (init-fn)))) dorun)) (defn initialize-events! diff --git a/src/metabase/events/activity_feed.clj b/src/metabase/events/activity_feed.clj index c93fdca03654615e58a9dbd9a2fb9e16d2ef6ef4..0387c9ac84dc3a5bbe095d5664d92590c924ab11 100644 --- a/src/metabase/events/activity_feed.clj +++ b/src/metabase/events/activity_feed.clj @@ -6,6 +6,7 @@ [metabase.events :as events] (metabase.models [activity :refer [Activity]] [dashboard :refer [Dashboard]] + [database :refer [Database]] [session :refer [Session]]))) @@ -79,17 +80,20 @@ :dashboard-remove-cards (record-activity topic object add-remove-card-details)))) (defn- process-database-activity [topic object] - (let [database-details-fn (fn [obj] (-> obj + (let [database (db/sel :one Database :id (events/object->model-id topic object)) + database-details-fn (fn [obj] (-> obj (assoc :status "started") (dissoc :database_id :custom_id))) - database-table-fn (fn [obj] {:database-id (events/object->model-id topic obj)})] - (case topic - :database-sync-begin (record-activity :database-sync object database-details-fn database-table-fn) - :database-sync-end (let [{activity-id :id} (db/sel :one Activity :custom_id (:custom_id object))] - (db/upd Activity activity-id - :details (-> object - (assoc :status "completed") - (dissoc :database_id :custom_id))))))) + database-table-fn (fn [obj] {:database-id (events/object->model-id topic obj)})] + ;; NOTE: we are skipping any handling of activity for sample databases + (when (= false (:is_sample database)) + (case topic + :database-sync-begin (record-activity :database-sync object database-details-fn database-table-fn) + :database-sync-end (let [{activity-id :id} (db/sel :one Activity :custom_id (:custom_id object))] + (db/upd Activity activity-id + :details (-> object + (assoc :status "completed") + (dissoc :database_id :custom_id)))))))) (defn- process-user-activity [topic object] ;; we only care about login activity when its the users first session (a.k.a. new user!) diff --git a/src/metabase/events/last_login.clj b/src/metabase/events/last_login.clj index c8428a6780be4291a8546aa83d3e6fca2df7ac6d..f1a76b5a87d928bd7923de4838fb1c4cf7272c84 100644 --- a/src/metabase/events/last_login.clj +++ b/src/metabase/events/last_login.clj @@ -38,7 +38,7 @@ ;;; ## ---------------------------------------- LIFECYLE ---------------------------------------- -;; this is what actually kicks off our listener for events -(when (config/is-dev?) - (log/info "Starting last-login events listener") - (events/start-event-listener last-login-topics last-login-channel process-last-login-event)) +(defn events-init [] + (when-not (config/is-test?) + (log/info "Starting last-login events listener") + (events/start-event-listener last-login-topics last-login-channel process-last-login-event))) diff --git a/src/metabase/sample_data.clj b/src/metabase/sample_data.clj new file mode 100644 index 0000000000000000000000000000000000000000..d1ea45e7636e78b0b6db223140bf65cf2b52869a --- /dev/null +++ b/src/metabase/sample_data.clj @@ -0,0 +1,37 @@ +(ns metabase.sample-data + (:require [clojure.java.io :as io] + [clojure.string :as s] + [clojure.tools.logging :as log] + [metabase.db :as db] + [metabase.driver :as driver] + (metabase.models [setting :refer [defsetting]] + [database :refer [Database]]))) + + +(def ^:const sample-dataset-name "Sample Dataset") +(def ^:const sample-dataset-filename "sample-dataset.db.mv.db") + +(defn add-sample-dataset! [] + (when-not (db/sel :one Database :is_sample true) + (try + (log/info "Loading sample dataset...") + (let [resource (io/resource sample-dataset-filename)] + (if-not resource + (log/error (format "Can't load sample dataset: the DB file '%s' can't be found." sample-dataset-filename)) + (let [h2-file (-> (.getPath resource) + (s/replace #"^file:" "zip:") ; to connect to an H2 DB inside a JAR just replace file: with zip: + (s/replace #"\.mv\.db$" "") ; strip the .mv.db suffix from the path + (str ";USER=GUEST;PASSWORD=guest")) ; specify the GUEST user account created for the DB + db (db/ins Database + :name sample-dataset-name + :details {:db h2-file} + :engine :h2 + :is_sample true)] + (driver/sync-database! db)))) + (catch Throwable e + (log/error (format "Failed to load sample dataset: %s" (.getMessage e))))))) + +(defn update-sample-dataset-if-needed! [] + ;; TODO - it would be a bit nicer if we skipped this when the data hasn't changed + (when-let [db (db/sel :one Database :is_sample true)] + (driver/sync-database! db))) diff --git a/src/metabase/task.clj b/src/metabase/task.clj index d5d0538b1faeab82c166552a567fc01661047ad9..712e12bd4c54b17b46e88da31b22e5d79d9101c0 100644 --- a/src/metabase/task.clj +++ b/src/metabase/task.clj @@ -27,7 +27,8 @@ (log/info "\tloading tasks namespace: " events-ns) (require events-ns) ;; look for `task-init` function in the namespace and call it if it exists - ((ns-resolve events-ns 'task-init)))) + (when-let [init-fn (ns-resolve events-ns 'task-init)] + (init-fn)))) dorun)) (defn start-scheduler! @@ -45,7 +46,8 @@ [] (log/debug "Stopping Quartz Scheduler") ;; tell quartz to stop everything - (qs/shutdown @quartz-scheduler) + (when @quartz-scheduler + (qs/shutdown @quartz-scheduler)) ;; reset our scheduler reference (reset! quartz-scheduler nil)) diff --git a/src/metabase/task/sync_databases.clj b/src/metabase/task/sync_databases.clj index a5ba542f91c143d763afa3371c2e35eb1d5fab34..87163e8d6ce035e12e2f585e7e381c6c9e1a937b 100644 --- a/src/metabase/task/sync_databases.clj +++ b/src/metabase/task/sync_databases.clj @@ -4,7 +4,6 @@ [triggers :as triggers]) [clojurewerkz.quartzite.schedule.cron :as cron] (metabase [config :as config] - [core :refer [sample-dataset-id]] [db :as db] [driver :as driver] [task :as task]) @@ -20,7 +19,7 @@ (jobs/defjob SyncDatabases [ctx] (dorun - (for [database (db/sel :many Database, :id [not= (some-> (sample-dataset-id) Integer/parseInt)])] ; skip Sample Dataset DB + (for [database (db/sel :many Database :is_sample false)] ; skip Sample Dataset DB (try ;; NOTE: this happens synchronously for now to avoid excessive load if there are lots of databases (driver/sync-database! database) diff --git a/test/metabase/api/database_test.clj b/test/metabase/api/database_test.clj index 3bf5012f54c5b3463563110973ad59dec23f8512..9f7a6196a83d802f393b18458a42321431e32a6a 100644 --- a/test/metabase/api/database_test.clj +++ b/test/metabase/api/database_test.clj @@ -49,6 +49,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) ((user->client :rasta) :get 200 (format "database/%d" (db-id)))) @@ -62,6 +63,7 @@ :details $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) ((user->client :crowberto) :get 200 (format "database/%d" (db-id)))) @@ -77,6 +79,7 @@ :details {:host "localhost", :port 5432, :dbname "fakedb", :user "cam"} :updated_at $ :name db-name + :is_sample false :organization_id nil :description nil}) (create-db db-name))) @@ -132,6 +135,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}))) (match-$ (sel :one Database :name db-name) @@ -140,6 +144,7 @@ :id $ :updated_at $ :name $ + :is_sample false :organization_id nil :description nil})))) (do @@ -163,6 +168,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil :tables [(match-$ (Table (id :categories)) diff --git a/test/metabase/api/field_test.clj b/test/metabase/api/field_test.clj index 14c95d68128852dbc4dc1d31fe1ea4708cccfc21..14b9bba345d4abe4aa571d791914f5dc04bdd26d 100644 --- a/test/metabase/api/field_test.clj +++ b/test/metabase/api/field_test.clj @@ -25,6 +25,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) :name "USERS" diff --git a/test/metabase/api/table_test.clj b/test/metabase/api/table_test.clj index 03a3aa842852e15c22cd8537aa588efe0f7be34b..39bb606a814450cb2013c32b8b798924ad645862 100644 --- a/test/metabase/api/table_test.clj +++ b/test/metabase/api/table_test.clj @@ -67,6 +67,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) :name "VENUES" @@ -128,6 +129,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) :name "CATEGORIES" @@ -208,6 +210,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) :name "USERS" @@ -321,6 +324,7 @@ :id $ :updated_at $ :name "Test Database" + :is_sample false :organization_id nil :description nil}) :name "USERS" @@ -418,6 +422,7 @@ {:description nil :organization_id $ :name "Test Database" + :is_sample false :updated_at $ :details $ :id $ @@ -484,6 +489,7 @@ {:description nil, :organization_id nil, :name "Test Database", + :is_sample false, :updated_at $, :id $, :engine "h2",