1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 13:35:23 +02:00

feat: Query Filter Builder for Cookbooks and Meal Plans (#4346)

This commit is contained in:
Michael Genson 2024-10-17 10:35:39 -05:00 committed by GitHub
parent 2a9a6fa5e6
commit b8e62ab8dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2043 additions and 440 deletions

View file

@ -178,7 +178,7 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
database.recipes.update_many([public_recipe, private_recipe])
# Create a recipe in another household that's public with the same known tag
# Create a public and private recipe with a known tag in another household
other_database = h2_user.repos
other_household = other_database.households.get_one(h2_user.household_id)
assert other_household and other_household.preferences
@ -187,17 +187,24 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
other_household.preferences.recipe_public = True
other_database.household_preferences.update(household.id, household.preferences)
other_household_recipe = other_database.recipes.create(
other_household_public_recipe, other_household_private_recipe = database.recipes.create_many(
Recipe(
user_id=h2_user.user_id,
group_id=h2_user.group_id,
name=random_string(),
)
for _ in range(2)
)
assert other_household_recipe.settings
other_household_recipe.settings.public = True
other_household_recipe.tags = [tag]
other_database.recipes.update(other_household_recipe.slug, other_household_recipe)
assert other_household_public_recipe.settings
other_household_public_recipe.settings.public = True
other_household_public_recipe.tags = [tag]
assert other_household_private_recipe.settings
other_household_private_recipe.settings.public = False
other_household_private_recipe.tags = [tag]
other_database.recipes.update_many([other_household_public_recipe, other_household_private_recipe])
# Create a public cookbook with tag
cookbook = database.cookbooks.create(
@ -206,11 +213,91 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
group_id=unique_user.group_id,
household_id=unique_user.household_id,
public=True,
tags=[tag],
query_filter_string=f'tags.id IN ["{tag.id}"]',
)
)
# Get the cookbook and make sure we only get the public recipe from the correct household
# Get the cookbook and make sure we only get the public recipes from each household
response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id))
assert response.status_code == 200
cookbook_data = response.json()
assert cookbook_data["id"] == str(cookbook.id)
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
assert len(cookbook_recipe_ids) == 2
assert str(public_recipe.id) in cookbook_recipe_ids
assert str(private_recipe.id) not in cookbook_recipe_ids
assert str(other_household_public_recipe.id) in cookbook_recipe_ids
assert str(other_household_private_recipe.id) not in cookbook_recipe_ids
def test_get_cookbooks_private_household(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
database = unique_user.repos
# Create a public recipe with a known tag
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = False
database.group_preferences.update(group.id, group.preferences)
household = database.households.get_one(unique_user.household_id)
assert household and household.preferences
household.preferences.private_household = False
household.preferences.recipe_public = True
database.household_preferences.update(household.id, household.preferences)
tag = database.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
public_recipe = database.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
)
assert public_recipe.settings
public_recipe.settings.public = True
public_recipe.tags = [tag]
database.recipes.update(public_recipe.slug, public_recipe)
# Create a public recipe with a known tag on a private household
other_database = h2_user.repos
other_household = other_database.households.get_one(h2_user.household_id)
assert other_household and other_household.preferences
other_household.preferences.private_household = True
other_household.preferences.recipe_public = True
other_database.household_preferences.update(household.id, household.preferences)
other_household_private_recipe = database.recipes.create(
Recipe(
user_id=h2_user.user_id,
group_id=h2_user.group_id,
name=random_string(),
)
)
assert other_household_private_recipe.settings
other_household_private_recipe.settings.public = False
other_household_private_recipe.tags = [tag]
other_database.recipes.update(other_household_private_recipe.slug, other_household_private_recipe)
# Create a public cookbook with tag
cookbook = database.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=unique_user.group_id,
household_id=unique_user.household_id,
public=True,
query_filter_string=f'tags.id IN ["{tag.id}"]',
)
)
# Get the cookbook and make sure we only get the public recipes from each household
response = api_client.get(api_routes.explore_groups_group_slug_cookbooks_item_id(unique_user.group_id, cookbook.id))
assert response.status_code == 200
cookbook_data = response.json()
@ -219,5 +306,4 @@ def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUse
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
assert len(cookbook_recipe_ids) == 1
assert str(public_recipe.id) in cookbook_recipe_ids
assert str(private_recipe.id) not in cookbook_recipe_ids
assert str(other_household_recipe.id) not in cookbook_recipe_ids
assert str(other_household_private_recipe.id) not in cookbook_recipe_ids

View file

