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)]
known_tags: Vec<entities::tags::Model>,
#[serde(default)]
known_ingredients: Vec<entities::ingredients::Model>,
#[serde(default)]
session: Option<User>,
#[serde(default)]
page: Page,
@ -326,23 +328,26 @@ async fn show(
})
.collect::<Vec<_>>();
HttpResponse::Ok().body(
RecipeDetailTemplate {
steps,
tags,
recipe,
ingredients,
session: admin.and_then(|s| s.id().ok()),
page: Page::Recipe,
}
.render()
.unwrap_or_default(),
)
HttpResponse::Ok()
.append_header((CONTENT_TYPE, "text/html"))
.body(
RecipeDetailTemplate {
steps,
tags,
recipe,
ingredients,
session: admin.and_then(|s| s.id().ok()),
page: Page::Recipe,
}
.render()
.unwrap_or_default(),
)
}
#[get("/create")]
async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm {
let tags = Tags::find().all(&**db).await.unwrap_or_default();
let ingredients = Ingredients::find().all(&**db).await.unwrap_or_default();
RecipeForm {
title: "".into(),
summary: "".into(),
@ -355,6 +360,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
time: None,
error: None,
known_tags: tags,
known_ingredients: ingredients,
session: admin.id().ok(),
page: Page::Index,
}
@ -363,7 +369,7 @@ async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeFor
#[post("/create")]
async fn create_recipe(
admin: Identity,
form: Form<RecipeForm>,
form: actix_web::web::Json<RecipeForm>,
db: Data<DatabaseConnection>,
) -> Result<HttpResponse, Error> {
let mut form = form.into_inner();
@ -382,9 +388,10 @@ async fn create_recipe(
failure.error = Some(e.to_string());
let _ = t.rollback().await;
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(_) => {
Ok(recipe_id) => {
let _ = t.commit().await;
let Ok(_) = tokio::fs::copy(
format!("/tmp{}", failure.image_url),
@ -395,14 +402,14 @@ async fn create_recipe(
tracing::error!("Failed to copy file: {}", failure.image_url);
return Ok(HttpResponse::InternalServerError().finish());
};
Ok(HttpResponse::TemporaryRedirect()
.append_header(("location", "/"))
Ok(HttpResponse::SeeOther()
.append_header(("location", format!("/recipe/{recipe_id}").as_str()))
.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 sea_orm::ActiveValue::*;
@ -434,85 +441,168 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result<()
.map_err(|_| Error::SaveRecipe)?
.last_insert_id;
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)?;
let ingredients = crate::utils::parse_ingenedients(&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()),
// Tags
{
tracing::debug!("Selected tags: {:?}", form.selected_tags);
let selected_tags = form
.selected_tags;
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)?;
}
}
// 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 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<_, _>>();
let _steps = RecipeSteps::insert_many(steps)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save steps: {e}"))
.map_err(|_| Error::SaveRecipeStep)?;
}
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),
// Ingredients
{
let ingredients = crate::utils::parse_ingenedients(&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<_>>(),
)
.exec(&mut *t)
.await
.inspect_err(|e| tracing::error!("Save ingeredients: {e}"))
.map_err(|_| Error::SaveRecipeIngeredient)?;
.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<_, _>>();
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")]
@ -526,7 +616,7 @@ async fn tmp_cleanup() {
let d = tokio::time::Duration::from_secs(10);
loop {
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 {
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;
}
}
tracing::info!("Files cleanup done");
// tracing::info!("Files cleanup done");
}
}

View File

@ -27,6 +27,8 @@ pub enum Error {
#[error("Internal server error")]
SaveRecipeIngeredient,
#[error("Internal server error")]
SaveTag,
#[error("Internal server error")]
SaveRecipeTag,
#[error("Internal server error")]
DatabaseError,
@ -42,6 +44,7 @@ impl actix_web::error::ResponseError for Error {
Self::SaveRecipeStep => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveRecipeIngeredient => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveRecipeTag => StatusCode::INTERNAL_SERVER_ERROR,
Self::SaveTag => 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>
</div>
</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">
<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>
{% match (tag.id, selected_tags.as_slice())|is_checked %}
{% 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 %}
<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 %}
</label>
{% endfor %}
@ -95,7 +102,8 @@
</main>
<script type="module">
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;
el.parentElement.querySelector('div').innerHTML = marked.parse(el.value);
el.addEventListener('keyup', () => {
@ -103,8 +111,8 @@ document.addEventListener("DOMContentLoaded", (event) => {
});
});
const image = document.body.querySelector('input#image');
const image_url = document.body.querySelector('#image_url');
const image = body.querySelector('input#image');
const image_url = body.querySelector('#image_url');
image.addEventListener('change', () => {
const f = new FormData();
f.append('image', image.files[0]);
@ -112,6 +120,25 @@ document.addEventListener("DOMContentLoaded", (event) => {
.then(res => res.json())
.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>
{% endblock %}

View File

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