Add all relations & update recipe

This commit is contained in:
Adrian Woźniak 2024-11-15 16:16:28 +01:00
parent af558d47a6
commit 1031e461e8
13 changed files with 787 additions and 239 deletions

View File

@ -10,6 +10,7 @@ use actix_web::HttpMessage;
use actix_web::{get, post, HttpRequest, HttpResponse, Responder};
use askama::Template;
use askama_actix::TemplateToResponse;
use itertools::Itertools;
use sea_orm::{
prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait,
};
@ -22,14 +23,249 @@ use std::collections::{BTreeMap, BTreeSet};
#[template(path = "recipe_card.jinja")]
struct RecipeCard(entities::recipies::Model);
#[derive(Debug, Template, derive_more::Deref)]
#[template(path = "ingredient_card.jinja")]
struct IngredientCard(crate::types::ingredient_with_recipe_count::Model);
#[derive(Debug, Template, derive_more::Deref)]
#[template(path = "tag_card.jinja")]
struct TagCard(crate::types::tag_with_recipe_count::Model);
#[derive(Debug, Template)]
#[template(path = "index.jinja", ext = "html")]
struct IndexTemplate {
#[template(path = "ingredient.jinja", ext = "html")]
pub struct IngredientTemplate {
recipies: Vec<RecipeCard>,
count: u64,
session: Option<User>,
page: Page,
}
impl IngredientTemplate {
pub async fn load(
db: &DatabaseConnection,
ingredient_id: u32,
page: Option<u16>,
session: Option<String>,
) -> Self {
let count = (entities::prelude::Recipies::find()
.join(
JoinType::InnerJoin,
entities::recipies::Relation::RecipeIngredients.def(),
)
.filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id))
.count(&*db)
.await
.unwrap_or_default() as f64
/ 20.0)
.ceil() as u64;
let recipies = entities::prelude::Recipies::find()
.join(
JoinType::InnerJoin,
entities::recipies::Relation::RecipeIngredients.def(),
)
.filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id))
.order_by_desc(entities::recipies::Column::Title)
.limit(20)
.offset(page.unwrap_or_default() as u64)
.all(&*db)
.await
.unwrap_or_default();
Self {
recipies: recipies.into_iter().map(RecipeCard).collect(),
count,
session,
page: Page::Index,
}
}
}
#[derive(Debug, Template)]
#[template(path = "ingredients.jinja", ext = "html")]
pub struct IngredientsTemplate {
session: Option<User>,
page: Page,
ingredients: Vec<IngredientCard>,
}
impl IngredientsTemplate {
pub async fn load(db: &DatabaseConnection, session: Option<String>) -> Self {
let rel = || {
Ingredients::find()
.join(
JoinType::Join,
entities::ingredients::Relation::RecipeIngredients.def(),
)
.join(
JoinType::Join,
entities::recipe_ingredients::Relation::Recipies.def(),
)
};
let ingredients = rel()
.select_only()
.column(entities::ingredients::Column::Id)
.column(entities::ingredients::Column::Name)
.column_as(
entities::recipe_ingredients::Column::RecipeId.count(),
"recipe_count",
)
.order_by_asc(entities::ingredients::Column::Name)
.group_by(entities::ingredients::Column::Id)
.into_model::<crate::types::ingredient_with_recipe_count::Model>()
.all(&*db)
.await
.inspect_err(|e| tracing::error!("Failed to load ingredients: {e}"))
.unwrap_or_default()
.into_iter()
.map(IngredientCard)
.collect();
Self {
ingredients,
session,
page: Page::Index,
}
}
}
#[derive(Debug, Template)]
#[template(path = "tags.jinja", ext = "html")]
pub struct TagsTemplate {
session: Option<User>,
page: Page,
tags: Vec<TagCard>,
}
impl TagsTemplate {
pub async fn load(db: &DatabaseConnection, session: Option<String>) -> Self {
let rel = || {
Tags::find()
.join(JoinType::Join, entities::tags::Relation::RecipeTags.def())
.join(
JoinType::Join,
entities::recipe_tags::Relation::Recipies.def(),
)
};
let tags = rel()
.select_only()
.column(entities::tags::Column::Id)
.column(entities::tags::Column::Name)
.column_as(
entities::recipe_tags::Column::RecipeId.count(),
"recipe_count",
)
.order_by_asc(entities::tags::Column::Name)
.group_by(entities::tags::Column::Id)
.into_model::<crate::types::tag_with_recipe_count::Model>()
.all(&*db)
.await
.inspect_err(|e| tracing::error!("Failed to load ingredients: {e}"))
.unwrap_or_default()
.into_iter()
.map(TagCard)
.collect();
Self {
tags,
session,
page: Page::Index,
}
}
}
//
//
//
//
#[derive(Debug, Template)]
#[template(path = "tag.jinja", ext = "html")]
pub struct TagTemplate {
recipies: Vec<RecipeCard>,
count: u64,
session: Option<User>,
page: Page,
}
impl TagTemplate {
pub async fn load(
db: &DatabaseConnection,
ingredient_id: u32,
page: Option<u16>,
session: Option<String>,
) -> Self {
let count = (entities::prelude::Recipies::find()
.join(
JoinType::InnerJoin,
entities::recipies::Relation::RecipeTags.def(),
)
.filter(entities::recipe_tags::Column::TagId.eq(ingredient_id))
.count(&*db)
.await
.unwrap_or_default() as f64
/ 20.0)
.ceil() as u64;
let recipies = entities::prelude::Recipies::find()
.join(
JoinType::InnerJoin,
entities::recipies::Relation::RecipeTags.def(),
)
.filter(entities::recipe_tags::Column::TagId.eq(ingredient_id))
.order_by_desc(entities::recipies::Column::Title)
.limit(20)
.offset(page.unwrap_or_default() as u64)
.all(&*db)
.await
.unwrap_or_default();
Self {
recipies: recipies.into_iter().map(RecipeCard).collect(),
count,
session,
page: Page::Index,
}
}
}
//
//
//
//
#[derive(Debug, Template)]
#[template(path = "index.jinja", ext = "html")]
pub struct IndexTemplate {
recipies: Vec<RecipeCard>,
count: u64,
session: Option<User>,
page: Page,
error: Option<String>,
}
impl IndexTemplate {
pub async fn load(db: &DatabaseConnection, page: Option<u16>, session: Option<String>) -> Self {
let count = (entities::prelude::Recipies::find()
.count(&*db)
.await
.unwrap_or_default() as f64
/ 20.0)
.ceil() as u64;
let recipies = entities::prelude::Recipies::find()
.order_by_desc(entities::recipies::Column::Title)
.limit(20)
.offset(page.unwrap_or_default() as u64)
.all(&*db)
.await
.unwrap_or_default();
Self {
recipies: recipies.into_iter().map(RecipeCard).collect(),
count,
session,
page: Page::Index,
error: None,
}
}
pub fn with_error(mut self, error: String) -> Self {
self.error = Some(error);
self
}
}
#[derive(Debug, Template)]
#[template(path = "search.jinja", ext = "html")]
@ -72,10 +308,10 @@ struct ImageUpload {
struct RecipeForm {
title: String,
summary: String,
ingeredients: String,
ingredients: String,
steps: String,
tags: String,
selected_tags: Vec<String>,
selected_tags: Option<Vec<String>>,
image_url: String,
time: Option<String>,
author: Option<String>,
@ -99,8 +335,7 @@ struct EditRecipeForm {
ingredients: String,
steps: String,
tags: String,
selected_tags: Vec<String>,
image_url: String,
selected_tags: Option<Vec<String>>,
time: Option<String>,
author: Option<String>,
error: Option<String>,
@ -161,10 +396,6 @@ async fn sign_in(
.any(|admin| admin.email == payload.email && admin.pass == payload.password);
if is_admin {
tracing::info!("Valid credentials");
// let res = session
// .insert(SESSION_KEY, payload.email.clone())
// .inspect_err(|e| tracing::error!("Failed to save session: {e}"));
// tracing::debug!("Saving session res: {res:?}");
let _s =
Identity::login(&req.extensions(), payload.email).expect("Failed to store session");
@ -256,33 +487,55 @@ async fn index_html(
q: actix_web::web::Query<Padding>,
admin: Option<Identity>,
) -> HttpResponse {
let count = (entities::prelude::Recipies::find()
.count(&**db)
IndexTemplate::load(&**db, q.page, admin.and_then(|session| session.id().ok()))
.await
.unwrap_or_default() as f64
/ 20.0)
.ceil() as u64;
let recipies = entities::prelude::Recipies::find()
.order_by_desc(entities::recipies::Column::Title)
.limit(20)
.offset(q.page.unwrap_or_default() as u64)
.all(&**db)
.to_response()
}
#[get("/ingredients")]
async fn ingredients_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
IngredientsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
.await
.unwrap_or_default();
.to_response()
}
#[get("/tags")]
async fn tags_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
TagsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
.await
.to_response()
}
#[get("/tags/{ingredient}")]
async fn by_tag(
db: Data<DatabaseConnection>,
q: actix_web::web::Query<Padding>,
admin: Option<Identity>,
path: Path<u32>,
) -> HttpResponse {
TagTemplate::load(
&**db,
path.into_inner(),
q.page,
admin.and_then(|session| session.id().ok()),
)
.await
.to_response()
}
let html = IndexTemplate {
recipies: recipies.into_iter().map(RecipeCard).collect(),
count,
session: admin.and_then(|s| s.id().ok()),
page: Page::Index,
}
.render()
.unwrap();
HttpResponse::Ok()
.append_header((CONTENT_TYPE, "text/html; charset=utf-8"))
.append_header((CONTENT_LENGTH, html.len()))
.body(html)
#[get("/ingredients/{ingredient}")]
async fn by_ingredient(
db: Data<DatabaseConnection>,
q: actix_web::web::Query<Padding>,
admin: Option<Identity>,
path: Path<u32>,
) -> HttpResponse {
IngredientTemplate::load(
&**db,
path.into_inner(),
q.page,
admin.and_then(|session| session.id().ok()),
)
.await
.to_response()
}
#[derive(Debug, Template)]
@ -330,14 +583,11 @@ async fn show(
.await
.unwrap_or_default();
let ingredients = entities::prelude::Ingredients::find()
.filter(
entities::ingredients::Column::Id.is_in(
recipe_ingredients
.iter()
.map(|i| i.ingredient_id)
.collect::<Vec<_>>(),
),
.join(
JoinType::InnerJoin,
entities::ingredients::Relation::RecipeIngredients.def(),
)
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe.id))
.all(db)
.await
.unwrap_or_default()
@ -348,6 +598,7 @@ async fn show(
.into_iter()
.filter_map(|ri| {
Some(Ingredient {
id: ri.ingredient_id,
name: ingredients.get(&ri.ingredient_id)?.to_owned(),
qty: ri.qty,
unit: ri.unit,
@ -378,7 +629,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
RecipeForm {
title: "".into(),
summary: "".into(),
ingeredients: "".into(),
ingredients: "".into(),
steps: "".into(),
tags: "".into(),
selected_tags: Default::default(),
@ -413,6 +664,7 @@ async fn create_recipe(
.map_err(|_| Error::DatabaseError)?;
match save_recipe(form, &mut t).await {
Err(e) => {
tracing::warn!("Failed to save changes: {e}");
failure.error = Some(e.to_string());
let _ = t.rollback().await;
failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default();
@ -470,171 +722,229 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
.last_insert_id;
// Tags
{
tracing::debug!("Selected tags: {:?}", form.selected_tags);
let selected_tags = form.selected_tags;
let selected_tags = selected_tags
.into_iter()
.filter_map(|s| s.parse().ok())
.collect::<Vec<i32>>();
let text_tags = form
.tags
.split(",")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from)
.collect::<Vec<_>>();
tracing::debug!("Received tags: {text_tags:?}");
let known = Tags::find()
.filter(entities::tags::Column::Name.is_in(&text_tags))
.all(&mut *t)
.await
.unwrap_or_default();
tracing::debug!("Known tags: {known:?}");
let missing = {
let known = known
.iter()
.map(|model| &model.name)
.collect::<BTreeSet<_>>();
tracing::debug!("Known tag names: {known:?}");
let mut missing = Vec::with_capacity(text_tags.len());
for tag_name in text_tags
.into_iter()
.filter(|tag_name| !known.contains(&tag_name))
{
tracing::debug!("Creating missing tag: {tag_name:?}");
missing.push(
Tags::insert(entities::tags::ActiveModel {
name: Set(tag_name),
..Default::default()
})
.exec_with_returning(&mut *t)
.await
.inspect_err(|e| tracing::warn!("Failed to find tag: {e}"))
.map_err(|_| Error::SaveTag)?,
);
}
tracing::debug!("Missing tags: {missing:?}");
missing
};
#[cfg(debug_assertions)]
tracing::debug!(
"Tags to attach: {:?}",
known
.clone()
.into_iter()
.map(|tag| tag.id)
.chain(missing.clone().into_iter().map(|tag| tag.id))
.chain(selected_tags.clone().into_iter())
.collect::<BTreeSet<_>>()
);
for tag_id in known
.into_iter()
.map(|tag| tag.id)
.chain(missing.into_iter().map(|tag| tag.id))
.chain(selected_tags.into_iter())
.collect::<BTreeSet<_>>()
{
RecipeTags::insert(entities::recipe_tags::ActiveModel {
recipe_id: Set(recipe_id),
tag_id: Set(tag_id),
..Default::default()
})
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
.map_err(|_| Error::SaveRecipeTag)?;
}
}
create_tags(recipe_id, form.selected_tags, form.tags, &mut *t).await?;
// Steps
{
let steps = crate::utils::parse_steps(&form.steps)?
.into_iter()
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
body: Set(body),
hint: Set(hint),
recipe_id: Set(recipe_id),
create_steps(recipe_id, form.steps, &mut *t).await?;
// Ingredients
create_ingredients(recipe_id, form.ingredients, &mut *t).await?;
Ok(recipe_id)
}
async fn create_ingredients(
recipe_id: i32,
ingredients: String,
t: &mut DatabaseTransaction,
) -> Result<(), Error> {
use sea_orm::ActiveValue::*;
let ingredients = crate::utils::parse_ingredients(&ingredients)?;
let known = Ingredients::find()
.filter(
entities::ingredients::Column::Name.is_in(
ingredients
.iter()
.map(|(name, ..)| name.to_string())
.collect::<Vec<_>>(),
),
)
.all(&mut *t)
.await
.inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
let missing = {
let known = known
.iter()
.map(|model| &model.name)
.collect::<BTreeSet<_>>();
let missing = ingredients
.iter()
.filter(|(name, ..)| !known.contains(name))
.map(|(name, ..)| entities::ingredients::ActiveModel {
name: Set(name.to_owned()),
..Default::default()
})
.collect::<Vec<_>>();
let _steps = RecipeSteps::insert_many(steps)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save steps: {e}"))
.map_err(|_| Error::SaveRecipeStep)?;
}
// Ingredients
{
let ingredients = crate::utils::parse_ingredients(&form.ingeredients)?;
let known = Ingredients::find()
.filter(
entities::ingredients::Column::Name.is_in(
ingredients
.iter()
.map(|(name, ..)| name.to_string())
.collect::<Vec<_>>(),
),
let mut v = Vec::with_capacity(missing.len());
for missing in missing {
v.push(
Ingredients::insert(missing)
.exec_with_returning(&mut *t)
.await
.inspect_err(|e| tracing::error!("Failed to create ingredients: {e}"))
.map_err(|_| Error::DatabaseError)?,
)
.all(&mut *t)
.await
.inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
let missing = {
let known = known
.iter()
.map(|model| &model.name)
.collect::<BTreeSet<_>>();
let missing = ingredients
.iter()
.filter(|(name, ..)| !known.contains(name))
.map(|(name, ..)| entities::ingredients::ActiveModel {
name: Set(name.to_owned()),
}
v
};
let map = known
.into_iter()
.chain(missing.into_iter())
.map(|row| (row.name, row.id))
.collect::<BTreeMap<_, _>>();
RecipeIngredients::insert_many(
ingredients
.into_iter()
.filter_map(|(name, unit, qty)| {
Some(entities::recipe_ingredients::ActiveModel {
ingredient_id: Set(*map.get(&name)?),
qty: Set(qty),
unit: Set(unit.unwrap_or_default()),
recipe_id: Set(recipe_id),
..Default::default()
})
.collect::<Vec<_>>();
let mut v = Vec::with_capacity(missing.len());
for missing in missing {
v.push(
Ingredients::insert(missing)
.exec_with_returning(&mut *t)
.await
.inspect_err(|e| tracing::error!("Failed to create ingredients: {e}"))
.map_err(|_| Error::DatabaseError)?,
)
}
v
};
let map = known
.into_iter()
.chain(missing.into_iter())
.map(|row| (row.name, row.id))
.collect::<BTreeMap<_, _>>();
})
.collect::<Vec<_>>(),
)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)
.map(|_| ())
}
RecipeIngredients::insert_many(
ingredients
.into_iter()
.filter_map(|(name, unit, qty)| {
Some(entities::recipe_ingredients::ActiveModel {
ingredient_id: Set(*map.get(&name)?),
qty: Set(qty),
unit: Set(unit.unwrap_or_default()),
recipe_id: Set(recipe_id),
..Default::default()
})
})
.collect::<Vec<_>>(),
)
async fn create_steps(
recipe_id: i32,
steps: String,
t: &mut DatabaseTransaction,
) -> Result<(), Error> {
use sea_orm::ActiveValue::*;
let steps = crate::utils::parse_steps(&steps)?
.into_iter()
.map(|(body, hint)| entities::recipe_steps::ActiveModel {
body: Set(body),
hint: Set(hint),
recipe_id: Set(recipe_id),
..Default::default()
})
.collect::<Vec<_>>();
let _steps = RecipeSteps::insert_many(steps)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
};
.inspect_err(|e| tracing::error!("Save steps: {e}"))
.map_err(|_| Error::SaveRecipeStep)?;
Ok(recipe_id)
Ok(())
}
async fn delete_steps(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
RecipeSteps::delete_many()
.filter(entities::recipe_steps::Column::RecipeId.eq(recipe_id))
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Delete steps: {e}"))
.map_err(|_| Error::SaveRecipeStep)?;
Ok(())
}
async fn delete_tags(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
RecipeTags::delete_many()
.filter(entities::recipe_tags::Column::RecipeId.eq(recipe_id))
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Delete tags: {e}"))
.map_err(|_| Error::SaveRecipeTag)?;
Ok(())
}
async fn delete_ingredients(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> {
RecipeIngredients::delete_many()
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe_id))
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Delete ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
Ok(())
}
async fn create_tags(
recipe_id: i32,
selected_tags: Option<Vec<String>>,
tags: String,
t: &mut DatabaseTransaction,
) -> Result<(), Error> {
use sea_orm::ActiveValue::*;
tracing::debug!("Selected tags: {:?}", selected_tags);
let selected_tags = selected_tags.unwrap_or_default();
let selected_tags = selected_tags
.into_iter()
.filter_map(|s| s.parse().ok())
.collect::<Vec<i32>>();
let text_tags = tags
.split(",")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from)
.collect::<Vec<_>>();
tracing::debug!("Received tags: {text_tags:?}");
let known = Tags::find()
.filter(entities::tags::Column::Name.is_in(&text_tags))
.all(&mut *t)
.await
.unwrap_or_default();
tracing::debug!("Known tags: {known:?}");
let missing = {
let known = known
.iter()
.map(|model| &model.name)
.collect::<BTreeSet<_>>();
tracing::debug!("Known tag names: {known:?}");
let mut missing = Vec::with_capacity(text_tags.len());
for tag_name in text_tags
.into_iter()
.filter(|tag_name| !known.contains(&tag_name))
{
tracing::debug!("Creating missing tag: {tag_name:?}");
missing.push(
Tags::insert(entities::tags::ActiveModel {
name: Set(tag_name),
..Default::default()
})
.exec_with_returning(&mut *t)
.await
.inspect_err(|e| tracing::warn!("Failed to find tag: {e}"))
.map_err(|_| Error::SaveTag)?,
);
}
tracing::debug!("Missing tags: {missing:?}");
missing
};
#[cfg(debug_assertions)]
tracing::debug!(
"Tags to attach: {:?}",
known
.clone()
.into_iter()
.map(|tag| tag.id)
.chain(missing.clone().into_iter().map(|tag| tag.id))
.chain(selected_tags.clone().into_iter())
.collect::<BTreeSet<_>>()
);
for tag_id in known
.into_iter()
.map(|tag| tag.id)
.chain(missing.into_iter().map(|tag| tag.id))
.chain(selected_tags.into_iter())
.collect::<BTreeSet<_>>()
{
RecipeTags::insert(entities::recipe_tags::ActiveModel {
recipe_id: Set(recipe_id),
tag_id: Set(tag_id),
..Default::default()
})
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
.map_err(|_| Error::SaveRecipeTag)?;
}
Ok(())
}
#[get("/recipe/{id}/edit")]
@ -642,7 +952,7 @@ async fn edit_recipe(
id: actix_web::web::Path<i32>,
admin: Identity,
db: Data<DatabaseConnection>,
) -> Result<EditRecipeForm, Error> {
) -> Result<HttpResponse, Error> {
let id = id.into_inner();
let recipe = match Recipies::find()
.filter(entities::recipies::Column::Id.eq(id))
@ -652,13 +962,17 @@ async fn edit_recipe(
Ok(Some(recipe)) => recipe,
Ok(_) => todo!("index & not found"),
Err(_err) => {
todo!()
return Ok(IndexTemplate::load(&**db, None, admin.id().ok())
.await
.with_error(_err.to_string())
.to_response())
}
};
// steps
let recipe_steps = RecipeSteps::find()
.filter(entities::recipe_steps::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_steps::Column::Id)
.all(&**db)
.await
.inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}"))
@ -667,6 +981,7 @@ async fn edit_recipe(
// tags
let recipe_tags = RecipeTags::find()
.filter(entities::recipe_tags::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_tags::Column::Id)
.all(&**db)
.await
.inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}"))
@ -680,6 +995,7 @@ async fn edit_recipe(
// ingredients
let recipe_ingredients = RecipeIngredients::find()
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_ingredients::Column::Id)
.all(&**db)
.await
.inspect_err(|e| tracing::error!("Failed to load recipe ingredients for edit recipe: {e}"))
@ -690,6 +1006,7 @@ async fn edit_recipe(
entities::ingredients::Relation::RecipeIngredients.def(),
)
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
.order_by_asc(entities::ingredients::Column::Name)
.all(&**db)
.await
.inspect_err(|e| tracing::error!("Failed to load ingredients for edit recipe: {e}"))
@ -705,9 +1022,12 @@ async fn edit_recipe(
});
let steps = recipe_steps
.iter()
.map(|recipe| match recipe.hint {
Some(ref hint) => format!("* {body}\n> {hint}", body = recipe.body, hint = hint),
None => format!("* {body}", body = recipe.body),
.map(|recipe| {
let body = recipe.body.replace("\n", " ");
match recipe.hint {
Some(ref hint) => format!("* {body}\n> {hint}", hint = hint),
None => format!("* {body}"),
}
})
.collect::<Vec<_>>()
.join("\n");
@ -734,27 +1054,33 @@ async fn edit_recipe(
.filter_map(|recipe_tag| Some(tags_by_id.get(&recipe_tag.tag_id)?.name.clone()))
.collect::<Vec<_>>()
.join(","),
selected_tags: recipe_tags
.iter()
.map(|rt| rt.recipe_id.to_string())
.collect(),
image_url: recipe.image_url,
selected_tags: Some(
recipe_tags
.iter()
.map(|rt| rt.recipe_id.to_string())
.collect(),
),
time: recipe.time.map(|i| i.to_string()),
author: recipe.author,
error: None,
known_tags: tags.clone(),
known_ingredients: ingredients.values().cloned().collect(),
known_ingredients: ingredients
.values()
.cloned()
.sorted_by(|a, b| a.name.cmp(&b.name))
.collect(),
session: admin.id().ok(),
page: Page::Index,
})
}
.to_response())
}
#[post("/recipe/{id}/edit")]
#[post("/recipe/{id}/update")]
async fn update_recipe(
_id: actix_web::web::Path<i32>,
admin: Identity,
db: Data<DatabaseConnection>,
form: QsForm<RecipeForm>,
form: QsForm<EditRecipeForm>,
) -> Result<HttpResponse, Error> {
let mut form = form.into_inner();
form.session = admin.id().ok();
@ -767,7 +1093,8 @@ async fn update_recipe(
.await
.inspect_err(|e| tracing::error!("Create recipe transaction: {e}"))
.map_err(|_| Error::DatabaseError)?;
match save_recipe(form, &mut t).await {
match save_recipe_changes(form, &mut t).await {
Err(e) => {
failure.error = Some(e.to_string());
let _ = t.rollback().await;
@ -777,15 +1104,6 @@ async fn update_recipe(
}
Ok(recipe_id) => {
let _ = t.commit().await;
let Ok(_) = tokio::fs::copy(
format!("/tmp{}", failure.image_url),
format!(".{}", failure.image_url),
)
.await
.inspect_err(|e| tracing::error!("Failed to copy {}: {e}", failure.image_url)) else {
tracing::error!("Failed to copy file: {}", failure.image_url);
return Ok(HttpResponse::InternalServerError().finish());
};
Ok(HttpResponse::SeeOther()
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
.finish())
@ -793,6 +1111,52 @@ async fn update_recipe(
}
}
async fn save_recipe_changes(
form: EditRecipeForm,
t: &mut DatabaseTransaction,
) -> Result<i32, Error> {
use crate::entities::recipies::ActiveModel as RAM;
use sea_orm::ActiveValue::*;
let mut summary = String::new();
let parser = pulldown_cmark::Parser::new(&form.summary);
pulldown_cmark::html::push_html(&mut summary, parser);
let time = match form.time {
Some(s) => Some(
humantime::parse_duration(&s)
.inspect_err(|e| tracing::warn!("Invalid duration format: {e}"))
.map_err(|_| Error::InvalidTime)?
.as_secs() as i32,
),
None => None,
};
delete_steps(form.id, &mut *t).await?;
create_steps(form.id, form.steps, &mut *t).await?;
delete_tags(form.id, &mut *t).await?;
create_tags(form.id, form.selected_tags, form.tags, &mut *t).await?;
delete_ingredients(form.id, &mut *t).await?;
create_ingredients(form.id, form.ingredients, &mut *t).await?;
Recipies::update(RAM {
id: Set(form.id),
title: Set(form.title),
author: Set(form.author),
time: Set(time),
summary: Set(Some(summary)),
..Default::default()
})
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Failed to save Recipe: {e}"))
.map_err(|_| Error::SaveRecipe)?;
Ok(form.id)
}
#[get("/styles.css")]
async fn styles_css() -> HttpResponse {
HttpResponse::Ok()
@ -843,5 +1207,9 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) {
.service(recipe_form)
.service(create_recipe)
.service(recipe_image_upload)
.service(by_ingredient)
.service(ingredients_list)
.service(by_tag)
.service(tags_list)
.service(Files::new("/assets", "./assets"));
}

View File

@ -7,6 +7,7 @@ pub type User = String;
#[derive(Debug)]
pub struct Ingredient {
pub id: i32,
pub qty: i32,
pub name: String,
pub unit: String,
@ -89,3 +90,38 @@ impl FromStr for Admins {
))
}
}
pub mod ingredient_with_recipe_count {
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "ingredients")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub recipe_count: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod tag_with_recipe_count {
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "tags")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub recipe_count: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}

View File

@ -12,10 +12,16 @@ pub fn parse_steps(s: &str) -> Result<Vec<(String, Option<String>)>, Error> {
v.push((line.replacen("*", "", 1).trim().to_string(), None));
}
Some('>') => {
v.last_mut().ok_or(InvalidStepList)?.1 =
Some(line.replacen(">", "", 1).trim().to_string());
v.last_mut()
.ok_or(InvalidStepList)
.inspect_err(|_| tracing::warn!("Hint must be part of step."))?
.1 = Some(line.replacen(">", "", 1).trim().to_string());
}
None => return Ok(v),
char => {
tracing::warn!("Invalid step starting character: {char:?}");
return Err(InvalidStepList);
}
_ => return Err(InvalidStepList),
};
Ok(v)

View File

@ -0,0 +1,28 @@
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Recipes</h2>
</header>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
{% for recipe in recipies %}
{{ recipe|safe }}
{% endfor %}
</div>
<div class="flex gap-4 flex-wrap">
{% match count %}
{% when 0 %}
{% when _ %}
{% for page in 0..count %}
<a class="btn" href="/?page={{page}}">{{page}}</a>
{% endfor %}
{% endmatch %}
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,12 @@
<a class="card hover:shadow-lg transition ease-linear transform hover:scale-105 shrink-0 grow-0 w-full md:w-[225px] lg:w-[400px]" href="/ingredients/{{id}}">
<div class="m-4">
<span class="font-bold">{{name}}</span>
<div>
{% match recipe_count %}
{% when Some with (n) %}
{{n}} recipies
{% else %}
{% endmatch %}
</div>
</div>
</a>

View File

@ -0,0 +1,19 @@
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Ingredients</h2>
</header>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
{% for ingredient in ingredients %}
{{ ingredient|safe }}
{% endfor %}
</div>
</div>
</main>
{% endblock %}

View File

@ -48,7 +48,7 @@
</a>
</li>
<li>
<a href="/ingeredients" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}} gap-2">
<a href="/ingredients" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}} gap-2">
<span>Ingeredients</span>
<svg
version="1.1"

View File

@ -36,10 +36,10 @@
<p class="text-base font-semibold">Summary supports Markdown</p>
</blockquote>
<label for="ingeredients" class="flex gap-4">
<span class="w-24 shrink-0">Ingeredients:</span>
<label for="ingredients" class="flex gap-4">
<span class="w-24 shrink-0">Ingredients:</span>
<div class="flex gap-2 w-full">
<textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea>
<textarea id="ingredients" name="ingredients" class="textarea-without-view" required>{{ingredients}}</textarea>
</div>
</label>
<div class="w-full flex gap-4 flex-wrap">
@ -84,11 +84,17 @@
{% for tag in known_tags %}
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
<span>{{ tag.name }}</span>
{% match (tag.id, selected_tags.as_slice())|is_checked %}
{% when true %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
{% when false %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% match selected_tags %}
{% when Some with (selected_tags) %}
{% match (tag.id, selected_tags.as_slice())|is_checked %}
{% when true %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
{% when false %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% endmatch %}
{% else %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% endmatch %}
</label>
{% endfor %}

View File

@ -69,7 +69,7 @@
</p>
<div class="flex gap-7">
{% for tag in tags %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center" href="/tags/{{tag.id}}">
{{ tag.name }}
</a>
{% endfor %}
@ -82,7 +82,7 @@
</p>
<div class="flex gap-7">
{% for ingredient in ingredients %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center" href="/ingredients/{{ingredient.id}}">
{{ ingredient.name }}
</a>
{% endfor %}

View File

@ -11,12 +11,19 @@
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Create recipe</h2>
</header>
<div class="error">
{% match error %}
{% when Some with (text) %}
<div>{{text}}</div>
{% else %}
{% endmatch %}
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
<form action="/recipe/{{id}}/update" method="post" class="flex flex-col gap-4 md:w-1/2 md:mb-10">
<input type="hidden" name="id" value="{{id}}" id="recipe-{{id}}"/>
<label for="image" class="flex gap-4">
<input type="file" class="file-input w-full max-w-xs" name="image" id="image" />
<input name="image_url" id='image_url' class="hidden" required />
</label>
<label for="title" class="flex gap-4">
@ -84,14 +91,21 @@
{% for tag in known_tags %}
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
<span>{{ tag.name }}</span>
{% match (tag.id, selected_tags.as_slice())|is_checked %}
{% when true %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
{% when false %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% match selected_tags %}
{% when Some with (selected_tags) %}
{% match (tag.id, selected_tags.as_slice())|is_checked %}
{% when true %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked data-type="number[]" />
{% when false %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% endmatch %}
{% else %}
<input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" data-type="number[]" />
{% endmatch %}
</label>
{% endfor %}
</div>
<div>
<input type="submit" class="btn w-full" value="Save" />

View File

@ -0,0 +1,28 @@
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Recipes</h2>
</header>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
{% for recipe in recipies %}
{{ recipe|safe }}
{% endfor %}
</div>
<div class="flex gap-4 flex-wrap">
{% match count %}
{% when 0 %}
{% when _ %}
{% for page in 0..count %}
<a class="btn" href="/?page={{page}}">{{page}}</a>
{% endfor %}
{% endmatch %}
</div>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,12 @@
<a class="card hover:shadow-lg transition ease-linear transform hover:scale-105 shrink-0 grow-0 w-full md:w-[225px] lg:w-[400px]" href="/tags/{{id}}">
<div class="m-4">
<span class="font-bold">{{name}}</span>
<div>
{% match recipe_count %}
{% when Some with (n) %}
{{n}} recipies
{% else %}
{% endmatch %}
</div>
</div>
</a>

View File

@ -0,0 +1,19 @@
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Tags</h2>
</header>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
{% for tag in tags %}
{{ tag|safe }}
{% endfor %}
</div>
</div>
</main>
{% endblock %}