Calendar (WIP)

This commit is contained in:
Greg Burri 2025-01-29 14:37:25 +01:00
parent 9d3f9e9c60
commit 79a0aeb1b8
24 changed files with 613 additions and 231 deletions

View file

@ -1,10 +1,9 @@
use chrono::prelude::*;
use chrono::{prelude::*, Days};
use common::ron_api::Difficulty;
use itertools::Itertools;
use super::{Connection, DBError, Result};
use crate::data::model;
use common::ron_api::Difficulty;
use crate::{data::model, user_authentication};
impl Connection {
/// Returns all the recipe titles where recipe is written in the given language.
@ -106,11 +105,10 @@ SELECT COUNT(*)
FROM [Recipe]
INNER JOIN [User] ON [User].[id] = [Recipe].[user_id]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Group].[id] IN ({}) AND ([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
WHERE [Group].[id] IN ({}) AND ([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in group_ids {
query = query.bind(id);
@ -147,11 +145,10 @@ FROM [Recipe]
INNER JOIN [User] ON [User].[id] = [Recipe].[user_id]
INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
WHERE [Step].[id] IN ({}) AND ([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
WHERE [Step].[id] IN ({}) AND ([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in steps_ids {
query = query.bind(id);
@ -199,11 +196,10 @@ INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
INNER JOIN [Ingredient] ON [Ingredient].[step_id] = [Step].[id]
WHERE [Ingredient].[id] IN ({}) AND
([user_id] = $2 OR (SELECT [is_admin] FROM [User] WHERE [id] = $2))
([user_id] = $1 OR (SELECT [is_admin] FROM [User] WHERE [id] = $1))
"#,
params
);
let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
for id in ingredients_ids {
query = query.bind(id);
@ -755,6 +751,73 @@ VALUES ($1, $2)
Ok(())
}
pub async fn add_schedule_recipe(
&self,
user_id: i64,
recipe_id: i64,
date: NaiveDate,
servings: u32,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO [RecipeScheduled] (user_id, recipe_id, date, servings)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(user_id)
.bind(recipe_id)
.bind(date)
.bind(servings)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn remove_scheduled_recipe(
&self,
user_id: i64,
recipe_id: i64,
date: NaiveDate,
) -> Result<()> {
sqlx::query(
r#"
DELETE FROM [RecipeScheduled]
WHERE [user_id] = $1 AND [recipe_id] = $2 AND [date] = $3
"#,
)
.bind(user_id)
.bind(recipe_id)
.bind(date)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn get_scheduled_recipes(
&self,
user_id: i64,
start_date: NaiveDate,
end_date: NaiveDate,
) -> Result<Vec<(NaiveDate, String, i64)>> {
sqlx::query_as(
r#"
SELECT [date], [Recipe].[title], [Recipe].[id], [RecipeScheduled].[date]
FROM [RecipeScheduled]
INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeScheduled].[recipe_id]
WHERE [RecipeScheduled].[user_id] = $1 AND [date] >= $2 AND [date] <= $3
ORDER BY [date]
"#,
)
.bind(user_id)
.bind(start_date)
.bind(end_date)
.fetch_all(&self.pool)
.await
.map_err(DBError::from)
}
}
#[cfg(test)]
@ -884,4 +947,83 @@ VALUES
Ok(())
}
#[tokio::test]
async fn schedule_recipe() -> Result<()> {
let connection = Connection::new_in_memory().await?;
let user_id = create_a_user(&connection).await?;
let recipe_id_1 = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id_1, "recipe 1").await?;
let recipe_id_2 = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id_2, "recipe 2").await?;
let today = NaiveDate::from_ymd_opt(2025, 1, 23).unwrap();
let yesterday = today - Days::new(1);
let tomorrow = today + Days::new(1);
connection
.add_schedule_recipe(user_id, recipe_id_1, today, 4)
.await?;
connection
.add_schedule_recipe(user_id, recipe_id_2, yesterday, 4)
.await?;
connection
.add_schedule_recipe(user_id, recipe_id_1, tomorrow, 4)
.await?;
assert_eq!(
connection
.get_scheduled_recipes(user_id, today, today)
.await?,
vec![(
NaiveDate::from_ymd_opt(2025, 1, 23).unwrap(),
"recipe 1".to_string(),
1
)]
);
assert_eq!(
connection
.get_scheduled_recipes(user_id, yesterday, tomorrow)
.await?,
vec![
(
NaiveDate::from_ymd_opt(2025, 1, 22).unwrap(),
"recipe 2".to_string(),
2
),
(
NaiveDate::from_ymd_opt(2025, 1, 23).unwrap(),
"recipe 1".to_string(),
1
),
(
NaiveDate::from_ymd_opt(2025, 1, 24).unwrap(),
"recipe 1".to_string(),
1
)
]
);
connection
.remove_scheduled_recipe(user_id, recipe_id_1, today)
.await?;
connection
.remove_scheduled_recipe(user_id, recipe_id_2, yesterday)
.await?;
connection
.remove_scheduled_recipe(user_id, recipe_id_1, tomorrow)
.await?;
assert_eq!(
connection
.get_scheduled_recipes(user_id, yesterday, tomorrow)
.await?,
vec![]
);
Ok(())
}
}

View file

@ -1,5 +1,5 @@
use chrono::{prelude::*, Duration};
use rand::distributions::{Alphanumeric, DistString};
use rand::distr::{Alphanumeric, SampleString};
use sqlx::Sqlite;
use super::{Connection, DBError, Result};
@ -57,7 +57,7 @@ pub enum ResetPasswordResult {
}
fn generate_token() -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
Alphanumeric.sample_string(&mut rand::rng(), consts::TOKEN_SIZE)
}
impl Connection {

View file

@ -177,6 +177,10 @@ async fn main() {
"/recipe/set_ingredients_order",
put(services::ron::set_ingredients_order),
)
.route(
"/calendar/get_scheduled_recipes",
get(services::ron::get_scheduled_recipes),
)
.fallback(services::ron::not_found);
let fragments_routes = Router::new().route(

View file

@ -5,6 +5,7 @@ use axum::{
response::{ErrorResponse, IntoResponse, Result},
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::NaiveDate;
use serde::Deserialize;
// use tracing::{event, Level};
@ -183,11 +184,11 @@ async fn check_user_rights_recipe_ingredient(
async fn check_user_rights_recipe_ingredients(
connection: &db::Connection,
user: &Option<model::User>,
step_ids: &[i64],
ingredient_ids: &[i64],
) -> Result<()> {
if user.is_none()
|| !connection
.can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, step_ids)
.can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, ingredient_ids)
.await?
{
Err(ErrorResponse::from(ron_error(
@ -599,7 +600,39 @@ pub async fn set_ingredients_order(
Ok(StatusCode::OK)
}
///// 404 /////
/// Calendar ///
#[derive(Deserialize)]
pub struct DateRange {
start_date: NaiveDate,
end_date: NaiveDate,
}
#[debug_handler]
pub async fn get_scheduled_recipes(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
date_range: Query<DateRange>,
) -> Result<impl IntoResponse> {
if let Some(user) = user {
Ok(ron_response(
StatusCode::OK,
common::ron_api::ScheduledRecipes {
recipes: connection
.get_scheduled_recipes(user.id, date_range.start_date, date_range.end_date)
.await?,
},
))
} else {
Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
NOT_AUTHORIZED_MESSAGE,
)))
}
}
/// 404 ///
#[debug_handler]
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
ron_error(StatusCode::NOT_FOUND, "Not found")

View file

@ -1,12 +1,13 @@
use std::{borrow::Borrow, fs::File, sync::LazyLock};
use common::utils;
use ron::de::from_reader;
use serde::Deserialize;
use strum::EnumCount;
use strum_macros::EnumCount;
use tracing::{event, Level};
use crate::{consts, utils};
use crate::consts;
#[derive(Debug, Clone, EnumCount, Deserialize)]
pub enum Sentence {
@ -109,6 +110,10 @@ pub enum Sentence {
RecipeIngredientQuantity,
RecipeIngredientUnit,
RecipeIngredientComment,
RecipeDeleteConfirmation,
RecipeGroupDeleteConfirmation,
RecipeStepDeleteConfirmation,
RecipeIngredientDeleteConfirmation,
// View Recipe.
RecipeOneServing,

View file

@ -39,44 +39,3 @@ pub fn get_url_from_host(host: &str) -> String {
host
)
}
pub fn substitute(str: &str, pattern: &str, replacements: &[&str]) -> String {
let mut result = String::with_capacity(
(str.len() + replacements.iter().map(|s| s.len()).sum::<usize>())
.saturating_sub(pattern.len() * replacements.len()),
);
let mut i = 0;
for s in str.split(pattern) {
result.push_str(s);
if i < replacements.len() {
result.push_str(replacements[i]);
}
i += 1;
}
if i == 1 {
return str.to_string();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute() {
assert_eq!(substitute("", "", &[]), "");
assert_eq!(substitute("", "", &[""]), "");
assert_eq!(substitute("", "{}", &["a"]), "");
assert_eq!(substitute("a", "{}", &["b"]), "a");
assert_eq!(substitute("a{}", "{}", &["b"]), "ab");
assert_eq!(substitute("{}c", "{}", &["b"]), "bc");
assert_eq!(substitute("a{}c", "{}", &["b"]), "abc");
assert_eq!(substitute("{}b{}", "{}", &["a", "c"]), "abc");
assert_eq!(substitute("{}{}{}", "{}", &["a", "bc", "def"]), "abcdef");
assert_eq!(substitute("{}{}{}", "{}", &["a"]), "a");
}
}