[[package]]
name = "bitflags"
-version = "2.7.0"
+version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
+checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
dependencies = [
"serde",
]
.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 {
}
}
-
#hidden-templates {
display: none;
}
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');
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])
[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,
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,
CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY,
+ [order] INTEGER NOT NULL DEFAULT 0,
[step_id] INTEGER NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
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]);
.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#"
SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
FROM [Ingredient]
WHERE [step_id] = $1
-ORDER BY [name]
+ORDER BY [order]
"#,
)
.bind(step.id)
}
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())
}
.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)]
"/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(
}
}
+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>,
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 {
</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" />
{% 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 %}
\ No newline at end of file
pub unit: String,
}
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientOrders {
+ pub ingredient_ids: Vec<i64>,
+}
+
#[derive(Serialize, Deserialize, Clone)]
pub struct Tags {
pub recipe_id: i64,
ingredient_element.set_id(&format!("ingredient-{}", ingredient.id));
step_element.append_child(&ingredient_element).unwrap();
+ set_draggable(&ingredient_element, "ingredient", |element| {
+ let element = element.clone();
+ spawn_local(async move {
+ let ingredient_ids = element
+ .parent_element()
+ .unwrap()
+ .selector_all::<Element>(".ingredient")
+ .into_iter()
+ .map(|e| e.id()[11..].parse::<i64>().unwrap())
+ .collect();
+
+ let body = ron_api::SetIngredientOrders { ingredient_ids };
+ let _ = request::put::<(), _>("recipe/set_ingredients_order", body).await;
+ });
+ });
+
// Ingredient name.
let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
name.set_value(&ingredient.name);