add persistence

master
Ondřej Hruška 3 years ago
parent 1369db85e8
commit 4e26080820
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 21
      Cargo.lock
  2. BIN
      yopa-store.dat
  3. 4
      yopa-web/Cargo.toml
  4. 4
      yopa-web/resources/src/utils.js
  5. 2
      yopa-web/resources/static/bundle.js
  6. 2
      yopa-web/resources/static/bundle.js.map
  7. 137
      yopa-web/src/main.rs
  8. 9
      yopa-web/src/routes/models/object.rs
  9. 9
      yopa-web/src/routes/models/property.rs
  10. 5
      yopa-web/src/routes/models/relation.rs
  11. 5
      yopa-web/src/routes/objects.rs
  12. 23
      yopa-web/src/utils.rs
  13. 1
      yopa/Cargo.toml
  14. 123
      yopa/src/lib.rs

21
Cargo.lock generated

@ -448,6 +448,16 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "bincode"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f30d3a39baa26f9651f17b375061f3233dde33424a8b72b0dbe93a68a0bc896d"
dependencies = [
"byteorder",
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.2.1" version = "1.2.1"
@ -2040,18 +2050,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.23" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146" checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.23" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1" checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2559,6 +2569,7 @@ name = "yopa"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bincode",
"itertools", "itertools",
"lazy_static", "lazy_static",
"log", "log",
@ -2576,6 +2587,7 @@ dependencies = [
"actix-session", "actix-session",
"actix-web", "actix-web",
"actix-web-static-files", "actix-web-static-files",
"anyhow",
"heck", "heck",
"include_dir", "include_dir",
"itertools", "itertools",
@ -2588,6 +2600,7 @@ dependencies = [
"serde_json", "serde_json",
"simple-logging", "simple-logging",
"tera", "tera",
"thiserror",
"tokio", "tokio",
"yopa", "yopa",
] ]

Binary file not shown.

@ -8,7 +8,7 @@ build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
yopa = { path = "../yopa", features = [] } yopa = { path = "../yopa", features = ["uuid-ids"] }
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
heck = "0.3.2" heck = "0.3.2"
@ -24,6 +24,8 @@ once_cell = "1.5.2"
rand = "0.8.3" rand = "0.8.3"
itertools = "0.10.0" itertools = "0.10.0"
json_dotpath = "1.0.3" json_dotpath = "1.0.3"
anyhow = "1.0.38"
thiserror = "1.0.24"
tokio = { version="0.2.6", features=["full"] } tokio = { version="0.2.6", features=["full"] }

@ -23,8 +23,8 @@ export function objCopy(object) {
} }
export function castId(id) { export function castId(id) {
// TODO no-op after switching to UUIDs return id.toString();
return +id; //return +id;
} }
// like _.isEmpty, but less stupid // like _.isEmpty, but less stupid

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -2,6 +2,8 @@
extern crate actix_web; extern crate actix_web;
#[macro_use] #[macro_use]
extern crate log; extern crate log;
#[macro_use]
extern crate thiserror;
use std::collections::HashMap; use std::collections::HashMap;
use std::ops::Deref; use std::ops::Deref;
@ -106,7 +108,8 @@ async fn main() -> std::io::Result<()> {
// Ensure the lazy ref is initialized early (to catch template bugs ASAP) // Ensure the lazy ref is initialized early (to catch template bugs ASAP)
let _ = TERA.deref(); let _ = TERA.deref();
let yopa_store: YopaStoreWrapper = init_yopa(); let yopa_store: YopaStoreWrapper = init_yopa()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let mut session_key = [0u8; 32]; let mut session_key = [0u8; 32];
rand::thread_rng().fill(&mut session_key); rand::thread_rng().fill(&mut session_key);
@ -164,133 +167,9 @@ async fn main() -> std::io::Result<()> {
.await .await
} }
fn init_yopa() -> YopaStoreWrapper { fn init_yopa() -> anyhow::Result<YopaStoreWrapper> {
let mut store = Storage::new(); let mut store = Storage::new_bincode(std::env::current_dir()?.join("yopa-store.dat"));
store.load()?;
/*
// Seed the store with some dummy data for view development
use yopa::model;
use yopa::DataType;
let id_recipe = store
.define_object(model::ObjectModel {
id: Default::default(),
name: "Recipe".to_string(),
})
.unwrap();
let id_book = store
.define_object(model::ObjectModel {
id: Default::default(),
name: "Book".to_string(),
})
.unwrap();
let _id_ing = store
.define_object(model::ObjectModel {
id: Default::default(),
name: "Ingredient".to_string(),
})
.unwrap();
let val_descr = store
.define_property(model::PropertyModel {
id: Default::default(),
object: id_recipe,
name: "description".to_string(),
optional: true,
multiple: true,
data_type: DataType::String,
default: TypedValue::String("".into()),
})
.unwrap();
store
.define_property(model::PropertyModel {
id: Default::default(),
object: id_book,
name: "author".to_string(),
optional: true,
multiple: true,
data_type: DataType::String,
default: TypedValue::String("Pepa Novák".into()),
})
.unwrap();
let rel_book_id = store
.define_relation(model::RelationModel {
id: Default::default(),
object: id_recipe,
name: "book reference".to_string(),
reciprocal_name: "recipes".to_string(),
optional: true,
multiple: true,
related: id_book,
})
.unwrap();
let page = store
.define_property(model::PropertyModel {
id: Default::default(),
object: rel_book_id,
name: "page".to_string(),
optional: true,
multiple: false,
data_type: DataType::Integer,
default: TypedValue::Integer(0),
})
.unwrap();
store
.define_relation(model::RelationModel {
id: Default::default(),
object: id_recipe,
name: "related recipe".to_string(),
reciprocal_name: "related recipe".to_string(),
optional: true,
multiple: true,
related: id_recipe,
})
.unwrap();
let book1 = store
.insert_object(InsertObj {
model: id_book,
name: "Book 1".to_string(),
values: vec![],
relations: vec![],
})
.unwrap();
store
.insert_object(InsertObj {
model: id_book,
name: "Book 2".to_string(),
values: vec![],
relations: vec![],
})
.unwrap();
store
.insert_object(InsertObj {
model: id_recipe,
name: "Recipe1".to_string(),
values: vec![InsertValue {
model: val_descr,
value: TypedValue::String("Bla bla bla".into()),
}],
relations: vec![InsertRel {
model: rel_book_id,
related: book1,
values: vec![InsertValue {
model: page,
value: TypedValue::Integer(15),
}],
}],
})
.unwrap();
*/
web::Data::new(tokio::sync::RwLock::new(store)) Ok(web::Data::new(tokio::sync::RwLock::new(store)))
} }