@ -244,8 +244,12 @@ def test_public_recipe_cookbook_filter(
assert response.status_code == 200
def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
@pytest.mark.parametrize("other_household_private", [True, False])
def test_public_recipe_cookbook_filter_with_recipes(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, other_household_private: bool
):
database = unique_user.repos
database.session.rollback()
# Create a public and private recipe with a known tag
group = database.groups.get_one(unique_user.group_id)
@ -281,14 +285,14 @@ def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, uniq
database.recipes.update_many([public_recipe, private_recipe])
# Create a recipe in another household that's public with the same known tag
# Create a recipe in another household with the same known tag
other_database = h2_user.repos
other_household = other_database.households.get_one(h2_user.household_id)
assert other_household and other_household.preferences
other_household.preferences.private_household = False
other_household.preferences.private_household = other_household_private
other_household.preferences.recipe_public = True
other_database.household_preferences.update(household.id, household.preferences)
other_database.household_preferences.update(other_household.id, other_household.preferences)
other_household_recipe = other_database.recipes.create(
Recipe(
@ -309,17 +313,25 @@ def test_public_recipe_cookbook_filter_with_recipes(api_client: TestClient, uniq
group_id=unique_user.group_id,
household_id=unique_user.household_id,
public=True,
tags=[tag],
query_filter_string=f'tags.id IN ["{tag.id}"]',
)
)
# Get the cookbook's recipes and make sure we only get the public recipe from the correct household
# Get the cookbook's recipes and make sure we get both public recipes
response = api_client.get(
api_routes.explore_groups_group_slug_recipes(unique_user.group_id), params={"cookbook": cookbook.id}
)
assert response.status_code == 200
recipe_ids: set[str] = {recipe["id"] for recipe in response.json()["items"]}
assert len(recipe_ids) == 1
if other_household_private:
assert len(recipe_ids) == 1
else:
assert len(recipe_ids) == 2
assert str(public_recipe.id) in recipe_ids
assert str(private_recipe.id) not in recipe_ids
assert str(other_household_recipe.id) not in recipe_ids
if other_household_private:
assert str(other_household_recipe.id) not in recipe_ids
else:
assert str(other_household_recipe.id) in recipe_ids

View file

@ -21,7 +21,7 @@ def get_page_data(group_id: UUID | str, household_id: UUID4 | str):
"slug": name_and_slug,
"description": "",
"position": 0,
"categories": [],
"query_filter_string": "",
"group_id": str(group_id),
"household_id": str(household_id),
}
@ -143,3 +143,42 @@ def test_delete_cookbook(api_client: TestClient, unique_user: TestUser, cookbook
response = api_client.get(api_routes.households_cookbooks_item_id(sample.slug), headers=unique_user.token)
assert response.status_code == 404
@pytest.mark.parametrize(
"qf_string, expected_code",
[
('tags.name CONTAINS ALL ["tag1","tag2"]', 200),
('badfield = "badvalue"', 422),
('recipe_category.id IN ["1"]', 422),
('created_at >= "not-a-date"', 422),
],
ids=[
"valid qf",
"invalid field",
"invalid UUID",
"invalid date",
],
)
def test_cookbook_validate_query_filter_string(
api_client: TestClient, unique_user: TestUser, qf_string: str, expected_code: int
):
# Create
cb_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": qf_string}
response = api_client.post(api_routes.households_cookbooks, json=cb_data, headers=unique_user.token)
assert response.status_code == expected_code if expected_code != 200 else 201
# Update
cb_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": ""}
response = api_client.post(api_routes.households_cookbooks, json=cb_data, headers=unique_user.token)
assert response.status_code == 201
cb_data = response.json()
cb_data["queryFilterString"] = qf_string
response = api_client.put(
api_routes.households_cookbooks_item_id(cb_data["id"]), json=cb_data, headers=unique_user.token
)
assert response.status_code == expected_code if expected_code != 201 else 200
# Out; should skip validation, so this should never error out
ReadCookBook(**cb_data)

View file

@ -40,15 +40,22 @@ def create_rule(
categories: list[CategoryOut] | None = None,
households: list[HouseholdSummary] | None = None,
):
qf_parts: list[str] = []
if tags:
qf_parts.append(f'tags.id CONTAINS ALL [{",".join([str(tag.id) for tag in tags])}]')
if categories:
qf_parts.append(f'recipe_category.id CONTAINS ALL [{",".join([str(cat.id) for cat in categories])}]')
if households:
qf_parts.append(f'household_id IN [{",".join([str(household.id) for household in households])}]')
query_filter_string = " AND ".join(qf_parts)
return unique_user.repos.group_meal_plan_rules.create(
PlanRulesSave(
group_id=UUID(unique_user.group_id),
household_id=UUID(unique_user.household_id),
day=day,
entry_type=entry_type,
tags=tags or [],
categories=categories or [],
households=households or [],
query_filter_string=query_filter_string,
)
)

View file

@ -8,6 +8,7 @@ from mealie.schema.recipe.recipe import RecipeCategory
from mealie.schema.recipe.recipe_category import CategorySave
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@ -32,7 +33,7 @@ def plan_rule(api_client: TestClient, unique_user: TestUser):
"householdId": unique_user.household_id,
"day": "monday",
"entryType": "breakfast",
"categories": [],
"queryFilterString": "",
}
response = api_client.post(
@ -48,12 +49,13 @@ def plan_rule(api_client: TestClient, unique_user: TestUser):
def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUser, category: RecipeCategory):
database = unique_user.repos
query_filter_string = f'recipe_category.id IN ["{category.id}"]'
payload = {
"groupId": unique_user.group_id,
"householdId": unique_user.household_id,
"day": "monday",
"entryType": "breakfast",
"categories": [category.model_dump()],
"queryFilterString": query_filter_string,
}
response = api_client.post(
@ -67,8 +69,8 @@ def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUs
assert response_data["householdId"] == str(unique_user.household_id)
assert response_data["day"] == "monday"
assert response_data["entryType"] == "breakfast"
assert len(response_data["categories"]) == 1
assert response_data["categories"][0]["slug"] == category.slug
assert len(response_data["queryFilter"]["parts"]) == 1
assert response_data["queryFilter"]["parts"][0]["value"] == [str(category.id)]
# Validate database entry
rule = database.group_meal_plan_rules.get_one(UUID(response_data["id"]))
@ -78,8 +80,7 @@ def test_group_mealplan_rules_create(api_client: TestClient, unique_user: TestUs
assert str(rule.household_id) == unique_user.household_id
assert rule.day == "monday"
assert rule.entry_type == "breakfast"
assert len(rule.categories) == 1
assert rule.categories[0].slug == category.slug
assert rule.query_filter_string == query_filter_string
# Cleanup
database.group_meal_plan_rules.delete(rule.id)
@ -96,7 +97,8 @@ def test_group_mealplan_rules_read(api_client: TestClient, unique_user: TestUser
assert response_data["householdId"] == str(unique_user.household_id)
assert response_data["day"] == "monday"
assert response_data["entryType"] == "breakfast"
assert len(response_data["categories"]) == 0
assert response_data["queryFilterString"] == ""
assert len(response_data["queryFilter"]["parts"]) == 0
def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
@ -119,7 +121,8 @@ def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUs
assert response_data["householdId"] == str(unique_user.household_id)
assert response_data["day"] == "tuesday"
assert response_data["entryType"] == "lunch"
assert len(response_data["categories"]) == 0
assert response_data["queryFilterString"] == ""
assert len(response_data["queryFilter"]["parts"]) == 0
def test_group_mealplan_rules_delete(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut):
@ -131,3 +134,42 @@ def test_group_mealplan_rules_delete(api_client: TestClient, unique_user: TestUs
response = api_client.get(api_routes.households_mealplans_rules_item_id(plan_rule.id), headers=unique_user.token)
assert response.status_code == 404
@pytest.mark.parametrize(
"qf_string, expected_code",
[
('tags.name CONTAINS ALL ["tag1","tag2"]', 200),
('badfield = "badvalue"', 422),
('recipe_category.id IN ["1"]', 422),
('created_at >= "not-a-date"', 422),
],
ids=[
"valid qf",
"invalid field",
"invalid UUID",
"invalid date",
],
)
def test_group_mealplan_rules_validate_query_filter_string(
api_client: TestClient, unique_user: TestUser, qf_string: str, expected_code: int
):
# Create
rule_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": qf_string}
response = api_client.post(api_routes.households_mealplans_rules, json=rule_data, headers=unique_user.token)
assert response.status_code == expected_code if expected_code != 200 else 201
# Update
rule_data = {"name": random_string(10), "slug": random_string(10), "query_filter_string": ""}
response = api_client.post(api_routes.households_mealplans_rules, json=rule_data, headers=unique_user.token)
assert response.status_code == 201
rule_data = response.json()
rule_data["queryFilterString"] = qf_string
response = api_client.put(
api_routes.households_mealplans_rules_item_id(rule_data["id"]), json=rule_data, headers=unique_user.token
)
assert response.status_code == expected_code if expected_code != 201 else 200
# Out; should skip validation, so this should never error out
PlanRulesOut(**rule_data)

View file

@ -257,9 +257,7 @@ def test_user_can_update_last_made_on_other_household(
assert new_last_made == now != old_last_made
def test_cookbook_recipes_only_includes_current_households(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
):
def test_cookbook_recipes_includes_all_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
recipes = unique_user.repos.recipes.create_many(
[
@ -300,4 +298,4 @@ def test_cookbook_recipes_only_includes_current_households(
for recipe in recipes:
assert recipe.id in fetched_recipe_ids
for recipe in other_recipes:
assert recipe.id not in fetched_recipe_ids
assert recipe.id in fetched_recipe_ids

View file

@ -858,7 +858,7 @@ def test_get_cookbook_recipes(api_client: TestClient, unique_user: utils.TestUse
name=random_string(),
group_id=unique_user.group_id,
household_id=unique_user.household_id,
tags=[tag],
query_filter_string=f'tags.id IN ["{tag.id}"]',
)
)