Ingredients can now be manually ordered

This commit is contained in:
Greg Burri 2025-01-16 00:17:08 +01:00
parent afd42ba1d0
commit ca2227037f
11 changed files with 205 additions and 32 deletions

View file

@ -138,10 +138,14 @@ body {
.step {
border: 0.1em solid lighten($color-3, 30%);
margin-top: 0px;
margin-bottom: 0px;
}
.ingredient {
border: 0.1em solid lighten($color-3, 30%);
margin-top: 0px;
margin-bottom: 0px;
}
.dropzone {
@ -160,7 +164,6 @@ body {
}
}
#hidden-templates {
display: none;
}

View file

@ -23,8 +23,8 @@ VALUES (
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (1, 1, 'Croissant au jambon', true, '2025-01-07T10:41:05.697884837+00:00');
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00');
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime], [servings], [estimated_time], [difficulty])
VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00', 4, 40, 1);
INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
VALUES (3, 1, 'Saumon en croute', true, '2025-01-07T10:41:05.697884837+00:00');
@ -58,20 +58,20 @@ VALUES (2, 2, "Sel à l'origan", "", 1, "c-à-c");
INSERT INTO [Step] ([id], [order], [group_id], [action])
VALUES (3, 3, 2, "Mélanger au fouet et verser sur le thon dans le plat");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (3, 3, "Concentré de tomate", "", 4, "c-à-s");
INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (3, 0, 3, "Concentré de tomate", "", 4, "c-à-s");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (4, 3, "Poivre", "", 0.25, "c-à-c");
INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (4, 1, 3, "Poivre", "", 0.25, "c-à-c");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (5, 3, "Herbe de Provence", "", 0.5, "c-à-c");
INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (5, 2, 3, "Herbe de Provence", "", 0.5, "c-à-c");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (6, 3, "Crème à café ou demi-crème", "", 2, "dl");
INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (6, 3, 3, "Crème à café ou demi-crème", "", 2, "dl");
INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (7, 3, "Olives farcies coupées en deuxs", "", 50, "g");
INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
VALUES (7, 4, 3, "Olives farcies coupées en deuxs", "", 50, "g");
INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])

View file

