Recipe edit (WIP): forms to edit groups, steps and ingredients

This commit is contained in:
Greg Burri 2024-12-26 01:39:07 +01:00
parent dd05a673d9
commit 07b7ff425e
25 changed files with 881 additions and 203 deletions

View file

@ -41,22 +41,31 @@ impl fmt::Debug for Config {
pub fn load() -> Config {
match File::open(consts::FILE_CONF) {
Ok(file) => from_reader(file).expect(&format!(
"Failed to open configuration file {}",
consts::FILE_CONF
)),
Ok(file) => from_reader(file).unwrap_or_else(|error| {
panic!(
"Failed to open configuration file {}: {}",
consts::FILE_CONF,
error
)
}),
Err(_) => {
let file = File::create(consts::FILE_CONF).expect(&format!(
"Failed to create default configuration file {}",
consts::FILE_CONF
));
let file = File::create(consts::FILE_CONF).unwrap_or_else(|error| {
panic!(
"Failed to create default configuration file {}: {}",
consts::FILE_CONF,
error
)
});
let default_config = Config::default();
to_writer_pretty(file, &default_config, PrettyConfig::new()).expect(&format!(
"Failed to write default configuration file {}",
consts::FILE_CONF
));
to_writer_pretty(file, &default_config, PrettyConfig::new()).unwrap_or_else(|error| {
panic!(
"Failed to write default configuration file {}: {}",
consts::FILE_CONF,
error
)
});
default_config
}

View file

@ -4,10 +4,10 @@ pub const FILE_CONF: &str = "conf.ron";
pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
pub const VALIDATION_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 60 * 60; // [s]. (1 jour).
// Number of alphanumeric characters for tokens
// (cookie authentication, password reset, validation token).
@ -21,4 +21,4 @@ pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse
pub const MAX_DB_CONNECTION: u32 = 1024;
pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")];
pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")];

View file

@ -196,26 +196,10 @@ WHERE [type] = 'table' AND [name] = 'Version'
}
fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
let mut file = File::open(&sql_file).map_err(|err| {
DBError::Other(format!(
"Cannot open SQL file ({}): {}",
&sql_file,
err.to_string()
))
})?;
let mut file = File::open(&sql_file)
.map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err)))?;
let mut sql = String::new();
file.read_to_string(&mut sql).map_err(|err| {
DBError::Other(format!(
"Cannot read SQL file ({}) : {}",
&sql_file,
err.to_string()
))
})?;
file.read_to_string(&mut sql)
.map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err)))?;
Ok(sql)
}
// #[cfg(test)]
// mod tests {
// use super::*;
// }

View file

