Add a way to delete recipe

This commit is contained in:
Greg Burri 2024-12-31 11:26:51 +01:00
parent 5ce3391466
commit 31bc31035a
10 changed files with 247 additions and 175 deletions

View file

@ -19,6 +19,6 @@ pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60);
// Common headers can be found in 'axum::http::header' (which is a re-export of the create 'http'). // Common headers can be found in 'axum::http::header' (which is a re-export of the create 'http').
pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx). pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx).
pub const MAX_DB_CONNECTION: u32 = 1024; pub const MAX_DB_CONNECTION: u32 = 1; // To avoid database lock.
pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")]; pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")];

View file

@ -4,10 +4,11 @@ use std::{
io::Read, io::Read,
path::Path, path::Path,
str::FromStr, str::FromStr,
time::Duration,
}; };
use sqlx::{ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
Pool, Sqlite, Transaction, Pool, Sqlite, Transaction,
}; };
use thiserror::Error; use thiserror::Error;
@ -75,8 +76,9 @@ impl Connection {
))? ))?
.journal_mode(SqliteJournalMode::Wal) // TODO: use 'Wal2' when available. .journal_mode(SqliteJournalMode::Wal) // TODO: use 'Wal2' when available.
.create_if_missing(true) .create_if_missing(true)
.pragma("foreign_keys", "ON") .busy_timeout(Duration::from_secs(10))
.pragma("synchronous", "NORMAL"); .foreign_keys(true)
.synchronous(SqliteSynchronous::Normal);
Self::create_connection( Self::create_connection(
SqlitePoolOptions::new() SqlitePoolOptions::new()

View file

@ -37,18 +37,20 @@ ORDER BY [title]
} }
pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result<bool> { pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result<bool> {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#) sqlx::query_scalar(
.bind(recipe_id) r#"SELECT COUNT(*) = 1 FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#,
.bind(user_id) )
.fetch_one(&self.pool) .bind(recipe_id)
.await .bind(user_id)
.map_err(DBError::from) .fetch_one(&self.pool)
.await
.map_err(DBError::from)
} }
pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> { pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> {
sqlx::query_scalar( sqlx::query_scalar(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*) = 1
FROM [Recipe] FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] = $1 AND [user_id] = $2 WHERE [Group].[id] = $1 AND [user_id] = $2
@ -64,7 +66,7 @@ WHERE [Group].[id] = $1 AND [user_id] = $2
pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result<bool> { pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result<bool> {
sqlx::query_scalar( sqlx::query_scalar(
r#" r#"
SELECT COUNT(*) SELECT COUNT(*) = 1
FROM [Recipe] FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id] INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
@ -171,6 +173,16 @@ WHERE [Recipe].[user_id] = $1
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn set_recipe_servings(&self, recipe_id: i64, servings: Option<u32>) -> Result<()> {
sqlx::query("UPDATE [Recipe] SET [servings] = $2 WHERE [id] = $1")
.bind(recipe_id)
.bind(servings)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_recipe_estimated_time( pub async fn set_recipe_estimated_time(
&self, &self,
recipe_id: i64, recipe_id: i64,
@ -222,6 +234,15 @@ WHERE [Recipe].[user_id] = $1
.map_err(DBError::from) .map_err(DBError::from)
} }
pub async fn rm_recipe(&self, recipe_id: i64) -> Result<()> {
sqlx::query("DELETE FROM [Recipe] WHERE [id] = $1")
.bind(recipe_id)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn get_groups(&self, recipe_id: i64) -> Result<Vec<model::Group>> { pub async fn get_groups(&self, recipe_id: i64) -> Result<Vec<model::Group>> {
let mut tx = self.tx().await?; let mut tx = self.tx().await?;
let mut groups: Vec<model::Group> = sqlx::query_as( let mut groups: Vec<model::Group> = sqlx::query_as(

View file

@ -28,7 +28,7 @@ pub struct Recipe {
#[sqlx(try_from = "u32")] #[sqlx(try_from = "u32")]
pub difficulty: Difficulty, pub difficulty: Difficulty,
pub servings: u32, pub servings: Option<u32>,
pub is_published: bool, pub is_published: bool,
// pub tags: Vec<String>, // pub tags: Vec<String>,
// pub groups: Vec<Group>, // pub groups: Vec<Group>,

View file

@ -91,6 +91,7 @@ async fn main() {
"/recipe/set_description", "/recipe/set_description",
put(services::ron::set_recipe_description), put(services::ron::set_recipe_description),
) )
.route("/recipe/set_servings", put(services::ron::set_servings))
.route( .route(
"/recipe/set_estimated_time", "/recipe/set_estimated_time",
put(services::ron::set_estimated_time), put(services::ron::set_estimated_time),
@ -101,6 +102,7 @@ async fn main() {
"/recipe/set_is_published", "/recipe/set_is_published",
put(services::ron::set_is_published), put(services::ron::set_is_published),
) )
.route("/recipe/remove", delete(services::ron::rm))
.route("/recipe/get_groups", get(services::ron::get_groups)) .route("/recipe/get_groups", get(services::ron::get_groups))
.route("/recipe/add_group", post(services::ron::add_group)) .route("/recipe/add_group", post(services::ron::add_group))
.route("/recipe/remove_group", delete(services::ron::rm_group)) .route("/recipe/remove_group", delete(services::ron::rm_group))

View file

@ -33,25 +33,28 @@ pub async fn edit_recipe(
Path(recipe_id): Path<i64>, Path(recipe_id): Path<i64>,
) -> Result<Response> { ) -> Result<Response> {
if let Some(user) = user { if let Some(user) = user {
let recipe = connection.get_recipe(recipe_id).await?.unwrap(); if let Some(recipe) = connection.get_recipe(recipe_id).await? {
if recipe.user_id == user.id { if recipe.user_id == user.id {
let recipes = Recipes { let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?, published: connection.get_all_published_recipe_titles().await?,
unpublished: connection unpublished: connection
.get_all_unpublished_recipe_titles(user.id) .get_all_unpublished_recipe_titles(user.id)
.await?, .await?,
current_id: Some(recipe_id), current_id: Some(recipe_id),
}; };
Ok(RecipeEditTemplate { Ok(RecipeEditTemplate {
user: Some(user), user: Some(user),
recipes, recipes,
recipe, recipe,
languages: consts::LANGUAGES, languages: consts::LANGUAGES,
}
.into_response())
} else {
Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
} }
.into_response())
} else { } else {
Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response()) Ok(MessageTemplate::new("Recipe not found").into_response())
} }
} else { } else {
Ok(MessageTemplate::new("Not logged in").into_response()) Ok(MessageTemplate::new("Not logged in").into_response())

View file

@ -14,6 +14,8 @@ use crate::{
ron_utils::{ron_error, ron_response}, ron_utils::{ron_error, ron_response},
}; };
const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized";
#[allow(dead_code)] #[allow(dead_code)]
#[debug_handler] #[debug_handler]
pub async fn update_user( pub async fn update_user(
@ -33,7 +35,7 @@ pub async fn update_user(
} else { } else {
return Err(ErrorResponse::from(ron_error( return Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Action not authorized", NOT_AUTHORIZED_MESSAGE,
))); )));
} }
Ok(StatusCode::OK) Ok(StatusCode::OK)
@ -51,7 +53,7 @@ async fn check_user_rights_recipe(
{ {
Err(ErrorResponse::from(ron_error( Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Action not authorized", NOT_AUTHORIZED_MESSAGE,
))) )))
} else { } else {
Ok(()) Ok(())
@ -70,7 +72,7 @@ async fn check_user_rights_recipe_group(
{ {
Err(ErrorResponse::from(ron_error( Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Action not authorized", NOT_AUTHORIZED_MESSAGE,
))) )))
} else { } else {
Ok(()) Ok(())
@ -89,7 +91,7 @@ async fn check_user_rights_recipe_step(
{ {
Err(ErrorResponse::from(ron_error( Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Action not authorized", NOT_AUTHORIZED_MESSAGE,
))) )))
} else { } else {
Ok(()) Ok(())
@ -108,7 +110,7 @@ async fn check_user_rights_recipe_ingredient(
{ {
Err(ErrorResponse::from(ron_error( Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Action not authorized", NOT_AUTHORIZED_MESSAGE,
))) )))
} else { } else {
Ok(()) Ok(())
@ -141,6 +143,19 @@ pub async fn set_recipe_description(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
#[debug_handler]
pub async fn set_servings(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeServings>,
) -> Result<StatusCode> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection
.set_recipe_servings(ron.recipe_id, ron.servings)
.await?;
Ok(StatusCode::OK)
}
#[debug_handler] #[debug_handler]
pub async fn set_estimated_time( pub async fn set_estimated_time(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
@ -193,6 +208,17 @@ pub async fn set_is_published(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
#[debug_handler]
pub async fn rm(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::Remove>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
connection.rm_recipe(ron.recipe_id).await?;
Ok(StatusCode::OK)
}
impl From<model::Group> for common::ron_api::Group { impl From<model::Group> for common::ron_api::Group {
fn from(group: model::Group) -> Self { fn from(group: model::Group) -> Self {
Self { Self {

View file

@ -20,6 +20,19 @@
<textarea <textarea
id="text-area-description">{{ recipe.description }}</textarea> id="text-area-description">{{ recipe.description }}</textarea>
<label for="input-servings">Servings</label>
<input
id="input-servings"
type="number"
step="1" min="1" max="100"
value="
{% match recipe.servings %}
{% when Some with (s) %}
{{ s }}
{% when None %}
{% endmatch %}"/>
<label for="input-estimated-time">Estimated time [min]</label> <label for="input-estimated-time">Estimated time [min]</label>
<input <input
id="input-estimated-time" id="input-estimated-time"
@ -30,7 +43,7 @@
{% when Some with (t) %} {% when Some with (t) %}
{{ t }} {{ t }}
{% when None %} {% when None %}
0
{% endmatch %}"/> {% endmatch %}"/>
<label for="select-difficulty">Difficulty</label> <label for="select-difficulty">Difficulty</label>
@ -61,6 +74,8 @@
> >
<label for="input-is-published">Is published</label> <label for="input-is-published">Is published</label>
<input id="input-delete" type="button" value="Delete recipe" />
<div id="groups-container"> <div id="groups-container">
</div> </div>

View file

@ -15,6 +15,12 @@ pub struct SetRecipeDescription {
pub description: String, pub description: String,
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeServings {
pub recipe_id: i64,
pub servings: Option<u32>,
}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SetRecipeEstimatedTime { pub struct SetRecipeEstimatedTime {
pub recipe_id: i64, pub recipe_id: i64,
@ -65,6 +71,11 @@ pub struct SetIsPublished {
pub is_published: bool, pub is_published: bool,
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct Remove {
pub recipe_id: i64,
}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct AddRecipeGroup { pub struct AddRecipeGroup {
pub recipe_id: i64, pub recipe_id: i64,

View file

@ -54,116 +54,165 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
{ {
let description: HtmlTextAreaElement = by_id("text-area-description"); let description: HtmlTextAreaElement = by_id("text-area-description");
let mut current_description = description.value(); let mut current_description = description.value();
let on_input_description_blur =
EventListener::new(&description.clone(), "blur", move |_event| { EventListener::new(&description.clone(), "blur", move |_event| {
if description.value() != current_description { if description.value() != current_description {
current_description = description.value(); current_description = description.value();
let body = ron_api::SetRecipeDescription { let body = ron_api::SetRecipeDescription {
recipe_id, recipe_id,
description: description.value(), description: description.value(),
}; };
spawn_local(async move { spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_description", body).await; let _ = request::put::<(), _>("recipe/set_description", body).await;
}); });
} }
}); })
on_input_description_blur.forget(); .forget();
}
// Servings.
{
let servings: HtmlInputElement = by_id("input-servings");
let mut current_servings = servings.value_as_number();
EventListener::new(&servings.clone(), "input", move |_event| {
let n = servings.value_as_number();
if n.is_nan() {
servings.set_value("");
}
if n != current_servings {
let servings = if n.is_nan() {
None
} else {
// TODO: Find a better way to validate integer numbers.
let n = n as u32;
servings.set_value_as_number(n as f64);
Some(n)
};
current_servings = n;
let body = ron_api::SetRecipeServings {
recipe_id,
servings,
};
spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_servings", body).await;
});
}
})
.forget();
} }
// Estimated time. // Estimated time.
{ {
let estimated_time: HtmlInputElement = by_id("input-estimated-time"); let estimated_time: HtmlInputElement = by_id("input-estimated-time");
let mut current_time = estimated_time.value_as_number(); let mut current_time = estimated_time.value_as_number();
let on_input_estimated_time_blur =
EventListener::new(&estimated_time.clone(), "blur", move |_event| { EventListener::new(&estimated_time.clone(), "input", move |_event| {
let n = estimated_time.value_as_number(); let n = estimated_time.value_as_number();
if n.is_nan() { if n.is_nan() {
estimated_time.set_value(""); estimated_time.set_value("");
} }
if n != current_time { if n != current_time {
let time = if n.is_nan() { let time = if n.is_nan() {
None None
} else { } else {
// TODO: Find a better way to validate integer numbers. // TODO: Find a better way to validate integer numbers.
let n = n as u32; let n = n as u32;
estimated_time.set_value_as_number(n as f64); estimated_time.set_value_as_number(n as f64);
Some(n) Some(n)
}; };
current_time = n; current_time = n;
let body = ron_api::SetRecipeEstimatedTime { let body = ron_api::SetRecipeEstimatedTime {
recipe_id, recipe_id,
estimated_time: time, estimated_time: time,
}; };
spawn_local(async move { spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_estimated_time", body).await; let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
}); });
} }
}); })
on_input_estimated_time_blur.forget(); .forget();
} }
// Difficulty. // Difficulty.
{ {
let difficulty: HtmlSelectElement = by_id("select-difficulty"); let difficulty: HtmlSelectElement = by_id("select-difficulty");
let mut current_difficulty = difficulty.value(); let mut current_difficulty = difficulty.value();
let on_select_difficulty_blur =
EventListener::new(&difficulty.clone(), "blur", move |_event| {
if difficulty.value() != current_difficulty {
current_difficulty = difficulty.value();
let body = ron_api::SetRecipeDifficulty { EventListener::new(&difficulty.clone(), "blur", move |_event| {
recipe_id, if difficulty.value() != current_difficulty {
difficulty: ron_api::Difficulty::try_from( current_difficulty = difficulty.value();
current_difficulty.parse::<u32>().unwrap(),
) let body = ron_api::SetRecipeDifficulty {
.unwrap(), recipe_id,
}; difficulty: ron_api::Difficulty::try_from(
spawn_local(async move { current_difficulty.parse::<u32>().unwrap(),
let _ = request::put::<(), _>("recipe/set_difficulty", body).await; )
}); .unwrap(),
} };
}); spawn_local(async move {
on_select_difficulty_blur.forget(); let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
});
}
})
.forget();
} }
// Language. // Language.
{ {
let language: HtmlSelectElement = by_id("select-language"); let language: HtmlSelectElement = by_id("select-language");
let mut current_language = language.value(); let mut current_language = language.value();
let on_select_language_blur = EventListener::new(&language.clone(), "blur", move |_event| {
EventListener::new(&language.clone(), "blur", move |_event| { if language.value() != current_language {
if language.value() != current_language { current_language = language.value();
current_language = language.value();
let body = ron_api::SetRecipeLanguage { let body = ron_api::SetRecipeLanguage {
recipe_id, recipe_id,
lang: language.value(), lang: language.value(),
}; };
spawn_local(async move { spawn_local(async move {
let _ = request::put::<(), _>("recipe/set_language", body).await; let _ = request::put::<(), _>("recipe/set_language", body).await;
}); });
} }
}); })
on_select_language_blur.forget(); .forget();
} }
// Is published. // Is published.
{ {
let is_published: HtmlInputElement = by_id("input-is-published"); let is_published: HtmlInputElement = by_id("input-is-published");
let on_input_is_published_blur = EventListener::new(&is_published.clone(), "input", move |_event| {
EventListener::new(&is_published.clone(), "input", move |_event| { let body = ron_api::SetIsPublished {
let body = ron_api::SetIsPublished { recipe_id,
recipe_id, is_published: is_published.checked(),
is_published: is_published.checked(), };
}; spawn_local(async move {
spawn_local(async move { let _ = request::put::<(), _>("recipe/set_is_published", body).await;
let _ = request::put::<(), _>("recipe/set_is_published", body).await; reload_recipes_list(recipe_id).await;
reload_recipes_list(recipe_id).await;
});
}); });
on_input_is_published_blur.forget(); })
.forget();
} }
// Delete recipe button.
let delete_button: HtmlInputElement = by_id("input-delete");
EventListener::new(&delete_button, "click", move |_event| {
let title: HtmlInputElement = by_id("input-title");
spawn_local(async move {
if modal_dialog::show(&format!(
"Are you sure to delete the recipe '{}'",
title.value()
))
.await
{
let body = ron_api::Remove { recipe_id };
let _ = request::delete::<(), _>("recipe/remove", body).await;
// by_id::<Element>(&format!("group-{}", group_id)).remove();
}
});
})
.forget();
fn create_group_element(group: &ron_api::Group) -> Element { fn create_group_element(group: &ron_api::Group) -> Element {
let group_id = group.id; let group_id = group.id;
let group_element: Element = select_and_clone("#hidden-templates .group"); let group_element: Element = select_and_clone("#hidden-templates .group");
@ -374,7 +423,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
.map_or("".to_string(), |q| q.to_string()), .map_or("".to_string(), |q| q.to_string()),
); );
let mut current_quantity = quantity.value_as_number(); let mut current_quantity = quantity.value_as_number();
EventListener::new(&quantity.clone(), "blur", move |_event| { EventListener::new(&quantity.clone(), "input", move |_event| {
let n = quantity.value_as_number(); let n = quantity.value_as_number();
if n.is_nan() { if n.is_nan() {
quantity.set_value(""); quantity.set_value("");
@ -479,60 +528,3 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
Ok(()) Ok(())
} }
// pub fn user_edit(doc: Document) -> Result<(), JsValue> {
// log!("user_edit");
// let button = doc
// .query_selector("#user-edit input[type='button']")?
// .unwrap();
// let on_click_submit = EventListener::new(&button, "click", move |_event| {
// log!("Click!");
// let input_name = doc.get_element_by_id("input-name").unwrap();
// let name = input_name.dyn_ref::<HtmlInputElement>().unwrap().value();
// let update_data = common::ron_api::UpdateProfile {
// name: Some(name),
// email: None,
// password: None,
// };
// let body = common::ron_api::to_string(update_data);
// let doc = doc.clone();
// spawn_local(async move {
// match Request::put("/ron-api/user/update")
// .header("Content-Type", "application/ron")
// .body(body)
// .unwrap()
// .send()
// .await
// {
// Ok(resp) => {
// log!("Status code: {}", resp.status());
// if resp.status() == 200 {
// toast::show(Level::Info, "Profile saved");
// } else {
// toast::show(
// Level::Error,
// &format!(
// "Status code: {} {}",
// resp.status(),
// resp.text().await.unwrap()
// ),
// );
// }
// }
// Err(error) => {
// toast::show(Level::Info, &format!("Internal server error: {}", error));
// }
// }
// });
// });
// on_click_submit.forget();
// Ok(())
// }