mirror of
https://github.com/penpot/penpot.git
synced 2025-02-03 04:49:03 -05:00
Merge pull request #4905 from penpot/niwinz-srepl-improvements
✨ Add proper deletion/restore helpers to srepl/main
This commit is contained in:
commit
2431cb40bf
2 changed files with 265 additions and 84 deletions
|
@ -192,15 +192,33 @@
|
||||||
(::webhooks/event? resultm)
|
(::webhooks/event? resultm)
|
||||||
false)}))
|
false)}))
|
||||||
|
|
||||||
(defn- handle-event!
|
(defn- event->params
|
||||||
[cfg event]
|
[event]
|
||||||
(let [params {:id (uuid/next)
|
(let [params {:id (uuid/next)
|
||||||
:name (::name event)
|
:name (::name event)
|
||||||
:type (::type event)
|
:type (::type event)
|
||||||
:profile-id (::profile-id event)
|
:profile-id (::profile-id event)
|
||||||
:ip-addr (::ip-addr event)
|
:ip-addr (::ip-addr event "0.0.0.0")
|
||||||
:context (::context event)
|
:context (::context event {})
|
||||||
:props (::props event)}
|
:props (::props event {})
|
||||||
|
:source "backend"}
|
||||||
|
tnow (::tracked-at event)]
|
||||||
|
|
||||||
|
(cond-> params
|
||||||
|
(some? tnow)
|
||||||
|
(assoc :tracked-at tnow))))
|
||||||
|
|
||||||
|
(defn- append-audit-entry!
|
||||||
|
[cfg params]
|
||||||
|
(let [params (-> params
|
||||||
|
(update :props db/tjson)
|
||||||
|
(update :context db/tjson)
|
||||||
|
(update :ip-addr db/inet))]
|
||||||
|
(db/insert! cfg :audit-log params)))
|
||||||
|
|
||||||
|
(defn- handle-event!
|
||||||
|
[cfg event]
|
||||||
|
(let [params (event->params event)
|
||||||
tnow (dt/now)]
|
tnow (dt/now)]
|
||||||
|
|
||||||
(when (contains? cf/flags :audit-log)
|
(when (contains? cf/flags :audit-log)
|
||||||
|
@ -209,12 +227,8 @@
|
||||||
;; this case we just retry the operation.
|
;; this case we just retry the operation.
|
||||||
(let [params (-> params
|
(let [params (-> params
|
||||||
(assoc :created-at tnow)
|
(assoc :created-at tnow)
|
||||||
(assoc :tracked-at tnow)
|
(update :tracked-at #(or % tnow)))]
|
||||||
(update :props db/tjson)
|
(append-audit-entry! cfg params)))
|
||||||
(update :context db/tjson)
|
|
||||||
(update :ip-addr db/inet)
|
|
||||||
(assoc :source "backend"))]
|
|
||||||
(db/insert! cfg :audit-log params)))
|
|
||||||
|
|
||||||
(when (and (or (contains? cf/flags :telemetry)
|
(when (and (or (contains? cf/flags :telemetry)
|
||||||
(cf/get :telemetry-enabled))
|
(cf/get :telemetry-enabled))
|
||||||
|
@ -226,12 +240,11 @@
|
||||||
;; NOTE: this is only executed when general audit log is disabled
|
;; NOTE: this is only executed when general audit log is disabled
|
||||||
(let [params (-> params
|
(let [params (-> params
|
||||||
(assoc :created-at tnow)
|
(assoc :created-at tnow)
|
||||||
(assoc :tracked-at tnow)
|
(update :tracked-at #(or % tnow))
|
||||||
(assoc :props (db/tjson {}))
|
(assoc :props {})
|
||||||
(assoc :context (db/tjson {}))
|
(assoc :context {})
|
||||||
(assoc :ip-addr (db/inet "0.0.0.0"))
|
(assoc :ip-addr "0.0.0.0"))]
|
||||||
(assoc :source "backend"))]
|
(append-audit-entry! cfg params)))
|
||||||
(db/insert! cfg :audit-log params)))
|
|
||||||
|
|
||||||
(when (and (contains? cf/flags :webhooks)
|
(when (and (contains? cf/flags :webhooks)
|
||||||
(::webhooks/event? event))
|
(::webhooks/event? event))
|
||||||
|
@ -258,9 +271,9 @@
|
||||||
|
|
||||||
(defn submit!
|
(defn submit!
|
||||||
"Submit audit event to the collector."
|
"Submit audit event to the collector."
|
||||||
[cfg params]
|
[cfg event]
|
||||||
(try
|
(try
|
||||||
(let [event (d/without-nils params)
|
(let [event (d/without-nils event)
|
||||||
cfg (-> cfg
|
cfg (-> cfg
|
||||||
(assoc ::rtry/when rtry/conflict-exception?)
|
(assoc ::rtry/when rtry/conflict-exception?)
|
||||||
(assoc ::rtry/max-retries 6)
|
(assoc ::rtry/max-retries 6)
|
||||||
|
@ -269,3 +282,18 @@
|
||||||
(rtry/invoke! cfg db/tx-run! handle-event! event))
|
(rtry/invoke! cfg db/tx-run! handle-event! event))
|
||||||
(catch Throwable cause
|
(catch Throwable cause
|
||||||
(l/error :hint "unexpected error processing event" :cause cause))))
|
(l/error :hint "unexpected error processing event" :cause cause))))
|
||||||
|
|
||||||
|
(defn insert!
|
||||||
|
"Submit audit event to the collector, intended to be used only from
|
||||||
|
command line helpers because this skips all webhooks and telemetry
|
||||||
|
logic."
|
||||||
|
[cfg event]
|
||||||
|
(when (contains? cf/flags :audit-log)
|
||||||
|
(let [event (d/without-nils event)]
|
||||||
|
(us/verify! ::event event)
|
||||||
|
(db/run! cfg (fn [cfg]
|
||||||
|
(let [tnow (dt/now)
|
||||||
|
params (-> (event->params event)
|
||||||
|
(assoc :created-at tnow)
|
||||||
|
(update :tracked-at #(or % tnow)))]
|
||||||
|
(append-audit-entry! cfg params)))))))
|
||||||
|
|
|
@ -21,8 +21,10 @@
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
[app.db.sql :as-alias sql]
|
||||||
[app.features.components-v2 :as feat.comp-v2]
|
[app.features.components-v2 :as feat.comp-v2]
|
||||||
[app.features.fdata :as feat.fdata]
|
[app.features.fdata :as feat.fdata]
|
||||||
|
[app.loggers.audit :as audit]
|
||||||
[app.main :as main]
|
[app.main :as main]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
[app.rpc.commands.auth :as auth]
|
[app.rpc.commands.auth :as auth]
|
||||||
|
@ -38,10 +40,12 @@
|
||||||
[app.util.pointer-map :as pmap]
|
[app.util.pointer-map :as pmap]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
[app.worker :as wrk]
|
[app.worker :as wrk]
|
||||||
|
[clojure.java.io :as io]
|
||||||
[clojure.pprint :refer [print-table]]
|
[clojure.pprint :refer [print-table]]
|
||||||
[clojure.stacktrace :as strace]
|
[clojure.stacktrace :as strace]
|
||||||
[clojure.tools.namespace.repl :as repl]
|
[clojure.tools.namespace.repl :as repl]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
[promesa.exec :as px]
|
[promesa.exec :as px]
|
||||||
[promesa.exec.semaphore :as ps]
|
[promesa.exec.semaphore :as ps]
|
||||||
[promesa.util :as pu]))
|
[promesa.util :as pu]))
|
||||||
|
@ -475,6 +479,27 @@
|
||||||
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(defn delete-file!
|
||||||
|
"Mark a project for deletion"
|
||||||
|
[file-id]
|
||||||
|
(let [file-id (h/parse-uuid file-id)
|
||||||
|
tnow (dt/now)]
|
||||||
|
|
||||||
|
(audit/insert! main/system
|
||||||
|
{::audit/name "delete-file"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props {:id file-id}
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to delete-file!"}
|
||||||
|
::audit/tracked-at tnow})
|
||||||
|
(wrk/invoke! (-> main/system
|
||||||
|
(assoc ::wrk/task :delete-object)
|
||||||
|
(assoc ::wrk/params {:object :file
|
||||||
|
:deleted-at tnow
|
||||||
|
:id file-id})))
|
||||||
|
:deleted))
|
||||||
|
|
||||||
(defn- restore-file*
|
(defn- restore-file*
|
||||||
[{:keys [::db/conn]} file-id]
|
[{:keys [::db/conn]} file-id]
|
||||||
(db/update! conn :file
|
(db/update! conn :file
|
||||||
|
@ -502,20 +527,105 @@
|
||||||
|
|
||||||
:restored)
|
:restored)
|
||||||
|
|
||||||
|
(defn restore-file!
|
||||||
|
"Mark a file and all related objects as not deleted"
|
||||||
|
[file-id]
|
||||||
|
(let [file-id (h/parse-uuid file-id)]
|
||||||
|
(db/tx-run! main/system
|
||||||
|
(fn [system]
|
||||||
|
(when-let [file (some-> (db/get* system :file
|
||||||
|
{:id file-id}
|
||||||
|
{::db/remove-deleted false
|
||||||
|
::sql/columns [:id :name]})
|
||||||
|
(files/decode-row))]
|
||||||
|
(audit/insert! system
|
||||||
|
{::audit/name "restore-file"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props file
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to restore-file!"}
|
||||||
|
::audit/tracked-at (dt/now)})
|
||||||
|
|
||||||
|
(restore-file* system file-id))))))
|
||||||
|
|
||||||
|
(defn delete-project!
|
||||||
|
"Mark a project for deletion"
|
||||||
|
[project-id]
|
||||||
|
(let [project-id (h/parse-uuid project-id)
|
||||||
|
tnow (dt/now)]
|
||||||
|
|
||||||
|
(audit/insert! main/system
|
||||||
|
{::audit/name "delete-project"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props {:id project-id}
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to delete-project!"}
|
||||||
|
::audit/tracked-at tnow})
|
||||||
|
|
||||||
|
(wrk/invoke! (-> main/system
|
||||||
|
(assoc ::wrk/task :delete-object)
|
||||||
|
(assoc ::wrk/params {:object :project
|
||||||
|
:deleted-at tnow
|
||||||
|
:id project-id})))
|
||||||
|
:deleted))
|
||||||
|
|
||||||
(defn- restore-project*
|
(defn- restore-project*
|
||||||
[{:keys [::db/conn] :as cfg} project-id]
|
[{:keys [::db/conn] :as cfg} project-id]
|
||||||
|
|
||||||
(db/update! conn :project
|
(db/update! conn :project
|
||||||
{:deleted-at nil}
|
{:deleted-at nil}
|
||||||
{:id project-id})
|
{:id project-id})
|
||||||
|
|
||||||
(doseq [{:keys [id]} (db/query conn :file
|
(doseq [{:keys [id]} (db/query conn :file
|
||||||
{:project-id project-id}
|
{:project-id project-id}
|
||||||
{::db/columns [:id]})]
|
{::sql/columns [:id]})]
|
||||||
(restore-file* cfg id))
|
(restore-file* cfg id))
|
||||||
|
|
||||||
:restored)
|
:restored)
|
||||||
|
|
||||||
|
(defn restore-project!
|
||||||
|
"Mark a project and all related objects as not deleted"
|
||||||
|
[project-id]
|
||||||
|
(let [project-id (h/parse-uuid project-id)]
|
||||||
|
(db/tx-run! main/system
|
||||||
|
(fn [system]
|
||||||
|
(when-let [project (db/get* system :project
|
||||||
|
{:id project-id}
|
||||||
|
{::db/remove-deleted false})]
|
||||||
|
(audit/insert! system
|
||||||
|
{::audit/name "restore-project"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props project
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to restore-team!"}
|
||||||
|
::audit/tracked-at (dt/now)})
|
||||||
|
|
||||||
|
(restore-project* system project-id))))))
|
||||||
|
|
||||||
|
(defn delete-team!
|
||||||
|
"Mark a team for deletion"
|
||||||
|
[team-id]
|
||||||
|
(let [team-id (h/parse-uuid team-id)
|
||||||
|
tnow (dt/now)]
|
||||||
|
|
||||||
|
(audit/insert! main/system
|
||||||
|
{::audit/name "delete-team"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props {:id team-id}
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to delete-profile!"}
|
||||||
|
::audit/tracked-at tnow})
|
||||||
|
|
||||||
|
(wrk/invoke! (-> main/system
|
||||||
|
(assoc ::wrk/task :delete-object)
|
||||||
|
(assoc ::wrk/params {:object :team
|
||||||
|
:deleted-at tnow
|
||||||
|
:id team-id})))
|
||||||
|
:deleted))
|
||||||
|
|
||||||
(defn- restore-team*
|
(defn- restore-team*
|
||||||
[{:keys [::db/conn] :as cfg} team-id]
|
[{:keys [::db/conn] :as cfg} team-id]
|
||||||
(db/update! conn :team
|
(db/update! conn :team
|
||||||
|
@ -528,84 +638,127 @@
|
||||||
|
|
||||||
(doseq [{:keys [id]} (db/query conn :project
|
(doseq [{:keys [id]} (db/query conn :project
|
||||||
{:team-id team-id}
|
{:team-id team-id}
|
||||||
{::db/columns [:id]})]
|
{::sql/columns [:id]})]
|
||||||
(restore-project* cfg id))
|
(restore-project* cfg id))
|
||||||
|
|
||||||
:restored)
|
:restored)
|
||||||
|
|
||||||
(defn- restore-profile*
|
(defn restore-team!
|
||||||
[{:keys [::db/conn] :as cfg} profile-id]
|
|
||||||
(db/update! conn :profile
|
|
||||||
{:deleted-at nil}
|
|
||||||
{:id profile-id})
|
|
||||||
|
|
||||||
(doseq [{:keys [id]} (profile/get-owned-teams conn profile-id)]
|
|
||||||
(restore-team* cfg id))
|
|
||||||
|
|
||||||
:restored)
|
|
||||||
|
|
||||||
|
|
||||||
(defn restore-deleted-profile!
|
|
||||||
"Mark a team and all related objects as not deleted"
|
|
||||||
[profile-id]
|
|
||||||
(let [profile-id (h/parse-uuid profile-id)]
|
|
||||||
(db/tx-run! main/system restore-profile* profile-id)))
|
|
||||||
|
|
||||||
(defn restore-deleted-team!
|
|
||||||
"Mark a team and all related objects as not deleted"
|
"Mark a team and all related objects as not deleted"
|
||||||
[team-id]
|
[team-id]
|
||||||
(let [team-id (h/parse-uuid team-id)]
|
(let [team-id (h/parse-uuid team-id)]
|
||||||
(db/tx-run! main/system restore-team* team-id)))
|
(db/tx-run! main/system
|
||||||
|
(fn [system]
|
||||||
|
(when-let [team (some-> (db/get* system :team
|
||||||
|
{:id team-id}
|
||||||
|
{::db/remove-deleted false})
|
||||||
|
(teams/decode-row))]
|
||||||
|
(audit/insert! system
|
||||||
|
{::audit/name "restore-team"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props team
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to restore-team!"}
|
||||||
|
::audit/tracked-at (dt/now)})
|
||||||
|
|
||||||
(defn restore-deleted-project!
|
(restore-team* system team-id))))))
|
||||||
"Mark a project and all related objects as not deleted"
|
|
||||||
[project-id]
|
|
||||||
(let [project-id (h/parse-uuid project-id)]
|
|
||||||
(db/tx-run! main/system restore-project* project-id)))
|
|
||||||
|
|
||||||
(defn restore-deleted-file!
|
|
||||||
"Mark a file and all related objects as not deleted"
|
|
||||||
[file-id]
|
|
||||||
(let [file-id (h/parse-uuid file-id)]
|
|
||||||
(db/tx-run! main/system restore-file* file-id)))
|
|
||||||
|
|
||||||
(defn delete-team!
|
|
||||||
"Mark a team for deletion"
|
|
||||||
[team-id]
|
|
||||||
(let [team-id (h/parse-uuid team-id)]
|
|
||||||
(wrk/invoke! (-> main/system
|
|
||||||
(assoc ::wrk/task :delete-object)
|
|
||||||
(assoc ::wrk/params {:object :team
|
|
||||||
:deleted-at (dt/now)
|
|
||||||
:id team-id})))))
|
|
||||||
(defn delete-profile!
|
(defn delete-profile!
|
||||||
"Mark a profile for deletion"
|
"Mark a profile for deletion."
|
||||||
[profile-id]
|
[profile-id]
|
||||||
(let [profile-id (h/parse-uuid profile-id)]
|
(let [profile-id (h/parse-uuid profile-id)
|
||||||
|
tnow (dt/now)]
|
||||||
|
|
||||||
|
(audit/insert! main/system
|
||||||
|
{::audit/name "delete-profile"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to delete-profile!"}
|
||||||
|
::audit/tracked-at tnow})
|
||||||
|
|
||||||
(wrk/invoke! (-> main/system
|
(wrk/invoke! (-> main/system
|
||||||
(assoc ::wrk/task :delete-object)
|
(assoc ::wrk/task :delete-object)
|
||||||
(assoc ::wrk/params {:object :profile
|
(assoc ::wrk/params {:object :profile
|
||||||
:deleted-at (dt/now)
|
:deleted-at tnow
|
||||||
:id profile-id})))))
|
:id profile-id})))
|
||||||
(defn delete-project!
|
:deleted))
|
||||||
"Mark a project for deletion"
|
|
||||||
[project-id]
|
|
||||||
(let [project-id (h/parse-uuid project-id)]
|
|
||||||
(wrk/invoke! (-> main/system
|
|
||||||
(assoc ::wrk/task :delete-object)
|
|
||||||
(assoc ::wrk/params {:object :project
|
|
||||||
:deleted-at (dt/now)
|
|
||||||
:id project-id})))))
|
|
||||||
|
|
||||||
(defn delete-file!
|
(defn restore-profile!
|
||||||
"Mark a project for deletion"
|
"Mark a team and all related objects as not deleted"
|
||||||
[file-id]
|
[profile-id]
|
||||||
(let [file-id (h/parse-uuid file-id)]
|
(let [profile-id (h/parse-uuid profile-id)]
|
||||||
(wrk/invoke! (-> main/system
|
(db/tx-run! main/system
|
||||||
(assoc ::wrk/task :delete-object)
|
(fn [system]
|
||||||
(assoc ::wrk/params {:object :file
|
(when-let [profile (some-> (db/get* system :profile
|
||||||
:deleted-at (dt/now)
|
{:id profile-id}
|
||||||
:id file-id})))))
|
{::db/remove-deleted false})
|
||||||
|
(profile/decode-row))]
|
||||||
|
(audit/insert! system
|
||||||
|
{::audit/name "restore-profile"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/profile-id uuid/zero
|
||||||
|
::audit/props (audit/profile->props profile)
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to restore-profile!"}
|
||||||
|
::audit/tracked-at (dt/now)})
|
||||||
|
|
||||||
|
(db/update! system :profile
|
||||||
|
{:deleted-at nil}
|
||||||
|
{:id profile-id}
|
||||||
|
{::db/return-keys false})
|
||||||
|
|
||||||
|
(doseq [{:keys [id]} (profile/get-owned-teams system profile-id)]
|
||||||
|
(restore-team* system id))
|
||||||
|
|
||||||
|
:restored)))))
|
||||||
|
|
||||||
|
(defn delete-profiles-in-bulk!
|
||||||
|
[system path]
|
||||||
|
(letfn [(process-data! [system deleted-at emails]
|
||||||
|
(loop [emails emails
|
||||||
|
deleted 0
|
||||||
|
total 0]
|
||||||
|
(if-let [email (first emails)]
|
||||||
|
(if-let [profile (db/get* system :profile
|
||||||
|
{:email (str/lower email)}
|
||||||
|
{::db/remove-deleted false})]
|
||||||
|
(do
|
||||||
|
(audit/insert! system
|
||||||
|
{::audit/name "delete-profile"
|
||||||
|
::audit/type "action"
|
||||||
|
::audit/tracked-at deleted-at
|
||||||
|
::audit/props (audit/profile->props profile)
|
||||||
|
::audit/context {:triggered-by "srepl"
|
||||||
|
:cause "explicit call to delete-profiles-in-bulk!"}})
|
||||||
|
(wrk/invoke! (-> system
|
||||||
|
(assoc ::wrk/task :delete-object)
|
||||||
|
(assoc ::wrk/params {:object :profile
|
||||||
|
:deleted-at deleted-at
|
||||||
|
:id (:id profile)})))
|
||||||
|
(recur (rest emails)
|
||||||
|
(inc deleted)
|
||||||
|
(inc total)))
|
||||||
|
(recur (rest emails)
|
||||||
|
deleted
|
||||||
|
(inc total)))
|
||||||
|
{:deleted deleted :total total})))]
|
||||||
|
|
||||||
|
(let [path (fs/path path)
|
||||||
|
deleted-at (dt/minus (dt/now) cf/deletion-delay)]
|
||||||
|
|
||||||
|
(when-not (fs/exists? path)
|
||||||
|
(throw (ex-info "path does not exists" {:path path})))
|
||||||
|
|
||||||
|
(db/tx-run! system
|
||||||
|
(fn [system]
|
||||||
|
(with-open [reader (io/reader path)]
|
||||||
|
(process-data! system deleted-at (line-seq reader))))))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; CASCADE FIXING
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(defn process-deleted-profiles-cascade
|
(defn process-deleted-profiles-cascade
|
||||||
[]
|
[]
|
||||||
|
|
Loading…
Add table
Reference in a new issue