@ -45,6 +45,21 @@ ORDER BY [title]
.map_err(DBError::from)
}
pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] = $1 AND [user_id] = $2
"#,
)
.bind(group_id)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(DBError::from)
}
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as(
r#"
@ -166,6 +181,88 @@ WHERE [Recipe].[user_id] = $1
.map(|_| ())
.map_err(DBError::from)
}
pub async fn get_groups(&self, recipe_id: i64) -> Result<Vec<model::Group>> {
let mut tx = self.tx().await?;
let mut groups: Vec<model::Group> = sqlx::query_as(
r#"
SELECT [id], [name], [comment]
FROM [Group]
WHERE [recipe_id] = $1
ORDER BY [order]
"#,
)
.bind(recipe_id)
.fetch_all(&mut *tx)
.await?;
for group in groups.iter_mut() {
group.steps = sqlx::query_as(
r#"
SELECT [id], [action]
FROM [Step]
WHERE [group_id] = $1
ORDER BY [order]
"#,
)
.bind(group.id)
.fetch_all(&mut *tx)
.await?;
for step in group.steps.iter_mut() {
step.ingredients = sqlx::query_as(
r#"
SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
FROM [Ingredient]
WHERE [step_id] = $1
ORDER BY [name]
"#,
)
.bind(step.id)
.fetch_all(&mut *tx)
.await?;
}
}
Ok(groups)
}
pub async fn add_recipe_group(&self, recipe_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id]) VALUES ($1)")
.bind(recipe_id)
.execute(&self.pool)
.await?;
Ok(db_result.last_insert_rowid())
}
pub async fn rm_recipe_group(&self, group_id: i64) -> Result<()> {
sqlx::query("DELETE FROM [Group] WHERE [id] = $1")
.bind(group_id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_group_name(&self, group_id: i64, name: &str) -> Result<()> {
sqlx::query("UPDATE [Group] SET [name] = $2 WHERE [id] = $1")
.bind(group_id)
.bind(name)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_group_comment(&self, group_id: i64, comment: &str) -> Result<()> {
sqlx::query("UPDATE [Group] SET [comment] = $2 WHERE [id] = $1")
.bind(group_id)
.bind(comment)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
}
#[cfg(test)]
@ -214,7 +311,7 @@ mod tests {
assert_eq!(recipe.estimated_time, Some(420));
assert_eq!(recipe.difficulty, Difficulty::Medium);
assert_eq!(recipe.lang, "fr");
assert_eq!(recipe.is_published, true);
assert!(recipe.is_published);
Ok(())
}

View file

@ -190,7 +190,7 @@ FROM [User] WHERE [email] = $1
return Ok(SignUpResult::UserAlreadyExists);
}
let token = generate_token();
let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
sqlx::query(
r#"
UPDATE [User]
@ -208,7 +208,7 @@ WHERE [id] = $1
}
None => {
let token = generate_token();
let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
let hashed_password = hash(password).map_err(DBError::from_dyn_error)?;
sqlx::query(
r#"
INSERT INTO [User]
@ -336,19 +336,18 @@ WHERE [id] = $1
pub async fn sign_out(&self, token: &str) -> Result<()> {
let mut tx = self.tx().await?;
match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
.bind(token)
.fetch_optional(&mut *tx)
.await?
if let Some(login_id) =
sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
.bind(token)
.fetch_optional(&mut *tx)
.await?
{
Some(login_id) => {
sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
.bind(login_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
None => (),
sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
.bind(login_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
Ok(())
}
@ -429,7 +428,7 @@ WHERE [password_reset_token] = $1
.execute(&mut *tx)
.await?;
let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
let hashed_new_password = hash(new_password).map_err(DBError::from_dyn_error)?;
sqlx::query(
r#"
@ -853,7 +852,7 @@ VALUES (
};
connection
.reset_password(&new_password, &token, Duration::hours(1))
.reset_password(new_password, &token, Duration::hours(1))
.await?;
// Sign in.

View file

@ -34,20 +34,30 @@ pub struct Recipe {
// pub groups: Vec<Group>,
}
#[derive(FromRow)]
pub struct Group {
pub id: i64,
pub name: String,
pub comment: String,
#[sqlx(skip)]
pub steps: Vec<Step>,
}
#[derive(FromRow)]
pub struct Step {
pub id: i64,
pub action: String,
#[sqlx(skip)]
pub ingredients: Vec<Ingredient>,
}
#[derive(FromRow)]
pub struct Ingredient {
pub id: i64,
pub name: String,
pub comment: String,
pub quantity: i32,
pub quantity_value: f64,
pub quantity_unit: String,
}

View file

@ -9,20 +9,20 @@ use crate::consts;
#[derive(Debug, Display)]
pub enum Error {
ParseError(lettre::address::AddressError),
SmtpError(lettre::transport::smtp::Error),
Parse(lettre::address::AddressError),
Smtp(lettre::transport::smtp::Error),
Email(lettre::error::Error),
}
impl From<lettre::address::AddressError> for Error {
fn from(error: lettre::address::AddressError) -> Self {
Error::ParseError(error)
Error::Parse(error)
}
}
impl From<lettre::transport::smtp::Error> for Error {
fn from(error: lettre::transport::smtp::Error) -> Self {
Error::SmtpError(error)
Error::Smtp(error)
}
}

View file

@ -5,7 +5,7 @@ use axum::{
http::StatusCode,
middleware::{self, Next},
response::{Response, Result},
routing::{get, put},
routing::{delete, get, post, put},
Router,
};
use axum_extra::extract::cookie::CookieJar;
@ -101,6 +101,14 @@ async fn main() {
"/recipe/set_is_published",
put(services::ron::set_is_published),
)
.route("/recipe/get_groups", get(services::ron::get_groups))
.route("/recipe/add_group", post(services::ron::add_group))
.route("/recipe/remove_group", delete(services::ron::rm_group))
.route("/recipe/set_group_name", put(services::ron::set_group_name))
.route(
"/recipe/set_group_comment",
put(services::ron::set_group_comment),
)
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(
@ -183,7 +191,7 @@ async fn get_current_user(
) -> Option<model::User> {
match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
Some(token_cookie) => match connection
.authentication(token_cookie.value(), &client_ip, &client_user_agent)
.authentication(token_cookie.value(), client_ip, client_user_agent)
.await
{
Ok(db::user::AuthenticationResult::NotValidToken) => None,
@ -234,12 +242,15 @@ async fn process_args() -> bool {
}
})
.unwrap();
std::fs::copy(&db_path, &db_path_bckup).expect(&format!(
"Unable to make backup of {:?} to {:?}",
&db_path, &db_path_bckup
));
std::fs::remove_file(&db_path)
.expect(&format!("Unable to remove db file: {:?}", &db_path));
std::fs::copy(&db_path, &db_path_bckup).unwrap_or_else(|error| {
panic!(
"Unable to make backup of {:?} to {:?}: {}",
&db_path, &db_path_bckup, error
)
});
std::fs::remove_file(&db_path).unwrap_or_else(|error| {
panic!("Unable to remove db file {:?}: {}", &db_path, error)
});
}
match db::Connection::new().await {

View file

@ -60,10 +60,8 @@ where
{
match from_bytes::<T>(&body) {
Ok(ron) => Ok(ron),
Err(error) => {
return Err(RonError {
error: format!("Ron parsing error: {}", error),
});
}
Err(error) => Err(RonError {
error: format!("Ron parsing error: {}", error),
}),
}
}

View file

@ -1,7 +1,7 @@
use axum::{
body, debug_handler,
extract::{Extension, Request, State},
http::header,
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response, Result},
};
@ -66,5 +66,8 @@ pub async fn home_page(
#[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
MessageTemplate::new_with_user("404: Not found", user)
(
StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", user),
)
}

View file

@ -1,8 +1,9 @@
use axum::{
debug_handler,
extract::{Extension, State},
extract::{Extension, Query, State},
response::{IntoResponse, Result},
};
use serde::Deserialize;
// use tracing::{event, Level};
use crate::{
@ -10,9 +11,15 @@ use crate::{
html_templates::*,
};
#[derive(Deserialize)]
pub struct CurrentRecipeId {
current_recipe_id: Option<i64>,
}
#[debug_handler]
pub async fn recipes_list_fragments(
State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
@ -24,8 +31,7 @@ pub async fn recipes_list_fragments(
} else {
vec![]
},
current_id: None,
current_id: current_recipe.current_recipe_id,
};
Ok(RecipesListFragmentTemplate { user, recipes })
}

View file

@ -48,13 +48,19 @@
use axum::{
debug_handler,
extract::{Extension, State},
extract::{Extension, Query, State},
http::StatusCode,
response::{ErrorResponse, IntoResponse, Result},
};
use serde::Deserialize;
// use tracing::{event, Level};
use crate::{data::db, model, ron_extractor::ExtractRon, ron_utils::ron_error};
use crate::{
data::db,
model,
ron_extractor::ExtractRon,
ron_utils::{ron_error, ron_response},
};
#[allow(dead_code)]
#[debug_handler]
@ -81,7 +87,7 @@ pub async fn update_user(
Ok(StatusCode::OK)
}
async fn check_user_rights(
async fn check_user_rights_recipe(
connection: &db::Connection,
user: &Option<model::User>,
recipe_id: i64,
@ -100,13 +106,32 @@ async fn check_user_rights(
}
}
async fn check_user_rights_recipe_group(
connection: &db::Connection,
user: &Option<model::User>,
group_id: i64,
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_group(user.as_ref().unwrap().id, group_id)
.await?
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
)))
} else {
Ok(())
}
}
#[debug_handler]
pub async fn set_recipe_title(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeTitle>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_title(ron.recipe_id, &ron.title)
.await?;
@ -119,7 +144,7 @@ pub async fn set_recipe_description(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDescription>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_description(ron.recipe_id, &ron.description)
.await?;
@ -132,7 +157,7 @@ pub async fn set_estimated_time(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeEstimatedTime>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_estimated_time(ron.recipe_id, ron.estimated_time)
.await?;
@ -145,7 +170,7 @@ pub async fn set_difficulty(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeDifficulty>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_difficulty(ron.recipe_id, ron.difficulty)
.await?;
@ -158,7 +183,7 @@ pub async fn set_language(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeLanguage>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_language(ron.recipe_id, &ron.lang)
.await?;
@ -171,13 +196,128 @@ pub async fn set_is_published(
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetIsPublished>,
) -> Result<StatusCode> {
check_user_rights(&connection, &user, ron.recipe_id).await?;
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_is_published(ron.recipe_id, ron.is_published)
.await?;
Ok(StatusCode::OK)
}
impl From<model::Group> for common::ron_api::Group {
fn from(group: model::Group) -> Self {
Self {
id: group.id,
name: group.name,
comment: group.comment,
steps: group
.steps
.into_iter()
.map(common::ron_api::Step::from)
.collect(),
}
}
}
impl From<model::Step> for common::ron_api::Step {
fn from(step: model::Step) -> Self {
Self {
id: step.id,
action: step.action,
ingredients: step
.ingredients
.into_iter()
.map(common::ron_api::Ingredient::from)
.collect(),
}
}
}
impl From<model::Ingredient> for common::ron_api::Ingredient {
fn from(ingredient: model::Ingredient) -> Self {
Self {
id: ingredient.id,
name: ingredient.name,
comment: ingredient.comment,
quantity_value: ingredient.quantity_value,
quantity_unit: ingredient.quantity_unit,
}
}
}
#[derive(Deserialize)]
pub struct RecipeId {
#[serde(rename = "recipe_id")]
id: i64,
}
#[debug_handler]
pub async fn get_groups(
State(connection): State<db::Connection>,
recipe_id: Query<RecipeId>,
) -> Result<impl IntoResponse> {
println!("PROUT");
// Here we don't check user rights on purpose.
Ok(ron_response(
StatusCode::OK,
connection
.get_groups(recipe_id.id)
.await?
.into_iter()
.map(common::ron_api::Group::from)
.collect::<Vec<_>>(),
))
}
#[debug_handler]
pub async fn add_group(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::AddRecipeGroup>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
let group_id = connection.add_recipe_group(ron.recipe_id).await?;
Ok(ron_response(
StatusCode::OK,
common::ron_api::AddRecipeGroupResult { group_id },
))
}
#[debug_handler]
pub async fn rm_group(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::RemoveRecipeGroup>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection.rm_recipe_group(ron.group_id).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_group_name(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetGroupName>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection.set_group_name(ron.group_id, &ron.name).await?;
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_group_comment(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetGroupComment>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_group(&connection, &user, ron.group_id).await?;
connection
.set_group_comment(ron.group_id, &ron.comment)
.await?;
Ok(StatusCode::OK)
}
///// 404 /////
#[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {

View file

@ -22,7 +22,7 @@ use crate::{
utils, AppState,
};
//// SIGN UP /////
/// SIGN UP ///
#[debug_handler]
pub async fn sign_up_get(
@ -207,7 +207,7 @@ pub async fn sign_up_validation(
}
}
///// SIGN IN /////
/// SIGN IN ///
#[debug_handler]
pub async fn sign_in_get(
@ -271,7 +271,7 @@ pub async fn sign_in_post(
}
}
///// SIGN OUT /////
/// SIGN OUT ///
#[debug_handler]
pub async fn sign_out(
@ -287,7 +287,7 @@ pub async fn sign_out(
Ok((jar, Redirect::to("/")))
}
///// RESET PASSWORD /////
/// RESET PASSWORD ///
#[debug_handler]
pub async fn ask_reset_password_get(
@ -510,7 +510,7 @@ pub async fn reset_password_post(
}
}
///// EDIT PROFILE /////
/// EDIT PROFILE ///
#[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
@ -614,7 +614,7 @@ pub async fn edit_user_post(
match connection
.update_user(
user.id,
Some(&email_trimmed),
Some(email_trimmed),
Some(&form_data.name),
new_password,
)