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 actix_web::{get, post, HttpRequest, HttpResponse, Responder};
use askama::Template; use askama::Template;
use askama_actix::TemplateToResponse; use askama_actix::TemplateToResponse;
use itertools::Itertools;
use sea_orm::{ use sea_orm::{
prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait, prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait,
}; };
@ -22,14 +23,249 @@ use std::collections::{BTreeMap, BTreeSet};
#[template(path = "recipe_card.jinja")] #[template(path = "recipe_card.jinja")]
struct RecipeCard(entities::recipies::Model); 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)] #[derive(Debug, Template)]
#[template(path = "index.jinja", ext = "html")] #[template(path = "ingredient.jinja", ext = "html")]
struct IndexTemplate { pub struct IngredientTemplate {
recipies: Vec<RecipeCard>, recipies: Vec<RecipeCard>,
count: u64, count: u64,
session: Option<User>, session: Option<User>,
page: Page, 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)] #[derive(Debug, Template)]
#[template(path = "search.jinja", ext = "html")] #[template(path = "search.jinja", ext = "html")]
@ -72,10 +308,10 @@ struct ImageUpload {
struct RecipeForm { struct RecipeForm {
title: String, title: String,
summary: String, summary: String,
ingeredients: String, ingredients: String,
steps: String, steps: String,
tags: String, tags: String,
selected_tags: Vec<String>, selected_tags: Option<Vec<String>>,
image_url: String, image_url: String,
time: Option<String>, time: Option<String>,
author: Option<String>, author: Option<String>,
@ -99,8 +335,7 @@ struct EditRecipeForm {
ingredients: String, ingredients: String,
steps: String, steps: String,
tags: String, tags: String,
selected_tags: Vec<String>, selected_tags: Option<Vec<String>>,
image_url: String,
time: Option<String>, time: Option<String>,
author: Option<String>, author: Option<String>,
error: Option<String>, error: Option<String>,
@ -161,10 +396,6 @@ async fn sign_in(
.any(|admin| admin.email == payload.email && admin.pass == payload.password); .any(|admin| admin.email == payload.email && admin.pass == payload.password);
if is_admin { if is_admin {
tracing::info!("Valid credentials"); 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 = let _s =
Identity::login(&req.extensions(), payload.email).expect("Failed to store session"); 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>, q: actix_web::web::Query<Padding>,
admin: Option<Identity>, admin: Option<Identity>,
) -> HttpResponse { ) -> HttpResponse {
let count = (entities::prelude::Recipies::find() IndexTemplate::load(&**db, q.page, admin.and_then(|session| session.id().ok()))
.count(&**db)
.await .await
.unwrap_or_default() as f64 .to_response()
/ 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)
.await
.unwrap_or_default();
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() #[get("/ingredients")]
.append_header((CONTENT_TYPE, "text/html; charset=utf-8")) async fn ingredients_list(db: Data<DatabaseConnection>, admin: Option<Identity>) -> HttpResponse {
.append_header((CONTENT_LENGTH, html.len())) IngredientsTemplate::load(&**db, admin.and_then(|session| session.id().ok()))
.body(html) .await
.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()
}
#[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)] #[derive(Debug, Template)]
@ -330,14 +583,11 @@ async fn show(
.await .await
.unwrap_or_default(); .unwrap_or_default();
let ingredients = entities::prelude::Ingredients::find() let ingredients = entities::prelude::Ingredients::find()
.filter( .join(
entities::ingredients::Column::Id.is_in( JoinType::InnerJoin,
recipe_ingredients entities::ingredients::Relation::RecipeIngredients.def(),
.iter()
.map(|i| i.ingredient_id)
.collect::<Vec<_>>(),
),
) )
.filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe.id))
.all(db) .all(db)
.await .await
.unwrap_or_default() .unwrap_or_default()
@ -348,6 +598,7 @@ async fn show(
.into_iter() .into_iter()
.filter_map(|ri| { .filter_map(|ri| {
Some(Ingredient { Some(Ingredient {
id: ri.ingredient_id,
name: ingredients.get(&ri.ingredient_id)?.to_owned(), name: ingredients.get(&ri.ingredient_id)?.to_owned(),
qty: ri.qty, qty: ri.qty,
unit: ri.unit, unit: ri.unit,
@ -378,7 +629,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
RecipeForm { RecipeForm {
title: "".into(), title: "".into(),
summary: "".into(), summary: "".into(),
ingeredients: "".into(), ingredients: "".into(),
steps: "".into(), steps: "".into(),
tags: "".into(), tags: "".into(),
selected_tags: Default::default(), selected_tags: Default::default(),
@ -413,6 +664,7 @@ async fn create_recipe(
.map_err(|_| Error::DatabaseError)?; .map_err(|_| Error::DatabaseError)?;
match save_recipe(form, &mut t).await { match save_recipe(form, &mut t).await {
Err(e) => { Err(e) => {
tracing::warn!("Failed to save changes: {e}");
failure.error = Some(e.to_string()); failure.error = Some(e.to_string());
let _ = t.rollback().await; let _ = t.rollback().await;
failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default(); failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default();
@ -470,16 +722,161 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
.last_insert_id; .last_insert_id;
// Tags // Tags
{ create_tags(recipe_id, form.selected_tags, form.tags, &mut *t).await?;
tracing::debug!("Selected tags: {:?}", form.selected_tags);
let selected_tags = form.selected_tags; // Steps
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 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<_, _>>();
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<_>>(),
)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)
.map(|_| ())
}
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 steps: {e}"))
.map_err(|_| Error::SaveRecipeStep)?;
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 let selected_tags = selected_tags
.into_iter() .into_iter()
.filter_map(|s| s.parse().ok()) .filter_map(|s| s.parse().ok())
.collect::<Vec<i32>>(); .collect::<Vec<i32>>();
let text_tags = form let text_tags = tags
.tags
.split(",") .split(",")
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@ -547,94 +944,7 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i3
.inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}")) .inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}"))
.map_err(|_| Error::SaveRecipeTag)?; .map_err(|_| Error::SaveRecipeTag)?;
} }
} Ok(())
// 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),
..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<_>>(),
),
)
.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 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<_, _>>();
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<_>>(),
)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
};
Ok(recipe_id)
} }
#[get("/recipe/{id}/edit")] #[get("/recipe/{id}/edit")]
@ -642,7 +952,7 @@ async fn edit_recipe(
id: actix_web::web::Path<i32>, id: actix_web::web::Path<i32>,
admin: Identity, admin: Identity,
db: Data<DatabaseConnection>, db: Data<DatabaseConnection>,
) -> Result<EditRecipeForm, Error> { ) -> Result<HttpResponse, Error> {
let id = id.into_inner(); let id = id.into_inner();
let recipe = match Recipies::find() let recipe = match Recipies::find()
.filter(entities::recipies::Column::Id.eq(id)) .filter(entities::recipies::Column::Id.eq(id))
@ -652,13 +962,17 @@ async fn edit_recipe(
Ok(Some(recipe)) => recipe, Ok(Some(recipe)) => recipe,
Ok(_) => todo!("index & not found"), Ok(_) => todo!("index & not found"),
Err(_err) => { Err(_err) => {
todo!() return Ok(IndexTemplate::load(&**db, None, admin.id().ok())
.await
.with_error(_err.to_string())
.to_response())
} }
}; };
// steps // steps
let recipe_steps = RecipeSteps::find() let recipe_steps = RecipeSteps::find()
.filter(entities::recipe_steps::Column::RecipeId.eq(id)) .filter(entities::recipe_steps::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_steps::Column::Id)
.all(&**db) .all(&**db)
.await .await
.inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}")) .inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}"))
@ -667,6 +981,7 @@ async fn edit_recipe(
// tags // tags
let recipe_tags = RecipeTags::find() let recipe_tags = RecipeTags::find()
.filter(entities::recipe_tags::Column::RecipeId.eq(id)) .filter(entities::recipe_tags::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_tags::Column::Id)
.all(&**db) .all(&**db)
.await .await
.inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}")) .inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}"))
@ -680,6 +995,7 @@ async fn edit_recipe(
// ingredients // ingredients
let recipe_ingredients = RecipeIngredients::find() let recipe_ingredients = RecipeIngredients::find()
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id)) .filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
.order_by_asc(entities::recipe_ingredients::Column::Id)
.all(&**db) .all(&**db)
.await .await
.inspect_err(|e| tracing::error!("Failed to load recipe ingredients for edit recipe: {e}")) .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(), entities::ingredients::Relation::RecipeIngredients.def(),
) )
.filter(entities::recipe_ingredients::Column::RecipeId.eq(id)) .filter(entities::recipe_ingredients::Column::RecipeId.eq(id))
.order_by_asc(entities::ingredients::Column::Name)
.all(&**db) .all(&**db)
.await .await
.inspect_err(|e| tracing::error!("Failed to load ingredients for edit recipe: {e}")) .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 let steps = recipe_steps
.iter() .iter()
.map(|recipe| match recipe.hint { .map(|recipe| {
Some(ref hint) => format!("* {body}\n> {hint}", body = recipe.body, hint = hint), let body = recipe.body.replace("\n", " ");
None => format!("* {body}", body = recipe.body), match recipe.hint {
Some(ref hint) => format!("* {body}\n> {hint}", hint = hint),
None => format!("* {body}"),
}
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .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())) .filter_map(|recipe_tag| Some(tags_by_id.get(&recipe_tag.tag_id)?.name.clone()))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","), .join(","),
selected_tags: recipe_tags selected_tags: Some(
recipe_tags
.iter() .iter()
.map(|rt| rt.recipe_id.to_string()) .map(|rt| rt.recipe_id.to_string())
.collect(), .collect(),
image_url: recipe.image_url, ),
time: recipe.time.map(|i| i.to_string()), time: recipe.time.map(|i| i.to_string()),
author: recipe.author, author: recipe.author,
error: None, error: None,
known_tags: tags.clone(), 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(), session: admin.id().ok(),
page: Page::Index, page: Page::Index,
}) }
.to_response())
} }
#[post("/recipe/{id}/edit")] #[post("/recipe/{id}/update")]
async fn update_recipe( async fn update_recipe(
_id: actix_web::web::Path<i32>, _id: actix_web::web::Path<i32>,
admin: Identity, admin: Identity,
db: Data<DatabaseConnection>, db: Data<DatabaseConnection>,
form: QsForm<RecipeForm>, form: QsForm<EditRecipeForm>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let mut form = form.into_inner(); let mut form = form.into_inner();
form.session = admin.id().ok(); form.session = admin.id().ok();
@ -767,7 +1093,8 @@ async fn update_recipe(
.await .await
.inspect_err(|e| tracing::error!("Create recipe transaction: {e}")) .inspect_err(|e| tracing::error!("Create recipe transaction: {e}"))
.map_err(|_| Error::DatabaseError)?; .map_err(|_| Error::DatabaseError)?;
match save_recipe(form, &mut t).await {
match save_recipe_changes(form, &mut t).await {
Err(e) => { Err(e) => {
failure.error = Some(e.to_string()); failure.error = Some(e.to_string());
let _ = t.rollback().await; let _ = t.rollback().await;
@ -777,15 +1104,6 @@ async fn update_recipe(
} }
Ok(recipe_id) => { Ok(recipe_id) => {
let _ = t.commit().await; 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() Ok(HttpResponse::SeeOther()
.append_header(("location", format!("/recipe/{recipe_id}").as_str())) .append_header(("location", format!("/recipe/{recipe_id}").as_str()))
.finish()) .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")] #[get("/styles.css")]
async fn styles_css() -> HttpResponse { async fn styles_css() -> HttpResponse {
HttpResponse::Ok() HttpResponse::Ok()
@ -843,5 +1207,9 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) {
.service(recipe_form) .service(recipe_form)
.service(create_recipe) .service(create_recipe)
.service(recipe_image_upload) .service(recipe_image_upload)
.service(by_ingredient)
.service(ingredients_list)
.service(by_tag)
.service(tags_list)
.service(Files::new("/assets", "./assets")); .service(Files::new("/assets", "./assets"));
} }

View File

@ -7,6 +7,7 @@ pub type User = String;
#[derive(Debug)] #[derive(Debug)]
pub struct Ingredient { pub struct Ingredient {
pub id: i32,
pub qty: i32, pub qty: i32,
pub name: String, pub name: String,
pub unit: 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)); v.push((line.replacen("*", "", 1).trim().to_string(), None));
} }
Some('>') => { Some('>') => {
v.last_mut().ok_or(InvalidStepList)?.1 = v.last_mut()
Some(line.replacen(">", "", 1).trim().to_string()); .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) 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> </a>
</li> </li>
<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> <span>Ingeredients</span>
<svg <svg
version="1.1" version="1.1"

View File

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

View File

@ -69,7 +69,7 @@
</p> </p>
<div class="flex gap-7"> <div class="flex gap-7">
{% for tag in tags %} {% 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 }} {{ tag.name }}
</a> </a>
{% endfor %} {% endfor %}
@ -82,7 +82,7 @@
</p> </p>
<div class="flex gap-7"> <div class="flex gap-7">
{% for ingredient in ingredients %} {% 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 }} {{ ingredient.name }}
</a> </a>
{% endfor %} {% endfor %}

View File

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