implement file_upload field

This commit is contained in:
Manuel Gugger 2022-12-29 19:56:20 +01:00
parent ce964220b7
commit a600ff5c9c
40 changed files with 399 additions and 187 deletions

3
.gitignore vendored
View File

@ -27,3 +27,6 @@ _site/
# Ignore folders generated by Bundler
.bundle/
vendor/
# Ignore File Uploads
file_uploads/

View File

@ -6,18 +6,21 @@ version = "0.2.0"
repository = "https://github.com/mgugger/actix-admin"
edition = "2021"
exclude = [
"example/*",
"examples/*",
"actix_admin_macros/*",
"tests/*",
"README.md",
"static/*",
"azure_auth/*",
"README.md"
]
[lib]
name = "actix_admin"
path = "src/lib.rs"
[dependencies]
actix-web = "^4.0.1"
actix-session = { version = "^0.7.1", features = [] }
actix-multipart = "^0.4.0"
actix-files = "^0.6.2"
futures-util = "0.3.21"
chrono = "0.4.20"
tera = "^1.16.0"
@ -32,3 +35,8 @@ derive_more = "0.99.17"
[dev-dependencies]
sea-orm = { version = "^0.9.1", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros" ], default-features = true }
actix-rt = "2.7.0"
azure_auth = { path = "./examples/azure_auth/azure_auth" }
oauth2 = "4.1"
dotenv = "0.15"
actix-session = { version = "0.7.1", features = ["cookie-session"] }

View File

@ -14,7 +14,9 @@ pub mod derive_attr {
pub html_input_type: Option<syn::LitStr>,
pub select_list: Option<syn::LitStr>,
pub searchable: Option<()>,
pub textarea: Option<()>
pub textarea: Option<()>,
pub file_upload: Option<()>,
pub not_empty: Option<()>
//pub inner_type: Option<syn::Type>,
// Anything that implements `syn::parse::Parse` is supported.

View File

@ -154,6 +154,7 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
let fields_searchable = get_actix_admin_fields_searchable(&fields);
let fields_type_path = get_actix_admin_fields_type_path_string(&fields);
let fields_textarea = get_actix_admin_fields_textarea(&fields);
let fields_file_upload = get_actix_admin_fields_file_upload(&fields);
let expanded = quote! {
actix_admin::prelude::lazy_static! {
@ -187,7 +188,11 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
#(#fields_textarea),*
];
for (field_name, html_input_type, select_list, is_option_list, fields_type_path, is_textarea) in actix_admin::prelude::izip!(&field_names, &html_input_types, &field_select_lists, is_option_lists, fields_type_paths, fields_textareas) {
let fields_fileupload = [
#(#fields_file_upload),*
];
for (field_name, html_input_type, select_list, is_option_list, fields_type_path, is_textarea, is_file_upload) in actix_admin::prelude::izip!(&field_names, &html_input_types, &field_select_lists, is_option_lists, fields_type_paths, fields_textareas, fields_fileupload) {
let select_list = select_list.replace('"', "").replace(' ', "").to_string();
let field_name = field_name.replace('"', "").replace(' ', "").to_string();
@ -198,7 +203,7 @@ pub fn derive_actix_admin_model(input: proc_macro::TokenStream) -> proc_macro::T
html_input_type: html_input_type,
select_list: select_list.clone(),
is_option: is_option_list,
field_type: ActixAdminViewModelFieldType::get_field_type(fields_type_path, select_list, is_textarea)
field_type: ActixAdminViewModelFieldType::get_field_type(fields_type_path, select_list, is_textarea, is_file_upload)
});
}
vec

View File

@ -7,13 +7,14 @@ pub struct ModelField {
pub visibility: Visibility,
pub ident: proc_macro2::Ident,
pub ty: Type,
// struct field is option<>
pub inner_type: Option<Type>,
pub primary_key: bool,
pub html_input_type: String,
pub select_list: String,
pub searchable: bool,
pub textarea: bool
pub textarea: bool,
pub file_upload: bool,
pub not_empty: bool
}
impl ModelField {

View File

@ -41,6 +41,12 @@ pub fn filter_fields(fields: &Fields) -> Vec<ModelField> {
let is_textarea = actix_admin_attr
.clone()
.map_or(false, |attr| attr.textarea.is_some());
let is_file_upload = actix_admin_attr
.clone()
.map_or(false, |attr| attr.file_upload.is_some());
let is_not_empty = actix_admin_attr
.clone()
.map_or(false, |attr| attr.not_empty.is_some());
let select_list = actix_admin_attr.clone().map_or("".to_string(), |attr| {
attr.select_list.map_or("".to_string(), |attr_field| {
(LitStr::from(attr_field)).value()
@ -62,7 +68,9 @@ pub fn filter_fields(fields: &Fields) -> Vec<ModelField> {
html_input_type: html_input_type,
select_list: select_list,
searchable: is_searchable,
textarea: is_textarea
textarea: is_textarea,
file_upload: is_file_upload,
not_empty: is_not_empty
};
Some(model_field)
} else {
@ -186,6 +194,20 @@ pub fn get_actix_admin_fields_textarea(fields: &Vec<ModelField>) -> Vec<TokenStr
.collect::<Vec<_>>()
}
pub fn get_actix_admin_fields_file_upload(fields: &Vec<ModelField>) -> Vec<TokenStream> {
fields
.iter()
.filter(|model_field| !model_field.primary_key)
.map(|model_field| {
let is_fileupload = model_field.file_upload;
quote! {
#is_fileupload
}
})
.collect::<Vec<_>>()
}
pub fn get_actix_admin_fields_searchable(fields: &Vec<ModelField>) -> Vec<TokenStream> {
fields
.iter()
@ -275,33 +297,34 @@ pub fn get_fields_for_validate_model(fields: &Vec<ModelField>) -> Vec<TokenStrea
let type_path = model_field.get_type_path_string();
let is_option_or_string = model_field.is_option() || model_field.is_string();
let is_allowed_to_be_empty = !model_field.not_empty;
let res = match (model_field.is_option(), type_path.as_str()) {
(_, "DateTime") => {
quote! {
model.get_datetime(#ident_name, #is_option_or_string).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
model.get_datetime(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
}
},
(_, "Date") => {
quote! {
model.get_date(#ident_name, #is_option_or_string).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
model.get_date(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
}
},
(_, "bool") => {
quote! {
model.get_bool(#ident_name, #is_option_or_string).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
model.get_bool(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
}
},
// generic
(true, _) => {
let inner_ty = model_field.inner_type.to_owned().unwrap();
quote! {
model.get_value::<#inner_ty>(#ident_name, #is_option_or_string).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
model.get_value::<#inner_ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
}
},
(false, _) => {
quote! {
model.get_value::<#ty>(#ident_name, #is_option_or_string).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
model.get_value::<#ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).map_err(|err| errors.insert(#ident_name.to_string(), err)).ok()
}
}
};
@ -323,51 +346,52 @@ pub fn get_fields_for_create_model(fields: &Vec<ModelField>) -> Vec<TokenStream>
let type_path = model_field.get_type_path_string();
let is_option_or_string = model_field.is_option() || model_field.is_string();
let is_allowed_to_be_empty = !model_field.not_empty;
let res = match (model_field.is_option(), model_field.is_string(), type_path.as_str()) {
// is DateTime
(true , _, "DateTime") => {
quote! {
#ident: Set(model.get_datetime(#ident_name, #is_option_or_string).unwrap())
#ident: Set(model.get_datetime(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
(false , _, "DateTime") => {
quote! {
#ident: Set(model.get_datetime(#ident_name, #is_option_or_string).unwrap().unwrap())
#ident: Set(model.get_datetime(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
(true , _, "Date") => {
quote! {
#ident: Set(model.get_date(#ident_name, #is_option_or_string).unwrap())
#ident: Set(model.get_date(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
(false , _, "Date") => {
quote! {
#ident: Set(model.get_date(#ident_name, #is_option_or_string).unwrap().unwrap())
#ident: Set(model.get_date(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
(_ , _, "bool") => {
quote! {
#ident: Set(model.get_bool(#ident_name, #is_option_or_string).unwrap().unwrap())
#ident: Set(model.get_bool(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
// Default fields
(true, _, _) => {
let inner_ty = model_field.inner_type.to_owned().unwrap();
quote! {
#ident: Set(model.get_value::<#inner_ty>(#ident_name, #is_option_or_string).unwrap())
#ident: Set(model.get_value::<#inner_ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
// is string which can be empty
(false, true, _) => {
quote! {
#ident: Set(model.get_value::<#ty>(#ident_name, #is_option_or_string).unwrap().unwrap_or(String::new()))
#ident: Set(model.get_value::<#ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap_or(String::new()))
}
},
// no string
(false, false, _) => {
quote! {
#ident: Set(model.get_value::<#ty>(#ident_name, #is_option_or_string).unwrap().unwrap())
#ident: Set(model.get_value::<#ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
}
};
@ -389,47 +413,48 @@ pub fn get_fields_for_edit_model(fields: &Vec<ModelField>) -> Vec<TokenStream> {
let type_path = model_field.get_type_path_string();
let is_option_or_string = model_field.is_option() || model_field.is_string();
let is_allowed_to_be_empty = !model_field.not_empty;
let res = match (model_field.is_option(), model_field.is_string(), type_path.as_str()) {
(_, _, "bool") => {
quote! {
entity.#ident = Set(model.get_bool(#ident_name, #is_option_or_string).unwrap().unwrap())
entity.#ident = Set(model.get_bool(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
(true , _, "DateTime") => {
quote! {
entity.#ident = Set(model.get_datetime(#ident_name, #is_option_or_string).unwrap())
entity.#ident = Set(model.get_datetime(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
(false , _, "DateTime") => {
quote! {
entity.#ident = Set(model.get_datetime(#ident_name, #is_option_or_string).unwrap().unwrap())
entity.#ident = Set(model.get_datetime(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
(true , _, "Date") => {
quote! {
entity.#ident = Set(model.get_date(#ident_name, #is_option_or_string).unwrap())
entity.#ident = Set(model.get_date(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
(false , _, "Date") => {
quote! {
entity.#ident = Set(model.get_date(#ident_name, #is_option_or_string).unwrap().unwrap())
entity.#ident = Set(model.get_date(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
},
(true, _, _) => {
let inner_ty = model_field.inner_type.to_owned().unwrap();
quote! {
entity.#ident = Set(model.get_value::<#inner_ty>(#ident_name, #is_option_or_string).unwrap())
entity.#ident = Set(model.get_value::<#inner_ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap())
}
},
(false, true, _) => {
quote! {
entity.#ident = Set(model.get_value::<#ty>(#ident_name, #is_option_or_string).unwrap().unwrap_or(String::new()))
entity.#ident = Set(model.get_value::<#ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap_or(String::new()))
}
},
(false, false, _) => {
quote! {
entity.#ident = Set(model.get_value::<#ty>(#ident_name, #is_option_or_string).unwrap().unwrap())
entity.#ident = Set(model.get_value::<#ty>(#ident_name, #is_option_or_string, #is_allowed_to_be_empty).unwrap().unwrap())
}
}
};

View File

@ -1,3 +1,4 @@
# Create a .env file in the same folder and add variables below with correct oauth credentials
OAUTH2_CLIENT_SECRET= "TODO"
OAUTH2_CLIENT_ID= "TODO"
OAUTH2_SERVER= "login.microsoftonline.com/a5f5xxxx-xxxx-414a-8463-xxxxxxxxxxxxx(tenantId)"

View File

@ -109,7 +109,8 @@ fn create_actix_admin_builder() -> ActixAdminBuilder {
user_info.is_some()
}),
login_link: Some("/azure-auth/login".to_string()),
logout_link: Some("/azure-auth/logout".to_string())
logout_link: Some("/azure-auth/logout".to_string()),
file_upload_directory: "./file_uploads"
};
let mut admin_builder = ActixAdminBuilder::new(configuration);
@ -135,7 +136,8 @@ fn create_actix_admin_builder() -> ActixAdminBuilder {
#[actix_rt::main]
async fn main() {
dotenv::dotenv().ok();
dotenv::from_filename("./examples/azure_auth/.env.example").ok();
dotenv::from_filename("./examples/azure_auth/.env").ok();
let oauth2_client_id = env::var("OAUTH2_CLIENT_ID").expect("Missing the OAUTH2_CLIENT_ID environment variable.");
let oauth2_client_secret = env::var("OAUTH2_CLIENT_SECRET").expect("Missing the OAUTH2_CLIENT_SECRET environment variable.");

View File

@ -1,12 +0,0 @@
[package]
name = "actix-admin-example"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.0.1"
actix-rt = "2.7.0"
serde = "1.0.136"
serde_derive = "1.0.136"
sea-orm = { version = "^0.9.1", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros" ], default-features = true }
actix-admin = { path = "../../" }

View File

@ -28,6 +28,7 @@ pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
.col(ColumnDef::new(post::Column::TeaMandatory).string().not_null())
.col(ColumnDef::new(post::Column::TeaOptional).string())
.col(ColumnDef::new(post::Column::InsertDate).date())
.col(ColumnDef::new(post::Column::Attachment).string())
.to_owned();
let _result = create_table(db, &stmt).await;

View File

@ -12,7 +12,7 @@ pub struct Model {
#[serde(skip_deserializing)]
#[actix_admin(primary_key)]
pub id: i32,
#[actix_admin(searchable)]
#[actix_admin(searchable, not_empty)]
pub title: String,
#[sea_orm(column_type = "Text")]
#[actix_admin(searchable, textarea)]
@ -22,6 +22,8 @@ pub struct Model {
#[actix_admin(select_list="Tea")]
pub tea_optional: Option<Tea>,
pub insert_date: Date,
#[actix_admin(file_upload)]
pub attachment: String
}
impl Display for Model {

View File

@ -31,7 +31,8 @@ fn create_actix_admin_builder() -> ActixAdminBuilder {
enable_auth: false,
user_is_logged_in: None,
login_link: None,
logout_link: None
logout_link: None,
file_upload_directory: "./file_uploads"
};
let mut admin_builder = ActixAdminBuilder::new(configuration);

View File

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Actix Admin Example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body>
<ul>
<li><a href="/admin/">Go to Actix-Admin</a></li>
</ul>
</body>
</html>

View File

@ -1,17 +0,0 @@
[package]
name = "actix-admin-example"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.0.1"
actix-rt = "2.7.0"
actix-session = { version = "0.7.1", features = ["cookie-session"] }
tera = "1.15.0"
oauth2 = "4.1"
dotenv = "0.15"
serde = "1.0.136"
serde_derive = "1.0.136"
sea-orm = { version = "^0.9.1", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros" ], default-features = true }
actix-admin = { path = "../../" }
azure_auth = { path = "./azure_auth" }

View File

@ -1 +0,0 @@
Rename .env.example to .env and update the oauth client credentials

View File

@ -1,9 +1,10 @@
use crate::{prelude::*, ActixAdminMenuElement};
use crate::{prelude::*, ActixAdminMenuElement, routes::delete_static_content};
use actix_web::{web, Route};
use std::collections::HashMap;
use std::fs;
use crate::routes::{
create_get, create_post, delete, delete_many, edit_get, edit_post, index, list, not_found, show,
create_get, create_post, delete, delete_many, edit_get, edit_post, index, list, not_found, show, download
};
/// Represents a builder entity which helps generating the ActixAdmin configuration
@ -102,9 +103,13 @@ impl ActixAdminBuilderTrait for ActixAdminBuilder {
.route("/delete", web::delete().to(delete_many::<T, E>))
.route("/delete/{id}", web::delete().to(delete::<T, E>))
.route("/show/{id}", web::get().to(show::<T, E>))
.route("/static_content/{id}/{column_name}", web::get().to(download::<T, E>))
.route("/static_content/{id}/{column_name}", web::delete().to(delete_static_content::<T, E>))
.default_service(web::to(not_found)),
);
fs::create_dir_all(format!("{}/{}", &self.actix_admin.configuration.file_upload_directory, E::get_entity_name())).unwrap();
let category = self.actix_admin.entity_names.get_mut(category_name);
let menu_element = ActixAdminMenuElement {
name: E::get_entity_name(),

View File

@ -96,6 +96,7 @@ pub fn get_html_input_type<S: BuildHasher>(value: &tera::Value, _: &HashMap<Stri
ActixAdminViewModelFieldType::DateTime => "datetime-local",
ActixAdminViewModelFieldType::Date => "date",
ActixAdminViewModelFieldType::Checkbox => "checkbox",
ActixAdminViewModelFieldType::FileUpload => "file",
_ => "text"
};
@ -120,7 +121,8 @@ pub struct ActixAdminConfiguration {
pub enable_auth: bool,
pub user_is_logged_in: Option<for<'a> fn(&'a Session) -> bool>,
pub login_link: Option<String>,
pub logout_link: Option<String>
pub logout_link: Option<String>,
pub file_upload_directory: &'static str
}
#[derive(Clone)]

View File

@ -1,11 +1,15 @@
use crate::{ ActixAdminViewModelField, ActixAdminError};
use crate::{ActixAdminError, ActixAdminViewModelField};
use actix_multipart::{Multipart, MultipartError};
use actix_web::web::Bytes;
use async_trait::async_trait;
use chrono::{NaiveDate, NaiveDateTime};
use futures_util::stream::StreamExt as _;
use sea_orm::DatabaseConnection;
use serde::Serialize;
use std::collections::HashMap;
use actix_multipart:: {Multipart, MultipartError} ;
use futures_util::stream::StreamExt as _;
use chrono::{NaiveDateTime, NaiveDate};
use std::fs::File;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
#[async_trait]
pub trait ActixAdminModelTrait {
@ -13,9 +17,9 @@ pub trait ActixAdminModelTrait {
db: &DatabaseConnection,
page: usize,
posts_per_page: usize,
search: &String
search: &String,
) -> Result<(usize, Vec<ActixAdminModel>), ActixAdminError>;
fn get_fields() -> &'static[ActixAdminViewModelField];
fn get_fields() -> &'static [ActixAdminViewModelField];
fn validate_model(model: &mut ActixAdminModel);
}
@ -30,10 +34,9 @@ pub struct ActixAdminModel {
pub primary_key: Option<String>,
pub values: HashMap<String, String>,
pub errors: HashMap<String, String>,
pub custom_errors: HashMap<String, String>
pub custom_errors: HashMap<String, String>,
}
impl ActixAdminModel {
pub fn create_empty() -> ActixAdminModel {
ActixAdminModel {
@ -44,21 +47,47 @@ impl ActixAdminModel {
}
}
pub async fn create_from_payload(mut payload: Multipart) -> Result<ActixAdminModel, MultipartError> {
pub async fn create_from_payload(
mut payload: Multipart, file_upload_folder: &str
) -> Result<ActixAdminModel, MultipartError> {
let mut hashmap = HashMap::<String, String>::new();
while let Some(item) = payload.next().await {
let mut field = item?;
// TODO: how to handle binary chunks?
let mut binary_data: Vec<Bytes> = Vec::new();
while let Some(chunk) = field.next().await {
binary_data.push(chunk.unwrap());
//println!("-- CHUNK: \n{:?}", String::from_utf8(chunk.map_or(Vec::new(), |c| c.to_vec())));
let res_string = String::from_utf8(chunk.map_or(Vec::new(), |c| c.to_vec()));
if res_string.is_ok() {
// let res_string = String::from_utf8(chunk.map_or(Vec::new(), |c| c.to_vec()));
}
let binary_data = binary_data.concat();
if field.content_disposition().get_filename().is_some() {
let mut filename = field
.content_disposition()
.get_filename()
.unwrap()
.to_string();
let mut file_path = format!("{}/{}", file_upload_folder, filename);
let file_exists = std::path::Path::new(&file_path).exists();
// Avoid overwriting existing files
if file_exists {
filename = format!("{}_{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), filename);
file_path = format!("{}/{}", file_upload_folder, filename);
}
let file = File::create(file_path);
let _res = file.unwrap().write_all(&binary_data);
hashmap.insert(
field.name().to_string(),
res_string.unwrap()
filename.clone()
);
} else {
let res_string = String::from_utf8(binary_data);
if res_string.is_ok() {
hashmap.insert(field.name().to_string(), res_string.unwrap());
}
}
}
@ -71,35 +100,68 @@ impl ActixAdminModel {
})
}
pub fn get_value<T: std::str::FromStr>(&self, key: &str, is_option_or_string: bool) -> Result<Option<T>, String> {
self.get_value_by_closure(key, is_option_or_string, |val| val.parse::<T>())
pub fn get_value<T: std::str::FromStr>(
&self,
key: &str,
is_option_or_string: bool,
is_allowed_to_be_empty: bool
) -> Result<Option<T>, String> {
self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| val.parse::<T>())
}
pub fn get_datetime(&self, key: &str, is_option_or_string: bool) -> Result<Option<NaiveDateTime>, String> {
self.get_value_by_closure(key, is_option_or_string, |val| NaiveDateTime::parse_from_str(val, "%Y-%m-%dT%H:%M"))
pub fn get_datetime(
&self,
key: &str,
is_option_or_string: bool,
is_allowed_to_be_empty: bool
) -> Result<Option<NaiveDateTime>, String> {
self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
NaiveDateTime::parse_from_str(val, "%Y-%m-%dT%H:%M")
})
}
pub fn get_date(&self, key: &str, is_option_or_string: bool) -> Result<Option<NaiveDate>, String> {
self.get_value_by_closure(key, is_option_or_string, |val| NaiveDate::parse_from_str(val, "%Y-%m-%d"))
pub fn get_date(
&self,
key: &str,
is_option_or_string: bool,
is_allowed_to_be_empty: bool
) -> Result<Option<NaiveDate>, String> {
self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty, |val| {
NaiveDate::parse_from_str(val, "%Y-%m-%d")
})
}
pub fn get_bool(&self, key: &str, is_option_or_string: bool) -> Result<Option<bool>, String> {
let val = self.get_value_by_closure(key, is_option_or_string, |val| if !val.is_empty() && (val == "true" || val == "yes") { Ok(true) } else { Ok(false) });
pub fn get_bool(&self, key: &str, is_option_or_string: bool, is_allowed_to_be_empty: bool) -> Result<Option<bool>, String> {
let val = self.get_value_by_closure(key, is_option_or_string, is_allowed_to_be_empty ,|val| {
if !val.is_empty() && (val == "true" || val == "yes") {
Ok(true)
} else {
Ok(false)
}
});
// not selected bool field equals to false and not to missing
match val {
Ok(val) => Ok(val),
Err(_) => Ok(Some(false))
Err(_) => Ok(Some(false)),
}
}
fn get_value_by_closure<T: std::str::FromStr>(&self, key: &str, is_option_or_string: bool, f: impl Fn(&String) -> Result<T, <T as std::str::FromStr>::Err>) -> Result<Option<T>, String> {
fn get_value_by_closure<T: std::str::FromStr>(
&self,
key: &str,
is_option_or_string: bool,
is_allowed_to_be_empty: bool,
f: impl Fn(&String) -> Result<T, <T as std::str::FromStr>::Err>,
) -> Result<Option<T>, String> {
let value = self.values.get(key);
let res: Result<Option<T>, String> = match value {
Some(val) => {
if val.is_empty() && is_option_or_string {
return Ok(None);
}
match (val.is_empty(), is_option_or_string, is_allowed_to_be_empty) {
(true, true, true) => return Ok(None),
(true, true, false) => return Err("Cannot be empty".to_string()),
_ => {}
};
let parsed_val = f(val);
@ -109,9 +171,10 @@ impl ActixAdminModel {
}
}
_ => {
match is_option_or_string {
true => Ok(None),
false => Err("Invalid Value".to_string()) // a missing value in the form for a non-optional value
match (is_option_or_string, is_allowed_to_be_empty) {
(true, true) => Ok(None),
(true, false) => Err("Cannot be empty".to_string()),
(false, _) => Err("Invalid Value".to_string()), // a missing value in the form for a non-optional value
}
}
};

View File

@ -70,7 +70,7 @@ async fn create_or_edit_get<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTra
ctx.insert("view_model", &ActixAdminViewModelSerializable::from(view_model.clone()));
ctx.insert("select_lists", &E::get_select_lists(db).await?);
ctx.insert("list_link", &E::get_list_link(&entity_name));
ctx.insert("base_path", &E::get_base_path(&entity_name));
ctx.insert("model", &model);
ctx.insert("notifications", &notifications);

View File

@ -1,23 +1,28 @@
use super::{render_unauthorized, user_can_access_page};
use crate::prelude::*;
use crate::ActixAdminError;
use crate::ActixAdminNotification;
use crate::prelude::*;
use crate::TERA;
use actix_multipart::Multipart;
use actix_multipart::MultipartError;
use actix_session::Session;
use actix_web::http::header;
use actix_web::{error, web, Error, HttpResponse};
use tera::Context;
use actix_multipart::Multipart;
use std::collections::HashMap;
use tera::Context;
pub async fn create_post<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
session: Session,
data: web::Data<T>,
payload: Multipart,
) -> Result<HttpResponse, Error> {
let model = ActixAdminModel::create_from_payload(payload).await;
create_or_edit_post::<T, E>(&session, &data, model, None).await
let actix_admin = data.get_actix_admin();
let model = ActixAdminModel::create_from_payload(
payload,
&format!("{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name())
)
.await;
create_or_edit_post::<T, E>(&session, &data, model, None, actix_admin).await
}
pub async fn edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
@ -26,8 +31,13 @@ pub async fn edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
payload: Multipart,
id: web::Path<i32>,
) -> Result<HttpResponse, Error> {
let model = ActixAdminModel::create_from_payload(payload).await;
create_or_edit_post::<T, E>(&session, &data, model, Some(id.into_inner())).await
let actix_admin = data.get_actix_admin();
let model = ActixAdminModel::create_from_payload(
payload,
&format!("{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name()),
)
.await;
create_or_edit_post::<T, E>(&session, &data, model, Some(id.into_inner()), actix_admin).await
}
pub async fn create_or_edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
@ -35,8 +45,8 @@ pub async fn create_or_edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewMod
data: &web::Data<T>,
model_res: Result<ActixAdminModel, MultipartError>,
id: Option<i32>,
actix_admin: &ActixAdmin,
) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let entity_name = E::get_entity_name();
let view_model = actix_admin.view_models.get(&entity_name).unwrap();
@ -62,14 +72,12 @@ pub async fn create_or_edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewMod
};
match res {
Ok(_) => {
Ok(HttpResponse::SeeOther()
Ok(_) => Ok(HttpResponse::SeeOther()
.append_header((
header::LOCATION,
format!("/admin/{}/list", view_model.entity_name),
))
.finish())
},
.finish()),
Err(e) => {
errors.push(e);
render_form::<E>(actix_admin, view_model, db, entity_name, &model, errors).await
@ -78,7 +86,14 @@ pub async fn create_or_edit_post<T: ActixAdminAppDataTrait, E: ActixAdminViewMod
}
}
async fn render_form<E: ActixAdminViewModelTrait>(actix_admin: &ActixAdmin, view_model: &ActixAdminViewModel, db: &&sea_orm::DatabaseConnection, entity_name: String, model: &ActixAdminModel, errors: Vec<ActixAdminError>) -> Result<HttpResponse, Error> {
async fn render_form<E: ActixAdminViewModelTrait>(
actix_admin: &ActixAdmin,
view_model: &ActixAdminViewModel,
db: &&sea_orm::DatabaseConnection,
entity_name: String,
model: &ActixAdminModel,
errors: Vec<ActixAdminError>,
) -> Result<HttpResponse, Error> {
let mut ctx = Context::new();
ctx.insert("entity_names", &actix_admin.entity_names);
ctx.insert(
@ -86,10 +101,11 @@ async fn render_form<E: ActixAdminViewModelTrait>(actix_admin: &ActixAdmin, view
&ActixAdminViewModelSerializable::from(view_model.clone()),
);
ctx.insert("select_lists", &E::get_select_lists(db).await?);
ctx.insert("list_link", &E::get_list_link(&entity_name));
ctx.insert("base_path", &E::get_base_path(&entity_name));
ctx.insert("model", model);
let notifications: Vec<ActixAdminNotification> = errors.into_iter()
let notifications: Vec<ActixAdminNotification> = errors
.into_iter()
.map(|err| ActixAdminNotification::from(err))
.collect();

View File

@ -1,7 +1,7 @@
use actix_web::{web, Error, HttpRequest, HttpResponse};
use actix_web::http::header;
use actix_session::{Session};
use crate::prelude::*;
use crate::{prelude::*};
use tera::{Context};
use super::{ user_can_access_page, render_unauthorized};
@ -24,11 +24,23 @@ pub async fn delete<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(
}
let db = &data.get_db();
let result = E::delete_entity(db, id.into_inner()).await;
let id = id.into_inner();
let model_result = E::get_entity(db, id).await;
let delete_result = E::delete_entity(db, id).await;
match result {
Ok(_) => Ok(HttpResponse::Ok().finish()),
Err(_) => Ok(HttpResponse::InternalServerError().finish())
match (model_result, delete_result) {
(Ok(model), Ok(_)) => {
for field in view_model.fields {
if field.field_type == ActixAdminViewModelFieldType::FileUpload {
let file_name = model.get_value::<String>(&field.field_name, true, true).unwrap_or_default();
let file_path = format!("{}/{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name(), file_name.unwrap_or_default());
std::fs::remove_file(file_path)?;
}
}
Ok(HttpResponse::Ok().finish())
},
(_,_) => Ok(HttpResponse::InternalServerError().finish())
}
}
@ -60,10 +72,20 @@ pub async fn delete_many<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>
// TODO: implement delete_many
for id in entity_ids {
let result = E::delete_entity(db, id).await;
match result {
Err(e) => errors.push(e),
_ => {}
let model_result = E::get_entity(db, id).await;
let delete_result = E::delete_entity(db, id).await;
match (delete_result, model_result) {
(Err(e), _) => errors.push(e),
(Ok(_), Ok(model)) => {
for field in view_model.fields {
if field.field_type == ActixAdminViewModelFieldType::FileUpload {
let file_name = model.get_value::<String>(&field.field_name, true, true).unwrap_or_default();
let file_path = format!("{}/{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name(), file_name.unwrap_or_default());
std::fs::remove_file(file_path)?;
}
}
},
(Ok(_), Err(e)) => errors.push(e)
}
}

View File

@ -18,3 +18,6 @@ pub use delete::{ delete, delete_many };
mod helpers;
pub use helpers::{ add_auth_context, user_can_access_page, render_unauthorized };
mod static_content;
pub use static_content::{download, delete_static_content};

View File

@ -6,17 +6,22 @@ use crate::ActixAdminNotification;
use crate::prelude::*;
use crate::TERA;
use super::{ add_auth_context };
use super::{ add_auth_context, user_can_access_page, render_unauthorized};
pub async fn show<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(session: Session, data: web::Data<T>, id: web::Path<i32>) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let db = &data.get_db();
let result = E::get_entity(db, id.into_inner()).await;
let mut ctx = Context::new();
let entity_name = E::get_entity_name();
let view_model: &ActixAdminViewModel = actix_admin.view_models.get(&entity_name).unwrap();
if !user_can_access_page(&session, actix_admin, view_model) {
return render_unauthorized(&ctx);
}
let mut errors: Vec<crate::ActixAdminError> = Vec::new();
let result = E::get_entity(db, id.into_inner()).await;
let model;
match result {
Ok(res) => {
model = res;
@ -27,9 +32,6 @@ pub async fn show<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(sessio
}
}
let entity_name = E::get_entity_name();
let view_model: &ActixAdminViewModel = actix_admin.view_models.get(&entity_name).unwrap();
let mut http_response_code = match errors.is_empty() {
false => HttpResponse::InternalServerError(),
true => HttpResponse::Ok()
@ -38,10 +40,9 @@ pub async fn show<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(sessio
.map(|err| ActixAdminNotification::from(err))
.collect();
let mut ctx = Context::new();
ctx.insert("model", &model);
ctx.insert("view_model", &ActixAdminViewModelSerializable::from(view_model.clone()));
ctx.insert("list_link", &E::get_list_link(&entity_name));
ctx.insert("base_path", &E::get_base_path(&entity_name));
ctx.insert("entity_names", &actix_admin.entity_names);
ctx.insert("notifications", &notifications);

View File

@ -0,0 +1,85 @@
use actix_web::{web, error, Error, HttpResponse, HttpRequest};
use actix_session::{Session};
use tera::{Context};
use crate::prelude::*;
use super::{ user_can_access_page, render_unauthorized};
pub async fn download<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(req: HttpRequest, session: Session, data: web::Data<T>, params: web::Path<(i32, String)>) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let db = &data.get_db();
let ctx = Context::new();
let entity_name = E::get_entity_name();
let view_model: &ActixAdminViewModel = actix_admin.view_models.get(&entity_name).unwrap();
if !user_can_access_page(&session, actix_admin, view_model) {
return render_unauthorized(&ctx);
}
let (id, column_name) = params.into_inner();
let mut errors: Vec<crate::ActixAdminError> = Vec::new();
let result = E::get_entity(db, id).await;
let model;
match result {
Ok(res) => {
model = res;
},
Err(e) => {
errors.push(e);
model = ActixAdminModel::create_empty();
}
}
let file_name = model.get_value::<String>(&column_name, true, true).unwrap_or_default();
let file_path = format!("{}/{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name(), file_name.unwrap_or_default());
let file = actix_files::NamedFile::open_async(file_path).await;
match file {
Ok(file) => Ok(file.into_response(&req)),
Err(_e) => Ok(HttpResponse::NotFound().content_type("text/html").body(""))
}
}
pub async fn delete_static_content<T: ActixAdminAppDataTrait, E: ActixAdminViewModelTrait>(session: Session, data: web::Data<T>, params: web::Path<(i32, String)>) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let db = &data.get_db();
let mut ctx = Context::new();
let entity_name = E::get_entity_name();
let view_model: &ActixAdminViewModel = actix_admin.view_models.get(&entity_name).unwrap();
if !user_can_access_page(&session, actix_admin, view_model) {
return render_unauthorized(&ctx);
}
let (id, column_name) = params.into_inner();
let mut errors: Vec<crate::ActixAdminError> = Vec::new();
let result = E::get_entity(db, id).await;
let mut model;
match result {
Ok(res) => {
model = res;
},
Err(e) => {
errors.push(e);
model = ActixAdminModel::create_empty();
}
}
let file_name = model.get_value::<String>(&column_name, true, true).unwrap_or_default();
let file_path = format!("{}/{}/{}", actix_admin.configuration.file_upload_directory, E::get_entity_name(), file_name.unwrap_or_default());
std::fs::remove_file(file_path).unwrap();
model.values.remove(&column_name);
let _edit_res = E::edit_entity(db, id, model.clone()).await;
let view_model_field = &view_model.fields.iter().find(|field| field.field_name == column_name).unwrap();
ctx.insert("model_field", view_model_field);
ctx.insert("base_path", &E::get_base_path(&entity_name));
ctx.insert("model", &model);
let body = TERA
.render("form_elements/input.html", &ctx)
.map_err(|err| error::ErrorInternalServerError(err))? ;
Ok(HttpResponse::Ok().content_type("text/html").body(body))
}

View File

@ -26,8 +26,8 @@ pub trait ActixAdminViewModelTrait {
fn get_entity_name() -> String;
fn get_list_link(entity_name: &String) -> String {
format!("/admin/{}/list", entity_name)
fn get_base_path(entity_name: &String) -> String {
format!("/admin/{}", entity_name)
}
}
@ -61,7 +61,7 @@ impl From<ActixAdminViewModel> for ActixAdminViewModelSerializable {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum ActixAdminViewModelFieldType {
Number,
Text,
@ -70,7 +70,8 @@ pub enum ActixAdminViewModelFieldType {
Date,
Time,
DateTime,
SelectList
SelectList,
FileUpload
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -83,13 +84,16 @@ pub struct ActixAdminViewModelField {
}
impl ActixAdminViewModelFieldType {
pub fn get_field_type(type_path: &str, select_list: String, is_textarea: bool) -> ActixAdminViewModelFieldType {
pub fn get_field_type(type_path: &str, select_list: String, is_textarea: bool, is_file_upload: bool) -> ActixAdminViewModelFieldType {
if !select_list.is_empty() {
return ActixAdminViewModelFieldType::SelectList;
}
if is_textarea {
return ActixAdminViewModelFieldType::TextArea;
}
if is_file_upload {
return ActixAdminViewModelFieldType::FileUpload;
}
match type_path {
"i32" => ActixAdminViewModelFieldType::Number,

View File

@ -29,7 +29,7 @@
<button class="button is-link" type="submit">Save</i></button>
</div>
<div class="control">
<a class="button is-link is-light" href="{{ list_link }}">Cancel</a>
<a class="button is-link is-light" href="{{ base_path }}/list">Cancel</a>
</div>
</div>
</form>

View File

@ -1,6 +1,5 @@
{% if model_field.field_type == "TextArea" %}
<textarea
class="{{ model_field | get_html_input_class }}
<textarea class="{{ model_field | get_html_input_class }}
{% if model.errors | length > 0 or model.custom_errors | length > 0 %}
{% if
model.errors | get(key=model_field.field_name, default="" ) !=""
@ -8,15 +7,18 @@
model.custom_errors | get(key=model_field.field_name, default="" ) !=""
%}is-danger{% else %}is-success{% endif %}
{% endif %}
"
type="{{ model_field | get_html_input_type }}"
name="{{ model_field.field_name }}"
" type="{{ model_field | get_html_input_type }}" name="{{ model_field.field_name }}"
placeholder="{{ model_field.field_name }}"
aria-label="{{ model_field.field_name }}"
>{{ model.values | get(key=model_field.field_name, default="") }}</textarea>
aria-label="{{ model_field.field_name }}">{{ model.values | get(key=model_field.field_name, default="") }}</textarea>
{% elif model_field.field_type == "FileUpload" and model.values | get(key=model_field.field_name, default="") != "" %}
<div>
<a hx-disable href="{{ base_path }}/static_content/{{ model.primary_key }}/{{ model_field.field_name }}">{{ model.values |
get(key=model_field.field_name, default="") }}</a>
<a class="is-pulled-right" hx-target="closest div" hx-push-url="false" hx-delete="{{ base_path }}/static_content/{{ model.primary_key }}/{{ model_field.field_name }}"
hx-confirm="Are you sure?"><i class="fa-solid fa-trash"></i></a>
</div>
{% else %}
<input
class="{{ model_field | get_html_input_class }}
<input class="{{ model_field | get_html_input_class }}
{% if model.errors | length > 0 or model.custom_errors | length > 0 %}
{% if
model.errors | get(key=model_field.field_name, default="" ) !=""
@ -24,11 +26,7 @@
model.custom_errors | get(key=model_field.field_name, default="" ) !=""
%}is-danger{% else %}is-success{% endif %}
{% endif %}
"
type="{{ model_field | get_html_input_type }}"
value="{{ model.values | get(key=model_field.field_name, default="") }}"
name="{{ model_field.field_name }}"
placeholder="{{ model_field.field_name }}"
aria-label="{{ model_field.field_name }}"
>
" type="{{ model_field | get_html_input_type }}"
value="{{ model.values | get(key=model_field.field_name, default="") }}" name="{{ model_field.field_name }}"
placeholder="{{ model_field.field_name }}" aria-label="{{ model_field.field_name }}">
{% endif %}

View File

@ -94,6 +94,8 @@
{% for model_field in view_model.fields -%}
{% if model_field.field_type == "Checkbox" %}
<td>{{ entity.values | get(key=model_field.field_name) | get_icon | safe }}</td>
{% elif model_field.field_type == "FileUpload" %}
<td><a href="static_content/{{ entity.primary_key }}/{{ model_field.field_name }}">{{ entity.values | get(key=model_field.field_name) }}</a></td>
{% else %}
<td>{{ entity.values | get(key=model_field.field_name) }}</td>
{% endif %}

View File

@ -8,6 +8,8 @@
<p>
{% if model_field.field_type == "Checkbox" %}
<td>{{ model.values | get(key=model_field.field_name) | get_icon | safe }}</td>
{% elif model_field.field_type == "FileUpload" %}
<td><a href="static_content/{{ entity.primary_key }}/{{ model_field.field_name }}">{{ entity.values | get(key=model_field.field_name) }}</a></td>
{% else %}
<td>{{ model.values | get(key=model_field.field_name) }}</td>
{% endif %}
@ -19,7 +21,7 @@
<div class="column">
<div class="field is-grouped">
<div class="control">
<a class="button is-link is-light" href="{{ list_link }}">Back</a>
<a class="button is-link is-light" href="{{ base_path }}/list">Back</a>
</div>
</div>
</div>

View File

@ -41,6 +41,7 @@ pub fn create_actix_admin_builder() -> ActixAdminBuilder {
user_is_logged_in: None,
login_link: None,
logout_link: None,
file_upload_directory: "./file_uploads"
};
let mut admin_builder = ActixAdminBuilder::new(configuration);
@ -78,8 +79,9 @@ async fn create_post_from_plaintext<
data: web::Data<T>,
text: String,
) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let model = ActixAdminModel::from(text);
create_or_edit_post::<T, E>(&session, &data, Ok(model), None).await
create_or_edit_post::<T, E>(&session, &data, Ok(model), None, actix_admin).await
}
async fn edit_post_from_plaintext<
@ -91,6 +93,7 @@ async fn edit_post_from_plaintext<
text: String,
id: web::Path<i32>,
) -> Result<HttpResponse, Error> {
let actix_admin = data.get_actix_admin();
let model = ActixAdminModel::from(text);
create_or_edit_post::<T, E>(&session, &data, Ok(model), Some(id.into_inner())).await
create_or_edit_post::<T, E>(&session, &data, Ok(model), Some(id.into_inner()), actix_admin).await
}