Create recipe
This commit is contained in:
parent
c55ae4c523
commit
8e9849debb
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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 %}
|
||||
|
@ -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(),
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user