Merge pull request #23 from stevedylandev/chore/refactor-cellar
a6812266
chore/refactor cellar
5 file(s) · +259 −244
chore/refactor cellar
| 693 | 693 | "rust-embed", |
|
| 694 | 694 | "serde", |
|
| 695 | 695 | "serde_json", |
|
| 696 | + | "serde_rusqlite", |
|
| 696 | 697 | "subtle", |
|
| 697 | 698 | "tokio", |
|
| 698 | 699 | "tracing", |
|
| 4031 | 4032 | dependencies = [ |
|
| 4032 | 4033 | "itoa", |
|
| 4033 | 4034 | "serde", |
|
| 4035 | + | "serde_core", |
|
| 4036 | + | ] |
|
| 4037 | + | ||
| 4038 | + | [[package]] |
|
| 4039 | + | name = "serde_rusqlite" |
|
| 4040 | + | version = "0.41.1" |
|
| 4041 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4042 | + | checksum = "5224145f7ea2188165a3ad4e856c4452474683cd7a72dab3325201f673a2b410" |
|
| 4043 | + | dependencies = [ |
|
| 4044 | + | "rusqlite", |
|
| 4034 | 4045 | "serde_core", |
|
| 4035 | 4046 | ] |
|
| 4036 | 4047 | ||
| 26 | 26 | reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } |
|
| 27 | 27 | base64 = "0.22" |
|
| 28 | 28 | image = "0.25" |
|
| 29 | + | serde_rusqlite = "0.41" |
| 99 | 99 | } |
|
| 100 | 100 | ||
| 101 | 101 | fn wine_from_row(row: &rusqlite::Row) -> rusqlite::Result<Wine> { |
|
| 102 | - | Ok(Wine { |
|
| 103 | - | id: row.get(0)?, |
|
| 104 | - | short_id: row.get(1)?, |
|
| 105 | - | name: row.get(2)?, |
|
| 106 | - | origin: row.get(3)?, |
|
| 107 | - | grape: row.get(4)?, |
|
| 108 | - | notes: row.get(5)?, |
|
| 109 | - | has_image: row.get(6)?, |
|
| 110 | - | image_mime: row.get(7)?, |
|
| 111 | - | sweetness: row.get(8)?, |
|
| 112 | - | acidity: row.get(9)?, |
|
| 113 | - | tannin: row.get(10)?, |
|
| 114 | - | alcohol: row.get(11)?, |
|
| 115 | - | body: row.get(12)?, |
|
| 116 | - | clarity: row.get(13)?, |
|
| 117 | - | color_intensity: row.get(14)?, |
|
| 118 | - | aroma_intensity: row.get(15)?, |
|
| 119 | - | nose_complexity: row.get(16)?, |
|
| 120 | - | background: row.get(17)?, |
|
| 121 | - | created_at: row.get(18)?, |
|
| 122 | - | wishlist: row.get::<_, i32>(19)? != 0, |
|
| 102 | + | serde_rusqlite::from_row::<Wine>(row).map_err(|e| { |
|
| 103 | + | rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e)) |
|
| 123 | 104 | }) |
|
| 124 | 105 | } |
|
| 125 | 106 | ||
| 126 | 107 | const WINE_COLUMNS: &str = |
|
| 127 | 108 | "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist"; |
|
| 128 | 109 | ||
| 129 | - | pub fn create_wine( |
|
| 130 | - | db: &Db, |
|
| 131 | - | name: &str, |
|
| 132 | - | origin: &str, |
|
| 133 | - | grape: &str, |
|
| 134 | - | notes: &str, |
|
| 135 | - | image: Option<&[u8]>, |
|
| 136 | - | image_mime: Option<&str>, |
|
| 137 | - | sweetness: i32, |
|
| 138 | - | acidity: i32, |
|
| 139 | - | tannin: i32, |
|
| 140 | - | alcohol: i32, |
|
| 141 | - | body: i32, |
|
| 142 | - | clarity: i32, |
|
| 143 | - | color_intensity: i32, |
|
| 144 | - | aroma_intensity: i32, |
|
| 145 | - | nose_complexity: i32, |
|
| 146 | - | background: &str, |
|
| 147 | - | wishlist: bool, |
|
| 148 | - | ) -> Result<Wine, DbError> { |
|
| 110 | + | #[derive(Serialize)] |
|
| 111 | + | pub struct WineInput<'a> { |
|
| 112 | + | pub name: &'a str, |
|
| 113 | + | pub origin: &'a str, |
|
| 114 | + | pub grape: &'a str, |
|
| 115 | + | pub notes: &'a str, |
|
| 116 | + | pub sweetness: i32, |
|
| 117 | + | pub acidity: i32, |
|
| 118 | + | pub tannin: i32, |
|
| 119 | + | pub alcohol: i32, |
|
| 120 | + | pub body: i32, |
|
| 121 | + | pub clarity: i32, |
|
| 122 | + | pub color_intensity: i32, |
|
| 123 | + | pub aroma_intensity: i32, |
|
| 124 | + | pub nose_complexity: i32, |
|
| 125 | + | pub background: &'a str, |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | pub fn create_wine(db: &Db, input: &WineInput, wishlist: bool) -> Result<Wine, DbError> { |
|
| 149 | 129 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 150 | 130 | let short_id = nanoid!(10); |
|
| 151 | - | let wishlist_int: i32 = if wishlist { 1 } else { 0 }; |
|
| 131 | + | let named = serde_rusqlite::to_params_named(input) |
|
| 132 | + | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 133 | + | let mut bindings = named.to_slice(); |
|
| 134 | + | bindings.push((":short_id", &short_id)); |
|
| 135 | + | bindings.push((":wishlist", &wishlist)); |
|
| 152 | 136 | conn.execute( |
|
| 153 | - | "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist) |
|
| 154 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", |
|
| 155 | - | params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist_int], |
|
| 137 | + | "INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist) |
|
| 138 | + | VALUES (:short_id, :name, :origin, :grape, :notes, :sweetness, :acidity, :tannin, :alcohol, :body, :clarity, :color_intensity, :aroma_intensity, :nose_complexity, :background, :wishlist)", |
|
| 139 | + | bindings.as_slice(), |
|
| 156 | 140 | )?; |
|
| 157 | 141 | let id = conn.last_insert_rowid(); |
|
| 158 | 142 | let wine = conn.query_row( |
|
| 263 | 247 | pub fn update_wine( |
|
| 264 | 248 | db: &Db, |
|
| 265 | 249 | short_id: &str, |
|
| 266 | - | name: &str, |
|
| 267 | - | origin: &str, |
|
| 268 | - | grape: &str, |
|
| 269 | - | notes: &str, |
|
| 270 | - | sweetness: i32, |
|
| 271 | - | acidity: i32, |
|
| 272 | - | tannin: i32, |
|
| 273 | - | alcohol: i32, |
|
| 274 | - | body: i32, |
|
| 275 | - | clarity: i32, |
|
| 276 | - | color_intensity: i32, |
|
| 277 | - | aroma_intensity: i32, |
|
| 278 | - | nose_complexity: i32, |
|
| 279 | - | background: &str, |
|
| 250 | + | input: &WineInput, |
|
| 280 | 251 | ) -> Result<Option<Wine>, DbError> { |
|
| 281 | 252 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 253 | + | let named = serde_rusqlite::to_params_named(input) |
|
| 254 | + | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 255 | + | let mut bindings = named.to_slice(); |
|
| 256 | + | bindings.push((":short_id", &short_id)); |
|
| 282 | 257 | let rows = conn.execute( |
|
| 283 | - | "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, clarity = ?10, color_intensity = ?11, aroma_intensity = ?12, nose_complexity = ?13, background = ?14 WHERE short_id = ?15", |
|
| 284 | - | params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, short_id], |
|
| 258 | + | "UPDATE wines SET name = :name, origin = :origin, grape = :grape, notes = :notes, sweetness = :sweetness, acidity = :acidity, tannin = :tannin, alcohol = :alcohol, body = :body, clarity = :clarity, color_intensity = :color_intensity, aroma_intensity = :aroma_intensity, nose_complexity = :nose_complexity, background = :background WHERE short_id = :short_id", |
|
| 259 | + | bindings.as_slice(), |
|
| 285 | 260 | )?; |
|
| 286 | 261 | if rows == 0 { |
|
| 287 | 262 | return Ok(None); |
|
| 401 | 376 | Arc::new(Mutex::new(conn)) |
|
| 402 | 377 | } |
|
| 403 | 378 | ||
| 379 | + | fn sample_input<'a>(name: &'a str, sweetness: i32) -> WineInput<'a> { |
|
| 380 | + | WineInput { |
|
| 381 | + | name, |
|
| 382 | + | origin: "France", |
|
| 383 | + | grape: "Merlot", |
|
| 384 | + | notes: "Smooth", |
|
| 385 | + | sweetness, |
|
| 386 | + | acidity: 3, |
|
| 387 | + | tannin: 3, |
|
| 388 | + | alcohol: 3, |
|
| 389 | + | body: 3, |
|
| 390 | + | clarity: 3, |
|
| 391 | + | color_intensity: 3, |
|
| 392 | + | aroma_intensity: 3, |
|
| 393 | + | nose_complexity: 3, |
|
| 394 | + | background: "", |
|
| 395 | + | } |
|
| 396 | + | } |
|
| 397 | + | ||
| 404 | 398 | fn create_test_wine(db: &Db, name: &str, wishlist: bool) -> Wine { |
|
| 405 | - | create_wine( |
|
| 406 | - | db, name, "France", "Merlot", "Smooth", None, None, |
|
| 407 | - | 3, 3, 3, 3, 3, 3, 3, 3, 3, "", wishlist, |
|
| 408 | - | ) |
|
| 409 | - | .unwrap() |
|
| 399 | + | create_wine(db, &sample_input(name, 3), wishlist).unwrap() |
|
| 410 | 400 | } |
|
| 411 | 401 | ||
| 412 | 402 | // ── Wine CRUD ────────────────────────────────────────────────────── |
|
| 426 | 416 | #[test] |
|
| 427 | 417 | fn create_wine_invalid_sweetness_fails() { |
|
| 428 | 418 | let db = test_db(); |
|
| 429 | - | let result = create_wine( |
|
| 430 | - | &db, "Bad", "X", "X", "X", None, None, |
|
| 431 | - | 6, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=6 > 5 |
|
| 432 | - | ); |
|
| 419 | + | let result = create_wine(&db, &sample_input("Bad", 6), false); |
|
| 433 | 420 | assert!(result.is_err()); |
|
| 434 | 421 | } |
|
| 435 | 422 | ||
| 436 | 423 | #[test] |
|
| 437 | 424 | fn create_wine_zero_rating_fails() { |
|
| 438 | 425 | let db = test_db(); |
|
| 439 | - | let result = create_wine( |
|
| 440 | - | &db, "Bad", "X", "X", "X", None, None, |
|
| 441 | - | 0, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=0 < 1 |
|
| 442 | - | ); |
|
| 426 | + | let result = create_wine(&db, &sample_input("Bad", 0), false); |
|
| 443 | 427 | assert!(result.is_err()); |
|
| 444 | 428 | } |
|
| 445 | 429 | ||
| 491 | 475 | let db = test_db(); |
|
| 492 | 476 | let wine = create_test_wine(&db, "Old Name", false); |
|
| 493 | 477 | ||
| 494 | - | let updated = update_wine( |
|
| 495 | - | &db, &wine.short_id, "New Name", "Italy", "Sangiovese", "Bold", |
|
| 496 | - | 4, 4, 4, 4, 4, 4, 4, 4, 4, "deep red", |
|
| 497 | - | ) |
|
| 498 | - | .unwrap() |
|
| 499 | - | .unwrap(); |
|
| 478 | + | let input = WineInput { |
|
| 479 | + | name: "New Name", |
|
| 480 | + | origin: "Italy", |
|
| 481 | + | grape: "Sangiovese", |
|
| 482 | + | notes: "Bold", |
|
| 483 | + | sweetness: 4, |
|
| 484 | + | acidity: 4, |
|
| 485 | + | tannin: 4, |
|
| 486 | + | alcohol: 4, |
|
| 487 | + | body: 4, |
|
| 488 | + | clarity: 4, |
|
| 489 | + | color_intensity: 4, |
|
| 490 | + | aroma_intensity: 4, |
|
| 491 | + | nose_complexity: 4, |
|
| 492 | + | background: "deep red", |
|
| 493 | + | }; |
|
| 494 | + | let updated = update_wine(&db, &wine.short_id, &input).unwrap().unwrap(); |
|
| 500 | 495 | ||
| 501 | 496 | assert_eq!(updated.name, "New Name"); |
|
| 502 | 497 | assert_eq!(updated.origin, "Italy"); |
|
| 136 | 136 | } |
|
| 137 | 137 | }; |
|
| 138 | 138 | ||
| 139 | - | match db::create_wine( |
|
| 140 | - | &state.db, |
|
| 141 | - | &data.name, |
|
| 142 | - | &data.origin, |
|
| 143 | - | &data.grape, |
|
| 144 | - | &data.notes, |
|
| 145 | - | data.image.as_deref(), |
|
| 146 | - | data.image_mime.as_deref(), |
|
| 147 | - | data.sweetness, |
|
| 148 | - | data.acidity, |
|
| 149 | - | data.tannin, |
|
| 150 | - | data.alcohol, |
|
| 151 | - | data.body, |
|
| 152 | - | data.clarity, |
|
| 153 | - | data.color_intensity, |
|
| 154 | - | data.aroma_intensity, |
|
| 155 | - | data.nose_complexity, |
|
| 156 | - | &data.background, |
|
| 157 | - | false, |
|
| 158 | - | ) { |
|
| 159 | - | Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(), |
|
| 139 | + | let input = db::WineInput::from(&data); |
|
| 140 | + | match db::create_wine(&state.db, &input, false) { |
|
| 141 | + | Ok(wine) => { |
|
| 142 | + | if let (Some(image), Some(mime)) = (&data.base.image, &data.base.image_mime) { |
|
| 143 | + | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 144 | + | tracing::error!("Failed to set wine image: {}", e); |
|
| 145 | + | } |
|
| 146 | + | } |
|
| 147 | + | Redirect::to(&format!("/wines/{}", wine.short_id)).into_response() |
|
| 148 | + | } |
|
| 160 | 149 | Err(e) => { |
|
| 161 | 150 | tracing::error!("Failed to create wine: {}", e); |
|
| 162 | 151 | Redirect::to("/admin/new?error=Failed+to+create+wine").into_response() |
|
| 182 | 171 | } |
|
| 183 | 172 | }; |
|
| 184 | 173 | ||
| 185 | - | match db::update_wine( |
|
| 186 | - | &state.db, |
|
| 187 | - | &short_id, |
|
| 188 | - | &data.name, |
|
| 189 | - | &data.origin, |
|
| 190 | - | &data.grape, |
|
| 191 | - | &data.notes, |
|
| 192 | - | data.sweetness, |
|
| 193 | - | data.acidity, |
|
| 194 | - | data.tannin, |
|
| 195 | - | data.alcohol, |
|
| 196 | - | data.body, |
|
| 197 | - | data.clarity, |
|
| 198 | - | data.color_intensity, |
|
| 199 | - | data.aroma_intensity, |
|
| 200 | - | data.nose_complexity, |
|
| 201 | - | &data.background, |
|
| 202 | - | ) { |
|
| 174 | + | let input = db::WineInput::from(&data); |
|
| 175 | + | match db::update_wine(&state.db, &short_id, &input) { |
|
| 203 | 176 | Ok(Some(_)) => { |
|
| 204 | - | if let Some(image) = &data.image { |
|
| 205 | - | if let Some(mime) = &data.image_mime { |
|
| 177 | + | if let Some(image) = &data.base.image { |
|
| 178 | + | if let Some(mime) = &data.base.image_mime { |
|
| 206 | 179 | if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) { |
|
| 207 | 180 | tracing::error!("Failed to update wine image: {}", e); |
|
| 208 | 181 | } |
|
| 285 | 258 | } |
|
| 286 | 259 | }; |
|
| 287 | 260 | ||
| 288 | - | match db::create_wine( |
|
| 289 | - | &state.db, |
|
| 290 | - | &data.name, |
|
| 291 | - | &data.origin, |
|
| 292 | - | &data.grape, |
|
| 293 | - | &data.notes, |
|
| 294 | - | data.image.as_deref(), |
|
| 295 | - | data.image_mime.as_deref(), |
|
| 296 | - | 3, 3, 3, 3, 3, 3, 3, 3, 3, |
|
| 297 | - | &data.background, |
|
| 298 | - | true, |
|
| 299 | - | ) { |
|
| 300 | - | Ok(_) => Redirect::to("/wishlist").into_response(), |
|
| 261 | + | let input = db::WineInput { |
|
| 262 | + | name: &data.name, |
|
| 263 | + | origin: &data.origin, |
|
| 264 | + | grape: &data.grape, |
|
| 265 | + | notes: &data.notes, |
|
| 266 | + | sweetness: 3, |
|
| 267 | + | acidity: 3, |
|
| 268 | + | tannin: 3, |
|
| 269 | + | alcohol: 3, |
|
| 270 | + | body: 3, |
|
| 271 | + | clarity: 3, |
|
| 272 | + | color_intensity: 3, |
|
| 273 | + | aroma_intensity: 3, |
|
| 274 | + | nose_complexity: 3, |
|
| 275 | + | background: &data.background, |
|
| 276 | + | }; |
|
| 277 | + | match db::create_wine(&state.db, &input, true) { |
|
| 278 | + | Ok(wine) => { |
|
| 279 | + | if let (Some(image), Some(mime)) = (&data.image, &data.image_mime) { |
|
| 280 | + | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 281 | + | tracing::error!("Failed to set wine image: {}", e); |
|
| 282 | + | } |
|
| 283 | + | } |
|
| 284 | + | Redirect::to("/wishlist").into_response() |
|
| 285 | + | } |
|
| 301 | 286 | Err(e) => { |
|
| 302 | 287 | tracing::error!("Failed to create wishlist wine: {}", e); |
|
| 303 | 288 | Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response() |
|
| 1 | 1 | use askama::Template; |
|
| 2 | 2 | use axum::{ |
|
| 3 | - | extract::{DefaultBodyLimit, Multipart}, |
|
| 3 | + | extract::{multipart::Field, DefaultBodyLimit, Multipart}, |
|
| 4 | 4 | routing::{get, post}, |
|
| 5 | 5 | Router, |
|
| 6 | 6 | }; |
|
| 320 | 320 | ||
| 321 | 321 | // --- Multipart parsing --- |
|
| 322 | 322 | ||
| 323 | - | struct WineFormData { |
|
| 323 | + | #[derive(Default)] |
|
| 324 | + | struct WineBase { |
|
| 324 | 325 | name: String, |
|
| 325 | 326 | origin: String, |
|
| 326 | 327 | grape: String, |
|
| 328 | 329 | background: String, |
|
| 329 | 330 | image: Option<Vec<u8>>, |
|
| 330 | 331 | image_mime: Option<String>, |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | impl WineBase { |
|
| 335 | + | fn owns(field_name: &str) -> bool { |
|
| 336 | + | matches!( |
|
| 337 | + | field_name, |
|
| 338 | + | "image" | "name" | "origin" | "grape" | "notes" | "background" |
|
| 339 | + | ) |
|
| 340 | + | } |
|
| 341 | + | ||
| 342 | + | async fn apply_field(&mut self, field_name: &str, field: Field<'_>) -> Result<(), String> { |
|
| 343 | + | match field_name { |
|
| 344 | + | "image" => { |
|
| 345 | + | let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?; |
|
| 346 | + | if !bytes.is_empty() { |
|
| 347 | + | self.image = Some(process_image(&bytes)?); |
|
| 348 | + | self.image_mime = Some("image/jpeg".to_string()); |
|
| 349 | + | } |
|
| 350 | + | } |
|
| 351 | + | "name" => self.name = field.text().await.unwrap_or_default(), |
|
| 352 | + | "origin" => self.origin = field.text().await.unwrap_or_default(), |
|
| 353 | + | "grape" => self.grape = field.text().await.unwrap_or_default(), |
|
| 354 | + | "notes" => self.notes = field.text().await.unwrap_or_default(), |
|
| 355 | + | "background" => self.background = field.text().await.unwrap_or_default(), |
|
| 356 | + | _ => {} |
|
| 357 | + | } |
|
| 358 | + | Ok(()) |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | fn finalize(&mut self) -> Result<(), String> { |
|
| 362 | + | if self.name.trim().is_empty() { |
|
| 363 | + | return Err("Name is required".to_string()); |
|
| 364 | + | } |
|
| 365 | + | self.name = self.name.trim().to_string(); |
|
| 366 | + | self.origin = self.origin.trim().to_string(); |
|
| 367 | + | self.grape = self.grape.trim().to_string(); |
|
| 368 | + | self.notes = self.notes.trim().to_string(); |
|
| 369 | + | self.background = self.background.trim().to_string(); |
|
| 370 | + | Ok(()) |
|
| 371 | + | } |
|
| 372 | + | } |
|
| 373 | + | ||
| 374 | + | type WishlistFormData = WineBase; |
|
| 375 | + | ||
| 376 | + | struct WineScores { |
|
| 331 | 377 | sweetness: i32, |
|
| 332 | 378 | acidity: i32, |
|
| 333 | 379 | tannin: i32, |
|
| 339 | 385 | nose_complexity: i32, |
|
| 340 | 386 | } |
|
| 341 | 387 | ||
| 342 | - | async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> { |
|
| 343 | - | let mut name = String::new(); |
|
| 344 | - | let mut origin = String::new(); |
|
| 345 | - | let mut grape = String::new(); |
|
| 346 | - | let mut notes = String::new(); |
|
| 347 | - | let mut background = String::new(); |
|
| 348 | - | let mut image: Option<Vec<u8>> = None; |
|
| 349 | - | let mut image_mime: Option<String> = None; |
|
| 350 | - | let mut sweetness = 3; |
|
| 351 | - | let mut acidity = 3; |
|
| 352 | - | let mut tannin = 3; |
|
| 353 | - | let mut alcohol = 3; |
|
| 354 | - | let mut body = 3; |
|
| 355 | - | let mut clarity = 3; |
|
| 356 | - | let mut color_intensity = 3; |
|
| 357 | - | let mut aroma_intensity = 3; |
|
| 358 | - | let mut nose_complexity = 3; |
|
| 388 | + | impl Default for WineScores { |
|
| 389 | + | fn default() -> Self { |
|
| 390 | + | Self { |
|
| 391 | + | sweetness: 3, |
|
| 392 | + | acidity: 3, |
|
| 393 | + | tannin: 3, |
|
| 394 | + | alcohol: 3, |
|
| 395 | + | body: 3, |
|
| 396 | + | clarity: 3, |
|
| 397 | + | color_intensity: 3, |
|
| 398 | + | aroma_intensity: 3, |
|
| 399 | + | nose_complexity: 3, |
|
| 400 | + | } |
|
| 401 | + | } |
|
| 402 | + | } |
|
| 359 | 403 | ||
| 360 | - | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 361 | - | let field_name = field.name().unwrap_or("").to_string(); |
|
| 362 | - | match field_name.as_str() { |
|
| 363 | - | "image" => { |
|
| 364 | - | let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?; |
|
| 365 | - | if !bytes.is_empty() { |
|
| 366 | - | let processed = process_image(&bytes)?; |
|
| 367 | - | image = Some(processed); |
|
| 368 | - | image_mime = Some("image/jpeg".to_string()); |
|
| 369 | - | } |
|
| 370 | - | } |
|
| 371 | - | "name" => name = field.text().await.unwrap_or_default(), |
|
| 372 | - | "origin" => origin = field.text().await.unwrap_or_default(), |
|
| 373 | - | "grape" => grape = field.text().await.unwrap_or_default(), |
|
| 374 | - | "notes" => notes = field.text().await.unwrap_or_default(), |
|
| 375 | - | "background" => background = field.text().await.unwrap_or_default(), |
|
| 376 | - | "sweetness" => sweetness = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 377 | - | "acidity" => acidity = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 378 | - | "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 379 | - | "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 380 | - | "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 381 | - | "clarity" => clarity = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 382 | - | "color_intensity" => color_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 383 | - | "aroma_intensity" => aroma_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 384 | - | "nose_complexity" => nose_complexity = field.text().await.unwrap_or_default().parse().unwrap_or(3), |
|
| 385 | - | _ => {} |
|
| 386 | - | } |
|
| 404 | + | impl WineScores { |
|
| 405 | + | fn slot(&mut self, field_name: &str) -> Option<&mut i32> { |
|
| 406 | + | Some(match field_name { |
|
| 407 | + | "sweetness" => &mut self.sweetness, |
|
| 408 | + | "acidity" => &mut self.acidity, |
|
| 409 | + | "tannin" => &mut self.tannin, |
|
| 410 | + | "alcohol" => &mut self.alcohol, |
|
| 411 | + | "body" => &mut self.body, |
|
| 412 | + | "clarity" => &mut self.clarity, |
|
| 413 | + | "color_intensity" => &mut self.color_intensity, |
|
| 414 | + | "aroma_intensity" => &mut self.aroma_intensity, |
|
| 415 | + | "nose_complexity" => &mut self.nose_complexity, |
|
| 416 | + | _ => return None, |
|
| 417 | + | }) |
|
| 387 | 418 | } |
|
| 388 | 419 | ||
| 389 | - | if name.trim().is_empty() { |
|
| 390 | - | return Err("Name is required".to_string()); |
|
| 420 | + | fn clamp_all(&mut self) { |
|
| 421 | + | for v in [ |
|
| 422 | + | &mut self.sweetness, |
|
| 423 | + | &mut self.acidity, |
|
| 424 | + | &mut self.tannin, |
|
| 425 | + | &mut self.alcohol, |
|
| 426 | + | &mut self.body, |
|
| 427 | + | &mut self.clarity, |
|
| 428 | + | &mut self.color_intensity, |
|
| 429 | + | &mut self.aroma_intensity, |
|
| 430 | + | &mut self.nose_complexity, |
|
| 431 | + | ] { |
|
| 432 | + | *v = (*v).clamp(1, 5); |
|
| 433 | + | } |
|
| 391 | 434 | } |
|
| 435 | + | } |
|
| 392 | 436 | ||
| 393 | - | let clamp = |v: i32| v.max(1).min(5); |
|
| 394 | - | Ok(WineFormData { |
|
| 395 | - | name: name.trim().to_string(), |
|
| 396 | - | origin: origin.trim().to_string(), |
|
| 397 | - | grape: grape.trim().to_string(), |
|
| 398 | - | notes: notes.trim().to_string(), |
|
| 399 | - | background: background.trim().to_string(), |
|
| 400 | - | image, |
|
| 401 | - | image_mime, |
|
| 402 | - | sweetness: clamp(sweetness), |
|
| 403 | - | acidity: clamp(acidity), |
|
| 404 | - | tannin: clamp(tannin), |
|
| 405 | - | alcohol: clamp(alcohol), |
|
| 406 | - | body: clamp(body), |
|
| 407 | - | clarity: clamp(clarity), |
|
| 408 | - | color_intensity: clamp(color_intensity), |
|
| 409 | - | aroma_intensity: clamp(aroma_intensity), |
|
| 410 | - | nose_complexity: clamp(nose_complexity), |
|
| 411 | - | }) |
|
| 437 | + | struct WineFormData { |
|
| 438 | + | base: WineBase, |
|
| 439 | + | scores: WineScores, |
|
| 412 | 440 | } |
|
| 413 | 441 | ||
| 414 | - | struct WishlistFormData { |
|
| 415 | - | name: String, |
|
| 416 | - | origin: String, |
|
| 417 | - | grape: String, |
|
| 418 | - | notes: String, |
|
| 419 | - | background: String, |
|
| 420 | - | image: Option<Vec<u8>>, |
|
| 421 | - | image_mime: Option<String>, |
|
| 442 | + | impl<'a> From<&'a WineFormData> for crate::db::WineInput<'a> { |
|
| 443 | + | fn from(data: &'a WineFormData) -> Self { |
|
| 444 | + | Self { |
|
| 445 | + | name: &data.base.name, |
|
| 446 | + | origin: &data.base.origin, |
|
| 447 | + | grape: &data.base.grape, |
|
| 448 | + | notes: &data.base.notes, |
|
| 449 | + | sweetness: data.scores.sweetness, |
|
| 450 | + | acidity: data.scores.acidity, |
|
| 451 | + | tannin: data.scores.tannin, |
|
| 452 | + | alcohol: data.scores.alcohol, |
|
| 453 | + | body: data.scores.body, |
|
| 454 | + | clarity: data.scores.clarity, |
|
| 455 | + | color_intensity: data.scores.color_intensity, |
|
| 456 | + | aroma_intensity: data.scores.aroma_intensity, |
|
| 457 | + | nose_complexity: data.scores.nose_complexity, |
|
| 458 | + | background: &data.base.background, |
|
| 459 | + | } |
|
| 460 | + | } |
|
| 422 | 461 | } |
|
| 423 | 462 | ||
| 424 | - | async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> { |
|
| 425 | - | let mut name = String::new(); |
|
| 426 | - | let mut origin = String::new(); |
|
| 427 | - | let mut grape = String::new(); |
|
| 428 | - | let mut notes = String::new(); |
|
| 429 | - | let mut background = String::new(); |
|
| 430 | - | let mut image: Option<Vec<u8>> = None; |
|
| 431 | - | let mut image_mime: Option<String> = None; |
|
| 463 | + | async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> { |
|
| 464 | + | let mut base = WineBase::default(); |
|
| 465 | + | let mut scores = WineScores::default(); |
|
| 432 | 466 | ||
| 433 | 467 | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 434 | 468 | let field_name = field.name().unwrap_or("").to_string(); |
|
| 435 | - | match field_name.as_str() { |
|
| 436 | - | "image" => { |
|
| 437 | - | let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?; |
|
| 438 | - | if !bytes.is_empty() { |
|
| 439 | - | let processed = process_image(&bytes)?; |
|
| 440 | - | image = Some(processed); |
|
| 441 | - | image_mime = Some("image/jpeg".to_string()); |
|
| 442 | - | } |
|
| 443 | - | } |
|
| 444 | - | "name" => name = field.text().await.unwrap_or_default(), |
|
| 445 | - | "origin" => origin = field.text().await.unwrap_or_default(), |
|
| 446 | - | "grape" => grape = field.text().await.unwrap_or_default(), |
|
| 447 | - | "notes" => notes = field.text().await.unwrap_or_default(), |
|
| 448 | - | "background" => background = field.text().await.unwrap_or_default(), |
|
| 449 | - | _ => {} |
|
| 469 | + | if WineBase::owns(&field_name) { |
|
| 470 | + | base.apply_field(&field_name, field).await?; |
|
| 471 | + | } else if let Some(slot) = scores.slot(&field_name) { |
|
| 472 | + | *slot = field.text().await.unwrap_or_default().parse().unwrap_or(3); |
|
| 450 | 473 | } |
|
| 451 | 474 | } |
|
| 452 | 475 | ||
| 453 | - | if name.trim().is_empty() { |
|
| 454 | - | return Err("Name is required".to_string()); |
|
| 455 | - | } |
|
| 476 | + | base.finalize()?; |
|
| 477 | + | scores.clamp_all(); |
|
| 478 | + | Ok(WineFormData { base, scores }) |
|
| 479 | + | } |
|
| 456 | 480 | ||
| 457 | - | Ok(WishlistFormData { |
|
| 458 | - | name: name.trim().to_string(), |
|
| 459 | - | origin: origin.trim().to_string(), |
|
| 460 | - | grape: grape.trim().to_string(), |
|
| 461 | - | notes: notes.trim().to_string(), |
|
| 462 | - | background: background.trim().to_string(), |
|
| 463 | - | image, |
|
| 464 | - | image_mime, |
|
| 465 | - | }) |
|
| 481 | + | async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> { |
|
| 482 | + | let mut base = WineBase::default(); |
|
| 483 | + | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 484 | + | let field_name = field.name().unwrap_or("").to_string(); |
|
| 485 | + | base.apply_field(&field_name, field).await?; |
|
| 486 | + | } |
|
| 487 | + | base.finalize()?; |
|
| 488 | + | Ok(base) |
|
| 466 | 489 | } |
|
| 467 | 490 | ||
| 468 | 491 | // --- Router --- |
|