@ -56,6 +56,7 @@ CREATE TABLE [Recipe] (
[lang] TEXT NOT NULL DEFAULT 'en',
[estimated_time] INTEGER, -- in [s].
[description] TEXT NOT NULL DEFAULT '',
-- 0: Unknown, 1: Easy, 2: Medium, 4: Hard.
[difficulty] INTEGER NOT NULL DEFAULT 0,
[servings] INTEGER DEFAULT 4,
[is_published] INTEGER NOT NULL DEFAULT FALSE,
@ -64,6 +65,28 @@ CREATE TABLE [Recipe] (
FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE SET NULL
) STRICT;
CREATE TRIGGER [Recipe_trigger_update_difficulty]
BEFORE UPDATE OF [difficulty]
ON [Recipe]
BEGIN
SELECT
CASE
WHEN NEW.[difficulty] < 0 OR NEW.[difficulty] > 3 THEN
RAISE (ABORT, 'Invalid [difficulty] value')
END;
END;
CREATE TRIGGER [Recipe_trigger_insert_difficulty]
BEFORE INSERT
ON [Recipe]
BEGIN
SELECT
CASE
WHEN NEW.[difficulty] < 0 OR NEW.[difficulty] > 3 THEN
RAISE (ABORT, 'Invalid [difficulty] value')
END;
END;
CREATE TABLE [Image] (
[Id] INTEGER PRIMARY KEY,
[recipe_id] INTEGER NOT NULL,
@ -124,6 +147,7 @@ CREATE INDEX [Step_order_index] ON [Group]([order]);
CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY,
[order] INTEGER NOT NULL DEFAULT 0,
[step_id] INTEGER NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
@ -134,14 +158,4 @@ CREATE TABLE [Ingredient] (
FOREIGN KEY([step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
) STRICT;
-- CREATE TABLE [IntermediateSubstance] (
-- [id] INTEGER PRIMARY KEY,
-- [name] TEXT NOT NULL DEFAULT '',
-- [quantity_value] REAL,
-- [quantity_unit] TEXT NOT NULL DEFAULT '',
-- [output_group_id] INTEGER NOT NULL,
-- [input_group_id] INTEGER NOT NULL,
-- FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
-- FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
-- ) STRICT;
CREATE INDEX [Ingredient_order_index] ON [Ingredient]([order]);

View file

@ -172,6 +172,33 @@ WHERE [Ingredient].[id] = $1 AND [user_id] = $2
.map_err(DBError::from)
}
pub async fn can_edit_recipe_all_ingredients(
&self,
user_id: i64,
ingredients_ids: &[i64],
) -> Result<bool> {
let params = (0..ingredients_ids.len())
.map(|n| format!("${}", n + 2))
.join(", ");
let query_str = format!(
r#"
SELECT COUNT(*)
FROM [Recipe]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
INNER JOIN [Ingredient] ON [Ingredient].[step_id] = [Step].[id]
WHERE [Ingredient].[id] IN ({}) AND [user_id] = $1
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in ingredients_ids {
query = query.bind(id);
}
Ok(query.fetch_one(&self.pool).await? == ingredients_ids.len() as u64)
}
pub async fn get_recipe(&self, id: i64, complete: bool) -> Result<Option<model::Recipe>> {
match sqlx::query_as::<_, model::Recipe>(
r#"
@ -485,7 +512,7 @@ ORDER BY [order]
SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
FROM [Ingredient]
WHERE [step_id] = $1
ORDER BY [name]
ORDER BY [order]
"#,
)
.bind(step.id)
@ -622,10 +649,27 @@ ORDER BY [name]
}
pub async fn add_recipe_ingredient(&self, step_id: i64) -> Result<i64> {
let db_result = sqlx::query("INSERT INTO [Ingredient] ([step_id]) VALUES ($1)")
.bind(step_id)
.execute(&self.pool)
.await?;
let mut tx = self.tx().await?;
let last_order = sqlx::query_scalar(
"SELECT [order] FROM [Ingredient] WHERE [step_id] = $1 ORDER BY [order] DESC LIMIT 1",
)
.bind(step_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(-1);
let db_result = sqlx::query(
r#"
INSERT INTO [Ingredient] ([step_id], [order])
VALUES ($1, $2)
"#,
)
.bind(step_id)
.bind(last_order as i64)
.execute(&mut *tx)
.await?;
Ok(db_result.last_insert_rowid())
}
@ -681,6 +725,22 @@ ORDER BY [name]
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_ingredients_order(&self, ingredient_ids: &[i64]) -> Result<()> {
let mut tx = self.tx().await?;
for (order, id) in ingredient_ids.iter().enumerate() {
sqlx::query("UPDATE [Ingredient] SET [order] = $2 WHERE [id] = $1")
.bind(id)
.bind(order as i64)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
}
#[cfg(test)]

View file

@ -173,6 +173,10 @@ async fn main() {
"/recipe/set_ingredient_unit",
put(services::ron::set_ingredient_unit),
)
.route(
"/recipe/set_ingredients_order",
put(services::ron::set_ingredients_order),
)
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(

View file

@ -180,6 +180,25 @@ async fn check_user_rights_recipe_ingredient(
}
}
async fn check_user_rights_recipe_ingredients(
connection: &db::Connection,
user: &Option<model::User>,
step_ids: &[i64],
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, step_ids)
.await?
{
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
NOT_AUTHORIZED_MESSAGE,
)))
} else {
Ok(())
}
}
#[debug_handler]
pub async fn set_recipe_title(
State(connection): State<db::Connection>,
@ -579,6 +598,19 @@ pub async fn set_ingredient_unit(
Ok(StatusCode::OK)
}
#[debug_handler]
pub async fn set_ingredients_order(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientOrders>,
) -> Result<impl IntoResponse> {
check_user_rights_recipe_ingredients(&connection, &user, &ron.ingredient_ids).await?;
connection
.set_ingredients_order(&ron.ingredient_ids)
.await?;
Ok(StatusCode::OK)
}
///// 404 /////
#[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {

View file

@ -116,6 +116,8 @@
</div>
<div class="ingredient">
<span class="drag-handle"></span>
<label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
<input class="input-ingredient-name" type="text" />

View file

@ -34,11 +34,48 @@
{% else %}
{% endmatch %}
<span class="difficulty">
{% match recipe.difficulty %}
{% when common::ron_api::Difficulty::Unknown %}
{% when common::ron_api::Difficulty::Easy %}
{{ tr.t(Sentence::RecipeDifficultyEasy) }}
{% when common::ron_api::Difficulty::Medium %}
{{ tr.t(Sentence::RecipeDifficultyMedium) }}
{% when common::ron_api::Difficulty::Hard %}
{{ tr.t(Sentence::RecipeDifficultyHard) }}
{% endmatch %}
</span>
{% if !recipe.description.is_empty() %}
<div class="recipe-description" >
{{ recipe.description.clone() }}
{{ recipe.description }}
</div>
{% endif %}
{% for group in recipe.groups %}
<div class="group">
<h3>{{ group.name }}</h3>
<div class="steps">
{% for step in group.steps %}
<div class="ingredients">
{% for ingredient in step.ingredients %}
<div class="ingredient">
{% if let Some(quantity) = ingredient.quantity_value %}
{{ quantity +}}
{{+ ingredient.quantity_unit }}
{% endif +%}
{{+ ingredient.name }}
</div>
{% endfor %}
</div>
<div class="step">
{{ step.action }}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}