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').
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")];

View file

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

View file

@ -37,7 +37,9 @@ ORDER BY [title]
}
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(
r#"SELECT COUNT(*) = 1 FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#,
)
.bind(recipe_id)
.bind(user_id)
.fetch_one(&self.pool)
@ -48,7 +50,7 @@ ORDER BY [title]
pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result<bool> {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
SELECT COUNT(*) = 1
FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
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> {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
SELECT COUNT(*) = 1
FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
@ -171,6 +173,16 @@ WHERE [Recipe].[user_id] = $1
.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(
&self,
recipe_id: i64,
@ -222,6 +234,15 @@ WHERE [Recipe].[user_id] = $1
.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>> {
let mut tx = self.tx().await?;
let mut groups: Vec<model::Group> = sqlx::query_as(

View file

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

View file

@ -91,6 +91,7 @@ async fn main() {
"/recipe/set_description",
put(services::ron::set_recipe_description),
)
.route("/recipe/set_servings", put(services::ron::set_servings))
.route(
"/recipe/set_estimated_time",
put(services::ron::set_estimated_time),
@ -101,6 +102,7 @@ async fn main() {
"/recipe/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/add_group", post(services::ron::add_group))
.route("/recipe/remove_group", delete(services::ron::rm_group))

View file

@ -33,7 +33,7 @@ pub async fn edit_recipe(
Path(recipe_id): Path<i64>,
) -> Result<Response> {
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 {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
@ -53,6 +53,9 @@ pub async fn edit_recipe(
} else {
Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
}
} else {
Ok(MessageTemplate::new("Recipe not found").into_response())
}
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}

View file

@ -14,6 +14,8 @@ use crate::{
ron_utils::{ron_error, ron_response},
};
const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized";
#[allow(dead_code)]
#[debug_handler]
pub async fn update_user(
@ -33,7 +35,7 @@ pub async fn update_user(
} else {
return Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
NOT_AUTHORIZED_MESSAGE,
)));
}
Ok(StatusCode::OK)
@ -51,7 +53,7 @@ async fn check_user_rights_recipe(
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
@ -70,7 +72,7 @@ async fn check_user_rights_recipe_group(
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
@ -89,7 +91,7 @@ async fn check_user_rights_recipe_step(
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
@ -108,7 +110,7 @@ async fn check_user_rights_recipe_ingredient(
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
"Action not authorized",
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
@ -141,6 +143,19 @@ pub async fn set_recipe_description(
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]
pub async fn set_estimated_time(
State(connection): State<db::Connection>,
@ -193,6 +208,17 @@ pub async fn set_is_published(
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 {
fn from(group: model::Group) -> Self {
Self {

View file

@ -20,6 +20,19 @@
<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>
<input
id="input-estimated-time"
@ -30,7 +43,7 @@
{% when Some with (t) %}
{{ t }}
{% when None %}
0
{% endmatch %}"/>
<label for="select-difficulty">Difficulty</label>
@ -61,6 +74,8 @@
>
<label for="input-is-published">Is published</label>
<input id="input-delete" type="button" value="Delete recipe" />
<div id="groups-container">
</div>

View file

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

View file

@ -54,7 +54,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
{
let description: HtmlTextAreaElement = by_id("text-area-description");
let mut current_description = description.value();
let on_input_description_blur =
EventListener::new(&description.clone(), "blur", move |_event| {
if description.value() != current_description {
current_description = description.value();
@ -66,16 +66,47 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let _ = request::put::<(), _>("recipe/set_description", body).await;
});
}
})
.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;
});
on_input_description_blur.forget();
}
})
.forget();
}
// Estimated time.
{
let estimated_time: HtmlInputElement = by_id("input-estimated-time");
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();
if n.is_nan() {
estimated_time.set_value("");
@ -98,15 +129,15 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let _ = request::put::<(), _>("recipe/set_estimated_time", body).await;
});
}
});
on_input_estimated_time_blur.forget();
})
.forget();
}
// Difficulty.
{
let difficulty: HtmlSelectElement = by_id("select-difficulty");
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();
@ -122,15 +153,14 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let _ = request::put::<(), _>("recipe/set_difficulty", body).await;
});
}
});
on_select_difficulty_blur.forget();
})
.forget();
}
// Language.
{
let language: HtmlSelectElement = by_id("select-language");
let mut current_language = language.value();
let on_select_language_blur =
EventListener::new(&language.clone(), "blur", move |_event| {
if language.value() != current_language {
current_language = language.value();
@ -143,14 +173,13 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let _ = request::put::<(), _>("recipe/set_language", body).await;
});
}
});
on_select_language_blur.forget();
})
.forget();
}
// 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| {
let body = ron_api::SetIsPublished {
recipe_id,
@ -160,10 +189,30 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
let _ = request::put::<(), _>("recipe/set_is_published", body).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 {
let group_id = group.id;
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()),
);
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();
if n.is_nan() {
quantity.set_value("");
@ -479,60 +528,3 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> {
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(())
// }