Create recipe

This commit is contained in:
eraden 2024-11-02 22:00:03 +01:00
parent c55ae4c523
commit 8e9849debb
4 changed files with 231 additions and 101 deletions

View File

@ -80,6 +80,8 @@ struct RecipeForm {
#[serde(default)] #[serde(default)]
known_tags: Vec<entities::tags::Model>, known_tags: Vec<entities::tags::Model>,
#[serde(default)] #[serde(default)]
known_ingredients: Vec<entities::ingredients::Model>,
#[serde(default)]
session: Option<User>, session: Option<User>,
#[serde(default)] #[serde(default)]
page: Page, page: Page,
@ -326,23 +328,26 @@ async fn show(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
HttpResponse::Ok().body( HttpResponse::Ok()
RecipeDetailTemplate { .append_header((CONTENT_TYPE, "text/html"))
steps, .body(
tags, RecipeDetailTemplate {
recipe, steps,
ingredients, tags,
session: admin.and_then(|s| s.id().ok()), recipe,
page: Page::Recipe, ingredients,
} session: admin.and_then(|s| s.id().ok()),
.render() page: Page::Recipe,
.unwrap_or_default(), }
) .render()
.unwrap_or_default(),
)
} }
#[get("/create")] #[get("/create")]
async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm { async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm {
let tags = Tags::find().all(&**db).await.unwrap_or_default(); let tags = Tags::find().all(&**db).await.unwrap_or_default();
let ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
RecipeForm { RecipeForm {
title: "".into(), title: "".into(),
summary: "".into(), summary: "".into(),
@ -355,6 +360,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
time: None, time: None,
error: None, error: None,
known_tags: tags, known_tags: tags,
known_ingredients: ingredients,
session: admin.id().ok(), session: admin.id().ok(),
page: Page::Index, page: Page::Index,
} }
@ -363,7 +369,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
#[post("/create")] #[post("/create")]
async fn create_recipe( async fn create_recipe(
admin: Identity, admin: Identity,
form: Form<RecipeForm>, form: actix_web::web::Json<RecipeForm>,
db: Data<DatabaseConnection>, db: Data<DatabaseConnection>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let mut form = form.into_inner(); let mut form = form.into_inner();
@ -382,9 +388,10 @@ async fn create_recipe(
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();
failure.known_ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
Ok(failure.to_response()) Ok(failure.to_response())
} }
Ok(_) => { Ok(recipe_id) => {
let _ = t.commit().await; let _ = t.commit().await;
let Ok(_) = tokio::fs::copy( let Ok(_) = tokio::fs::copy(
format!("/tmp{}", failure.image_url), format!("/tmp{}", failure.image_url),
@ -395,14 +402,14 @@ async fn create_recipe(
tracing::error!("Failed to copy file: {}", failure.image_url); tracing::error!("Failed to copy file: {}", failure.image_url);
return Ok(HttpResponse::InternalServerError().finish()); return Ok(HttpResponse::InternalServerError().finish());
}; };
Ok(HttpResponse::TemporaryRedirect() Ok(HttpResponse::SeeOther()
.append_header(("location", "/")) .append_header(("location", format!("/recipe/{recipe_id}").as_str()))
.finish()) .finish())
} }
} }
} }
async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<(), Error> { async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<i32, Error> {
use crate::entities::recipies::ActiveModel as RAM; use crate::entities::recipies::ActiveModel as RAM;
use sea_orm::ActiveValue::*; use sea_orm::ActiveValue::*;
@ -434,85 +441,168 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<()
.map_err(|_| Error::SaveRecipe)? .map_err(|_| Error::SaveRecipe)?
.last_insert_id; .last_insert_id;
let steps = crate::utils::parse_steps(&form.steps)? // Tags
.into_iter() {
.map(|(body, hint)| entities::recipe_steps::ActiveModel { tracing::debug!("Selected tags: {:?}", form.selected_tags);
body: Set(body), let selected_tags = form
hint: Set(hint), .selected_tags;
recipe_id: Set(recipe_id),
..Default::default() let text_tags = form
}) .tags
.collect::<Vec<_>>(); .split(",")
let _steps = RecipeSteps::insert_many(steps) .map(|s| s.trim())
.exec(&mut *t) .filter(|s| !s.is_empty())
.await .map(String::from)
.inspect_err(|e| tracing::error!("Save steps: {e}")) .collect::<Vec<_>>();
.map_err(|_| Error::SaveRecipeStep)?; tracing::debug!("Received tags: {text_tags:?}");
let ingredients = crate::utils::parse_ingenedients(&form.ingeredients)?; let known = Tags::find()
let known = Ingredients::find() .filter(entities::tags::Column::Name.is_in(&text_tags))
.filter( .all(&mut *t)
entities::ingredients::Column::Name.is_in( .await
ingredients .unwrap_or_default();
.iter() tracing::debug!("Known tags: {known:?}");
.map(|(name, ..)| name.to_string()) let missing = {
.collect::<Vec<_>>(), let known = known
), .iter()
) .map(|model| &model.name)
.all(&mut *t) .collect::<BTreeSet<_>>();
.await tracing::debug!("Known tag names: {known:?}");
.inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}")) let mut missing = Vec::with_capacity(text_tags.len());
.map_err(|_| Error::SaveRecipeIngeredient)?; for tag_name in text_tags
let missing = { .into_iter()
let known = known .filter(|tag_name| !known.contains(&tag_name))
.iter() {
.map(|model| &model.name) tracing::debug!("Creating missing tag: {tag_name:?}");
.collect::<BTreeSet<_>>(); missing.push(
let missing = ingredients Tags::insert(entities::tags::ActiveModel {
.iter() name: Set(tag_name),
.filter(|(name, ..)| !known.contains(name)) ..Default::default()
.map(|(name, ..)| entities::ingredients::ActiveModel { })
name: Set(name.to_owned()), .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)?;
}
}
// 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() ..Default::default()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut v = Vec::with_capacity(missing.len()); let _steps = RecipeSteps::insert_many(steps)
for missing in missing { .exec(&mut *t)
v.push( .await
Ingredients::insert(missing) .inspect_err(|e| tracing::error!("Save steps: {e}"))
.exec_with_returning(&mut *t) .map_err(|_| Error::SaveRecipeStep)?;
.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
ingredients {
.into_iter() let ingredients = crate::utils::parse_ingenedients(&form.ingeredients)?;
.filter_map(|(name, unit, qty)| { let known = Ingredients::find()
Some(entities::recipe_ingredients::ActiveModel { .filter(
ingredient_id: Set(*map.get(&name)?), entities::ingredients::Column::Name.is_in(
qty: Set(qty), ingredients
unit: Set(unit.unwrap_or_default()), .iter()
recipe_id: Set(recipe_id), .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() ..Default::default()
}) })
}) .collect::<Vec<_>>();
.collect::<Vec<_>>(), let mut v = Vec::with_capacity(missing.len());
) for missing in missing {
.exec(&mut *t) v.push(
.await Ingredients::insert(missing)
.inspect_err(|e| tracing::error!("Save ingeredients: {e}")) .exec_with_returning(&mut *t)
.map_err(|_| Error::SaveRecipeIngeredient)?; .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<_, _>>();
Ok(()) 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("/styles.css")] #[get("/styles.css")]
@ -526,7 +616,7 @@ async fn tmp_cleanup() {
let d = tokio::time::Duration::from_secs(10); let d = tokio::time::Duration::from_secs(10);
loop { loop {
tokio::time::sleep(d).await; tokio::time::sleep(d).await;
tracing::info!("Starting files cleanup"); // tracing::info!("Starting files cleanup");
let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else { let Ok(mut dir) = tokio::fs::read_dir("/tmp/assets/recipies/images").await else {
tracing::info!("Files cleanup failed. No tmp images dir"); tracing::info!("Files cleanup failed. No tmp images dir");
@ -545,7 +635,7 @@ async fn tmp_cleanup() {
let _ = tokio::fs::remove_file(entry.path()).await; let _ = tokio::fs::remove_file(entry.path()).await;
} }
} }
tracing::info!("Files cleanup done"); // tracing::info!("Files cleanup done");
} }
} }

View File

@ -27,6 +27,8 @@ pub enum Error {
#[error("Internal server error")] #[error("Internal server error")]
SaveRecipeIngeredient, SaveRecipeIngeredient,
#[error("Internal server error")] #[error("Internal server error")]
SaveTag,
#[error("Internal server error")]
SaveRecipeTag, SaveRecipeTag,
#[error("Internal server error")] #[error("Internal server error")]
DatabaseError, DatabaseError,
@ -42,6 +44,7 @@ impl actix_web::error::ResponseError for Error {
Self::SaveRecipeStep => StatusCode::INTERNAL_SERVER_ERROR, Self::SaveRecipeStep => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveRecipeIngeredient => StatusCode::INTERNAL_SERVER_ERROR, Self::SaveRecipeIngeredient => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveRecipeTag => StatusCode::INTERNAL_SERVER_ERROR, Self::SaveRecipeTag => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveTag => StatusCode::INTERNAL_SERVER_ERROR,
Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }

View File

@ -42,6 +42,13 @@
<textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea> <textarea id="ingeredients" name="ingeredients" class="textarea-without-view" required>{{ingeredients}}</textarea>
</div> </div>
</label> </label>
<div class="w-full flex gap-4 flex-wrap">
{% for ingredient in known_ingredients %}
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
<span>{{ ingredient.name }}</span>
</label>
{% endfor %}
</div>
<details class="w-full collapse collapse-arrow"> <details class="w-full collapse collapse-arrow">
<summary class="text-base font-semibold collapse-title">Ingeredients should be listed as list of AMOUNT UNIT INGEREDIENT. Example:</summary> <summary class="text-base font-semibold collapse-title">Ingeredients should be listed as list of AMOUNT UNIT INGEREDIENT. Example:</summary>
@ -79,9 +86,9 @@
<span>{{ tag.name }}</span> <span>{{ tag.name }}</span>
{% 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[{{loop.index0}}]" value="{{tag.id}}" class="hidden" checked /> <input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" checked />
{% when false %} {% when false %}
<input type="checkbox" name="selected_tags[{{loop.index0}}]" value="{{tag.id}}" class="hidden" /> <input type="checkbox" name="selected_tags[]" value="{{tag.id}}" class="hidden" />
{% endmatch %} {% endmatch %}
</label> </label>
{% endfor %} {% endfor %}
@ -95,7 +102,8 @@
</main> </main>
<script type="module"> <script type="module">
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
Array.from(document.body.querySelectorAll('textarea')).forEach(el => { const body = document.body;
Array.from(body.querySelectorAll('textarea')).forEach(el => {
if (!el.parentElement.querySelector('.content')) return; if (!el.parentElement.querySelector('.content')) return;
el.parentElement.querySelector('div').innerHTML = marked.parse(el.value); el.parentElement.querySelector('div').innerHTML = marked.parse(el.value);
el.addEventListener('keyup', () => { el.addEventListener('keyup', () => {
@ -103,8 +111,8 @@ document.addEventListener("DOMContentLoaded", (event) => {
}); });
}); });
const image = document.body.querySelector('input#image'); const image = body.querySelector('input#image');
const image_url = document.body.querySelector('#image_url'); const image_url = body.querySelector('#image_url');
image.addEventListener('change', () => { image.addEventListener('change', () => {
const f = new FormData(); const f = new FormData();
f.append('image', image.files[0]); f.append('image', image.files[0]);
@ -112,6 +120,25 @@ document.addEventListener("DOMContentLoaded", (event) => {
.then(res => res.json()) .then(res => res.json())
.then(({ url }) => image_url.value = url); .then(({ url }) => image_url.value = url);
}); });
const form = body.querySelector('form');
form.addEventListener('submit', ev => {
ev.preventDefault();
ev.stopPropagation();
const p = Array.from(form.elements).reduce((memo, el) => {
const { name, value } = el;
if (name.endsWith('[]')) {
const v = memo[name.replace('[]', '')] || [];
v.push(value);
memo[name] = v;
} else {
memo[name] = value;
}
return memo;
}, {});
fetch(form.action, { method: form.method, body: JSON.stringify(p), headers: { 'Content-Type': 'application/json' } })
.then(res => this.location = res.headers.get('location'))
.catch(res => res.text().then(html => body.innerHTML = html ));
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -87,7 +87,9 @@ impl MigrationTrait for Migration {
.to_tbl(Recipe::Recipies) .to_tbl(Recipe::Recipies)
.to_col(Recipe::Id) .to_col(Recipe::Id)
.from_tbl(RecipeStep::RecipeSteps) .from_tbl(RecipeStep::RecipeSteps)
.from_col(RecipeStep::RecipeId), .from_col(RecipeStep::RecipeId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
) )
.to_owned(), .to_owned(),
) )
@ -122,14 +124,18 @@ impl MigrationTrait for Migration {
.to_tbl(Recipe::Recipies) .to_tbl(Recipe::Recipies)
.to_col(Recipe::Id) .to_col(Recipe::Id)
.from_tbl(RecipeIngredient::RecipeIngredients) .from_tbl(RecipeIngredient::RecipeIngredients)
.from_col(RecipeIngredient::RecipeId), .from_col(RecipeIngredient::RecipeId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
) )
.foreign_key( .foreign_key(
&mut ForeignKeyCreateStatement::new() &mut ForeignKeyCreateStatement::new()
.to_tbl(Ingredient::Ingredients) .to_tbl(Ingredient::Ingredients)
.to_col(Recipe::Id) .to_col(Recipe::Id)
.from_tbl(RecipeIngredient::RecipeIngredients) .from_tbl(RecipeIngredient::RecipeIngredients)
.from_col(RecipeIngredient::IngredientId), .from_col(RecipeIngredient::IngredientId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
) )
.to_owned(), .to_owned(),
) )
@ -154,14 +160,18 @@ impl MigrationTrait for Migration {
.to_tbl(Recipe::Recipies) .to_tbl(Recipe::Recipies)
.to_col(Recipe::Id) .to_col(Recipe::Id)
.from_tbl(RecipeTag::RecipeTags) .from_tbl(RecipeTag::RecipeTags)
.from_col(RecipeTag::RecipeId), .from_col(RecipeTag::RecipeId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
) )
.foreign_key( .foreign_key(
&mut ForeignKeyCreateStatement::new() &mut ForeignKeyCreateStatement::new()
.to_tbl(Tag::Tags) .to_tbl(Tag::Tags)
.to_col(Tag::Id) .to_col(Tag::Id)
.from_tbl(RecipeTag::RecipeTags) .from_tbl(RecipeTag::RecipeTags)
.from_col(RecipeTag::TagId), .from_col(RecipeTag::TagId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
) )
.to_owned(), .to_owned(),
) )