diff --git a/src/main/lrsql/admin/interceptors/lrs_management.clj b/src/main/lrsql/admin/interceptors/lrs_management.clj index 76f326eb..bcc4fddd 100644 --- a/src/main/lrsql/admin/interceptors/lrs_management.clj +++ b/src/main/lrsql/admin/interceptors/lrs_management.clj @@ -1,9 +1,19 @@ (ns lrsql.admin.interceptors.lrs-management (:require [clojure.spec.alpha :as s] + [clojure.data.csv :as csv] + [clojure.edn :as edn] + [clojure.java.io :as io] [io.pedestal.interceptor :refer [interceptor]] [io.pedestal.interceptor.chain :as chain] [lrsql.admin.protocol :as adp] - [lrsql.spec.admin :as ads])) + [lrsql.spec.admin :as ads] + [com.yetanalytics.lrs.pedestal.interceptor.xapi :as i-xapi] + [com.yetanalytics.lrs-reactions.spec :as rs]) + (:import [javax.servlet ServletOutputStream])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Actor Delete +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def validate-delete-actor-params (interceptor @@ -31,3 +41,62 @@ (assoc ctx :response {:status 200 :body params})))})) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CSV Download +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def validate-property-paths + (interceptor + {:name ::validate-property-paths + :enter + (fn validate-property-paths [ctx] + (let [property-paths (-> ctx + (get-in [:request + :params + :property-paths]) + edn/read-string)] + (if-some [e (s/explain-data (s/every ::rs/path) property-paths)] + (assoc (chain/terminate ctx) + :response + {:status 400 + :body {:error (format "Invalid property paths:\n%s" + (-> e s/explain-out with-out-str))}}) + ;; Need to dissoc since lrs.pedestal.interceptor.xapi/params-interceptor + ;; restricts allowed keys in the query param map. + (-> ctx + (update-in [:request :params] dissoc :property-paths) + (update-in [:request :query-params] dissoc :property-paths) + (assoc-in [:request :property-paths] property-paths)))))})) + +(def validate-query-params + (interceptor + (i-xapi/params-interceptor :xapi.statements.GET.request/params))) + +(def csv-response-header + {"Content-Type" "text/csv" + "Content-Disposition" "attachment"}) + +(defn- stream-csv + [csv-data-seq] + (fn [^ServletOutputStream os] + (with-open [writer (io/writer os)] + (csv/write-csv writer csv-data-seq :newline :cr+lf)))) + +(def download-statement-csv + (interceptor + {:name ::download-statement-csv + :enter + (fn download-statement-csv [ctx] + (let [{lrs :com.yetanalytics/lrs + request :request} + ctx + {:keys [property-paths query-params]} + request + csv-data-seq (adp/-get-statements-csv lrs + property-paths + query-params)] + (assoc ctx + :response {:status 200 + :headers csv-response-header + :body (stream-csv csv-data-seq)})))})) diff --git a/src/main/lrsql/admin/routes.clj b/src/main/lrsql/admin/routes.clj index 170d9022..79893f07 100644 --- a/src/main/lrsql/admin/routes.clj +++ b/src/main/lrsql/admin/routes.clj @@ -262,13 +262,21 @@ ri/delete-reaction) :route-name :lrsql.admin.reaction/delete]}) -(defn admin-lrs-management-routes [common-interceptors jwt-secret jwt-leeway no-val-opts] +(defn admin-lrs-management-routes + [common-interceptors jwt-secret jwt-leeway no-val-opts] #{["/admin/agents" :delete (conj common-interceptors lm/validate-delete-actor-params (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) ji/validate-jwt-account lm/delete-actor) - :route-name :lrsql.lrs-management/delete-actor]}) + :route-name :lrsql.lrs-management/delete-actor] + ["/admin/csv" :get (conj common-interceptors + lm/validate-property-paths + lm/validate-query-params + (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + lm/download-statement-csv) + :route-name :lrsql.lrs-management/download-csv]}) (defn add-admin-routes "Given a set of routes `routes` for a default LRS implementation, diff --git a/src/test/lrsql/admin/route_test.clj b/src/test/lrsql/admin/route_test.clj index 8ec278a5..cec7af60 100644 --- a/src/test/lrsql/admin/route_test.clj +++ b/src/test/lrsql/admin/route_test.clj @@ -4,6 +4,7 @@ (:require [clojure.test :refer [deftest testing is use-fixtures are]] [clojure.string :refer [lower-case]] [babashka.curl :as curl] + [ring.util.codec :refer [url-encode]] [com.stuartsierra.component :as component] [xapi-schema.spec.regex :refer [Base64RegEx]] [com.yetanalytics.lrs.protocol :as lrsp] @@ -284,6 +285,22 @@ "new-password" orig-pass}) :status (= 200)))))) + (testing "download CSV data" + (let [property-paths-vec [["id"] ["actor" "mbox"]] + property-paths-str (url-encode (str property-paths-vec)) + endpoint-url (format "http://0.0.0.0:8080/admin/csv?property-paths=%s&ascending=true" + property-paths-str) + {:keys [status body]} (curl/get endpoint-url + {:headers headers + :as :stream}) + csv-body (slurp body)] + (is (= 200 status)) + (is (= "id,actor_mbox\r\n" csv-body))) + (let [bad-prop-path (->> ["zoo" "wee" "mama"] str url-encode) + bad-url (format "http://0.0.0.0:8080/admin/csv?property-paths=%s" + bad-prop-path)] + (is-err-code (curl/get bad-url {:headers headers :as :stream}) + 400))) (testing "delete the `myname` account using the seed account" (let [del-jwt (-> (login-account content-type req-body) :body