@ -1,5 +1,5 @@
use actix_session::Session; use actix_session::Session;
use actix_web::{web, Responder}; use actix_web::{web, Responder, HttpResponse};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use yopa::model::{ObjectModel, PropertyModel}; use yopa::model::{ObjectModel, PropertyModel};
@ -8,7 +8,7 @@ use yopa::ID;
use crate::routes::models::relation::RelationModelDisplay; use crate::routes::models::relation::RelationModelDisplay;
use crate::session_ext::SessionExt; use crate::session_ext::SessionExt;
use crate::tera_ext::TeraExt; use crate::tera_ext::TeraExt;
use crate::utils::redirect; use crate::utils::{redirect, StorageErrorIntoResponseError};
use crate::TERA; use crate::TERA;
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
@ -45,7 +45,7 @@ pub(crate) async fn create(
form: web::Form<ObjectModelForm>, form: web::Form<ObjectModelForm>,
store: crate::YopaStoreWrapper, store: crate::YopaStoreWrapper,
session: Session, session: Session,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<HttpResponse> {
let mut wg = store.write().await; let mut wg = store.write().await;
let form = form.into_inner(); let form = form.into_inner();
match wg.define_object(ObjectModel { match wg.define_object(ObjectModel {
@ -53,6 +53,7 @@ pub(crate) async fn create(
name: form.name.clone(), name: form.name.clone(),
}) { }) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Object created, redirecting to root"); debug!("Object created, redirecting to root");
session.flash_success(format!("Object model \"{}\" created.", form.name)); session.flash_success(format!("Object model \"{}\" created.", form.name));
redirect("/models") redirect("/models")
@ -108,6 +109,7 @@ pub(crate) async fn update(
name: form.name.clone(), name: form.name.clone(),
}) { }) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Object updated, redirecting to root"); debug!("Object updated, redirecting to root");
session.flash_success(format!("Object model \"{}\" updated.", form.name)); session.flash_success(format!("Object model \"{}\" updated.", form.name));
redirect("/models") redirect("/models")
@ -133,6 +135,7 @@ pub(crate) async fn delete(
.map_err(|e| actix_web::error::ErrorBadRequest(e))?, .map_err(|e| actix_web::error::ErrorBadRequest(e))?,
) { ) {
Ok(om) => { Ok(om) => {
wg.persist().err_to_500()?;
debug!("Object model deleted, redirecting to root"); debug!("Object model deleted, redirecting to root");
session.flash_success(format!("Object model \"{}\" deleted.", om.name)); session.flash_success(format!("Object model \"{}\" deleted.", om.name));
redirect("/models") redirect("/models")

@ -8,7 +8,7 @@ use yopa::{DataType, TypedValue, ID};
use crate::routes::models::relation::ObjectOrRelationModelDisplay; use crate::routes::models::relation::ObjectOrRelationModelDisplay;
use crate::session_ext::SessionExt; use crate::session_ext::SessionExt;
use crate::tera_ext::TeraExt; use crate::tera_ext::TeraExt;
use crate::utils::{redirect, ParseOrBadReq}; use crate::utils::{redirect, ParseOrBadReq, StorageErrorIntoResponseError};
use crate::TERA; use crate::TERA;
#[get("/model/property/create/{object_id}")] #[get("/model/property/create/{object_id}")]
@ -148,6 +148,7 @@ pub(crate) async fn create(
default, default,
}) { }) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Property created, redirecting to root"); debug!("Property created, redirecting to root");
session.flash_success(format!("Property model \"{}\" created.", form.name)); session.flash_success(format!("Property model \"{}\" created.", form.name));
redirect("/models") redirect("/models")
@ -170,6 +171,7 @@ pub(crate) async fn delete(
let mut wg = store.write().await; let mut wg = store.write().await;
match wg.undefine_property(id.parse_or_bad_request()?) { match wg.undefine_property(id.parse_or_bad_request()?) {
Ok(rm) => { Ok(rm) => {
wg.persist().err_to_500()?;
debug!("Property deleted, redirecting to root"); debug!("Property deleted, redirecting to root");
session.flash_success(format!("Property \"{}\" deleted.", rm.name)); session.flash_success(format!("Property \"{}\" deleted.", rm.name));
redirect("/models") redirect("/models")
@ -256,7 +258,8 @@ pub(crate) async fn update(
default, default,
}) { }) {
Ok(_id) => { Ok(_id) => {
debug!("Relation updated, redirecting to root"); wg.persist().err_to_500()?;
debug!("Property updated, redirecting to root");
session.flash_success(format!("Property \"{}\" updated.", form.name)); session.flash_success(format!("Property \"{}\" updated.", form.name));
redirect("/models") redirect("/models")
} }
@ -264,7 +267,7 @@ pub(crate) async fn update(
warn!("Error updating model: {}", e); warn!("Error updating model: {}", e);
session.flash_error(e.to_string()); session.flash_error(e.to_string());
session.set("old", form).unwrap(); session.set("old", form).unwrap();
redirect(format!("/model/relation/update/{}", id)) redirect(format!("/model/property/update/{}", id))
} }
} }
} }

@ -7,7 +7,7 @@ use yopa::ID;
use crate::session_ext::SessionExt; use crate::session_ext::SessionExt;
use crate::tera_ext::TeraExt; use crate::tera_ext::TeraExt;
use crate::utils::{redirect, ParseOrBadReq}; use crate::utils::{redirect, ParseOrBadReq, StorageErrorIntoResponseError};
use crate::TERA; use crate::TERA;
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
@ -90,6 +90,7 @@ pub(crate) async fn create(
related: form.related, related: form.related,
}) { }) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Relation created, redirecting to root"); debug!("Relation created, redirecting to root");
session.flash_success(format!("Relation model \"{}\" created.", form.name)); session.flash_success(format!("Relation model \"{}\" created.", form.name));
redirect("/models") redirect("/models")
@ -118,6 +119,7 @@ pub(crate) async fn delete(
let mut wg = store.write().await; let mut wg = store.write().await;
match wg.undefine_relation(id.parse_or_bad_request()?) { match wg.undefine_relation(id.parse_or_bad_request()?) {
Ok(rm) => { Ok(rm) => {
wg.persist().err_to_500()?;
debug!("Relation deleted, redirecting to root"); debug!("Relation deleted, redirecting to root");
session.flash_success(format!("Relation model \"{}\" deleted.", rm.name)); session.flash_success(format!("Relation model \"{}\" deleted.", rm.name));
redirect("/models") redirect("/models")
@ -189,6 +191,7 @@ pub(crate) async fn update(
related: Default::default(), // dummy related: Default::default(), // dummy
}) { }) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Relation updated, redirecting to root"); debug!("Relation updated, redirecting to root");
session.flash_success(format!("Relation model \"{}\" updated.", form.name)); session.flash_success(format!("Relation model \"{}\" updated.", form.name));
redirect("/models") redirect("/models")

@ -3,7 +3,7 @@ use actix_session::Session;
use actix_web::{web, HttpResponse, Responder}; use actix_web::{web, HttpResponse, Responder};
use crate::tera_ext::TeraExt; use crate::tera_ext::TeraExt;
use crate::utils::redirect; use crate::utils::{redirect, StorageErrorIntoResponseError};
use crate::TERA; use crate::TERA;
use serde::Serialize; use serde::Serialize;
use yopa::data::Object; use yopa::data::Object;
@ -110,6 +110,7 @@ pub(crate) async fn create(
match wg.insert_object(form) { match wg.insert_object(form) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Object created, redirecting to root"); debug!("Object created, redirecting to root");
session.flash_success(format!("{} \"{}\" created.", model_name, name)); session.flash_success(format!("{} \"{}\" created.", model_name, name));
Ok(HttpResponse::Ok().finish()) Ok(HttpResponse::Ok().finish())
@ -512,6 +513,7 @@ pub(crate) async fn update(
match wg.update_object(form) { match wg.update_object(form) {
Ok(_id) => { Ok(_id) => {
wg.persist().err_to_500()?;
debug!("Object created, redirecting to root"); debug!("Object created, redirecting to root");
session.flash_success(format!("{} \"{}\" updated.", model_name, name)); session.flash_success(format!("{} \"{}\" updated.", model_name, name));
Ok(HttpResponse::Ok().finish()) Ok(HttpResponse::Ok().finish())
@ -532,6 +534,7 @@ pub(crate) async fn delete(
let mut wg = store.write().await; let mut wg = store.write().await;
match wg.delete_object(*id) { match wg.delete_object(*id) {
Ok(obj) => { Ok(obj) => {
wg.persist().err_to_500()?;
debug!("Object deleted, redirecting to root"); debug!("Object deleted, redirecting to root");
session.flash_success(format!("Object \"{}\" deleted.", obj.name)); session.flash_success(format!("Object \"{}\" deleted.", obj.name));
redirect("/") redirect("/")

@ -1,7 +1,8 @@
use actix_web::http::header::IntoHeaderValue; use actix_web::http::header::IntoHeaderValue;
use actix_web::HttpResponse; use actix_web::{HttpResponse, ResponseError, Error};
use std::fmt::{Debug, Display}; use std::fmt::{Debug, Display};
use std::str::FromStr; use std::str::FromStr;
use yopa::StorageError;
pub fn redirect(path: impl IntoHeaderValue) -> actix_web::Result<HttpResponse> { pub fn redirect(path: impl IntoHeaderValue) -> actix_web::Result<HttpResponse> {
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
@ -38,3 +39,23 @@ impl ParseOrBadReq for String {
self.as_str().parse_or_bad_request() self.as_str().parse_or_bad_request()
} }
} }
#[derive(Debug, Error)]
pub enum ActixErrorWrapper {
#[error(transparent)]
Storage(#[from] StorageError),
}
impl ResponseError for ActixErrorWrapper {
}
pub trait StorageErrorIntoResponseError<T> {
fn err_to_500(self) -> Result<T, ActixErrorWrapper>;
}
impl<T> StorageErrorIntoResponseError<T> for Result<T, StorageError> {
fn err_to_500(self) -> Result<T, ActixErrorWrapper> {
self.map_err(|e| e.into())
}
}

@ -20,6 +20,7 @@ anyhow = "1.0.38"
thiserror = "1.0.23" thiserror = "1.0.23"
itertools = "0.10.0" itertools = "0.10.0"
lazy_static = "1.4.0" lazy_static = "1.4.0"
bincode = "1.3.1"
[features] [features]
default = [] default = []

@ -22,6 +22,9 @@ use crate::data::Object;
use crate::update::{UpdateObj, UpsertValue}; use crate::update::{UpdateObj, UpsertValue};
pub use data::TypedValue; pub use data::TypedValue;
pub use model::DataType; pub use model::DataType;
use std::path::{PathBuf, Path};
use std::fs::OpenOptions;
use std::io::{BufReader, BufWriter};
mod cool; mod cool;
pub mod data; pub mod data;
@ -34,7 +37,7 @@ mod serde_map_as_list;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
/// Stupid storage with no persistence /// Stupid storage with naive inefficient file persistence
#[derive(Debug, Default, Serialize, Deserialize, Clone)] #[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Storage { pub struct Storage {
#[serde(with = "serde_map_as_list")] #[serde(with = "serde_map_as_list")]
@ -50,14 +53,46 @@ pub struct Storage {
relations: HashMap<ID, data::Relation>, relations: HashMap<ID, data::Relation>,
#[serde(with = "serde_map_as_list")] #[serde(with = "serde_map_as_list")]
values: HashMap<ID, data::Value>, values: HashMap<ID, data::Value>,
#[serde(skip)]
opts: StoreOpts,
}
#[derive(Debug, Clone)]
pub struct StoreOpts {
file : Option<PathBuf>,
file_format: FileEncoding
}
impl Default for StoreOpts {
fn default() -> Self {
Self {
file: None,
file_format: FileEncoding::JSON
}
}
}
#[derive(Debug,Clone,Copy)]
pub enum FileEncoding {
JSON,
BINCODE,
} }
#[derive(Debug, Error)] #[derive(Debug,Error)]
pub enum StorageError { pub enum StorageError {
#[error("Referenced {0} does not exist")] #[error("Referenced {0} does not exist")]
NotExist(Cow<'static, str>), NotExist(Cow<'static, str>),
#[error("{0}")] #[error("{0}")]
ConstraintViolation(Cow<'static, str>), ConstraintViolation(Cow<'static, str>),
#[error("Persistence not configured!")]
NotPersistent,
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
#[error(transparent)]
Bincode(#[from] bincode::Error),
} }
impl Storage { impl Storage {
@ -66,6 +101,90 @@ impl Storage {
Self::default() Self::default()
} }
pub fn new_json(file : impl AsRef<Path>) -> Self {
let mut s = Self::new();
s.opts.file_format = FileEncoding::JSON;
s.opts.file = Some(file.as_ref().to_path_buf());
s
}
pub fn new_bincode(file : impl AsRef<Path>) -> Self {
let mut s = Self::new();
s.opts.file_format = FileEncoding::BINCODE;
s.opts.file = Some(file.as_ref().to_path_buf());
s
}
pub fn set_file(&mut self, file : impl AsRef<Path>, format : FileEncoding) {
self.opts.file_format = format;
self.opts.file = Some(file.as_ref().to_path_buf());
}
pub fn unset_file(&mut self) {
self.opts.file = None;
}
pub fn load(&mut self) -> Result<(), StorageError> {
match &self.opts.file {
None => {
return Err(StorageError::NotPersistent);
}
Some(path) => {
debug!("Load from: {}", path.display());
if !path.exists() {
warn!("File does not exist, skip load.");
return Ok(())
}
let f = OpenOptions::new().read(true).open(&path)?;
let reader = BufReader::new(f);
let parsed : Self = match self.opts.file_format {
FileEncoding::JSON => {
serde_json::from_reader(reader)?
}
FileEncoding::BINCODE => {
bincode::deserialize_from(reader)?
}
};
let opts = std::mem::replace(&mut self.opts, StoreOpts::default());
*self = parsed;
self.opts = opts;
}
}
Ok(())
}
pub fn persist(&mut self) -> Result<(), StorageError> {
match &self.opts.file {
None => {
warn!("Store is not persistent!");
//return Err(StorageError::NotPersistent);
}
Some(path) => {
debug!("Persist to: {}", path.display());
let f = OpenOptions::new().write(true).create(true).truncate(true).open(&path)?;
let writer = BufWriter::new(f);
match self.opts.file_format {
FileEncoding::JSON => {
serde_json::to_writer(writer, self)?;
}
FileEncoding::BINCODE => {
bincode::serialize_into(writer, self)?
}
};
}
}
Ok(())
}
/// Define a data object /// Define a data object
pub fn define_object(&mut self, mut tpl: model::ObjectModel) -> Result<ID, StorageError> { pub fn define_object(&mut self, mut tpl: model::ObjectModel) -> Result<ID, StorageError> {
if tpl.name.is_empty() { if tpl.name.is_empty() {

Loading…
Cancel
Save