Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding the rest ofthe nutrition properties from schema.org #4301

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""'add the rest of the schema.org nutrition properties'

Revision ID: 602927e1013e
Revises: 1fe4bd37ccc8
Create Date: 2024-10-01 14:17:00.611398

"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "602927e1013e"
down_revision: str | None = "1fe4bd37ccc8"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op:
batch_op.add_column(sa.Column("cholesterol_content", sa.String(), nullable=True))
batch_op.add_column(sa.Column("saturated_fat_content", sa.String(), nullable=True))
batch_op.add_column(sa.Column("trans_fat_content", sa.String(), nullable=True))
batch_op.add_column(sa.Column("unsaturated_fat_content", sa.String(), nullable=True))

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op:
batch_op.drop_column("unsaturated_fat_content")
batch_op.drop_column("trans_fat_content")
batch_op.drop_column("saturated_fat_content")
batch_op.drop_column("cholesterol_content")

# ### end Alembic commands ###
20 changes: 18 additions & 2 deletions frontend/composables/recipes/use-recipe-nutrition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.calories"),
suffix: i18n.tc("recipe.calories-suffix"),
},
carbohydrateContent: {
label: i18n.tc("recipe.carbohydrate-content"),
suffix: i18n.tc("recipe.grams"),
},
cholesterolContent: {
label: i18n.tc("recipe.cholesterol-content"),
suffix: i18n.tc("recipe.milligrams"),
},
fatContent: {
label: i18n.tc("recipe.fat-content"),
suffix: i18n.tc("recipe.grams"),
Expand All @@ -29,6 +37,10 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.protein-content"),
suffix: i18n.tc("recipe.grams"),
},
saturatedFatContent: {
label: i18n.tc("recipe.saturated-fat-content"),
suffix: i18n.tc("recipe.grams"),
},
sodiumContent: {
label: i18n.tc("recipe.sodium-content"),
suffix: i18n.tc("recipe.milligrams"),
Expand All @@ -37,8 +49,12 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.sugar-content"),
suffix: i18n.tc("recipe.grams"),
},
carbohydrateContent: {
label: i18n.tc("recipe.carbohydrate-content"),
transFatContent: {
label: i18n.tc("recipe.trans-fat-content"),
suffix: i18n.tc("recipe.grams"),
},
unsaturatedFatContent: {
label: i18n.tc("recipe.unsaturated-fat-content"),
suffix: i18n.tc("recipe.grams"),
},
};
Expand Down
4 changes: 4 additions & 0 deletions frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@
"calories-suffix": "calories",
"carbohydrate-content": "Carbohydrate",
"categories": "Categories",
"cholesterol-content": "Cholesterol",
"comment-action": "Comment",
"comment": "Comment",
"comments": "Comments",
Expand Down Expand Up @@ -507,6 +508,7 @@
"recipe-updated": "Recipe updated",
"remove-from-favorites": "Remove from Favorites",
"remove-section": "Remove Section",
"saturated-fat-content": "Saturated fat",
"save-recipe-before-use": "Save recipe before use",
"section-title": "Section Title",
"servings": "Servings",
Expand All @@ -517,7 +519,9 @@
"sugar-content": "Sugar",
"title": "Title",
"total-time": "Total Time",
"trans-fat-content": "Trans-fat",
"unable-to-delete-recipe": "Unable to Delete Recipe",
"unsaturated-fat-content": "Unsaturated fat",
"no-recipe": "No Recipe",
"locked-by-owner": "Locked by Owner",
"join-the-conversation": "Join the Conversation",
Expand Down
10 changes: 7 additions & 3 deletions frontend/lib/api/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,16 @@ export interface MergeUnit {
}
export interface Nutrition {
calories?: string | null;
fatContent?: string | null;
proteinContent?: string | null;
carbohydrateContent?: string | null;
cholesterolContent?: string | null;
fatContent?: string | null;
fiberContent?: string | null;
proteinContent?: string | null;
saturatedFatContent?: string | null;
sodiumContent?: string | null;
sugarContent?: string | null;
transFatContent?: string | null;
unsaturatedFatContent?: string | null;
}
export interface ParsedIngredient {
input?: string | null;
Expand Down Expand Up @@ -486,7 +490,7 @@ export interface ScrapeRecipeTest {
url: string;
useOpenAI?: boolean;
}
export interface SlugResponse {}
export interface SlugResponse { }
export interface TagIn {
name: string;
}
Expand Down
30 changes: 27 additions & 3 deletions mealie/db/models/recipe/nutrition.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,52 @@ class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)

calories: Mapped[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
cholesterol_content: Mapped[str | None] = mapped_column(sa.String)
fat_content: Mapped[str | None] = mapped_column(sa.String)
fiber_content: Mapped[str | None] = mapped_column(sa.String)
protein_content: Mapped[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String)

# `serving_size` is not a scaling factor, but a per-serving volume or mass
# according to schema.org. E.g., "2 L", "500 g", "5 cups", etc.
#
# Ignoring for now because it's too difficult to work around variable units
# in translation for the frontend. Also, it causes cognitive dissonance wrt
# "servings" (i.e., "serves 2" etc.), which is an unrelated concept that
# might cause confusion.
#
# serving_size: Mapped[str | None] = mapped_column(sa.String)

sodium_content: Mapped[str | None] = mapped_column(sa.String)
sugar_content: Mapped[str | None] = mapped_column(sa.String)
trans_fat_content: Mapped[str | None] = mapped_column(sa.String)
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String)

def __init__(
self,
calories=None,
carbohydrate_content=None,
cholesterol_content=None,
fat_content=None,
fiber_content=None,
protein_content=None,
saturated_fat_content=None,
sodium_content=None,
sugar_content=None,
carbohydrate_content=None,
trans_fat_content=None,
unsaturated_fat_content=None,
) -> None:
self.calories = calories
self.carbohydrate_content = carbohydrate_content
self.cholesterol_content = cholesterol_content
self.fat_content = fat_content
self.fiber_content = fiber_content
self.protein_content = protein_content
self.saturated_fat_content = saturated_fat_content
self.sodium_content = sodium_content
self.sugar_content = sugar_content
self.carbohydrate_content = carbohydrate_content
self.trans_fat_content = trans_fat_content
self.unsaturated_fat_content = unsaturated_fat_content
4 changes: 2 additions & 2 deletions mealie/db/models/recipe/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def __init__(
settings: dict | None = None,
**_,
) -> None:
self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition()
self.nutrition = Nutrition(**(nutrition or {}))

if recipe_instructions is not None:
self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions]
Expand All @@ -198,7 +198,7 @@ def __init__(
if assets:
self.assets = [RecipeAsset(**a) for a in assets]

self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
self.settings = RecipeSettings(**(settings or {}))

if notes:
self.notes = [Note(**n) for n in notes]
Expand Down
10 changes: 1 addition & 9 deletions mealie/routes/spa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:

ingredients.append(s)

nutrition: dict[str, str | None] = {}
if recipe.nutrition:
nutrition["calories"] = recipe.nutrition.calories
nutrition["fatContent"] = recipe.nutrition.fat_content
nutrition["fiberContent"] = recipe.nutrition.fiber_content
nutrition["proteinContent"] = recipe.nutrition.protein_content
nutrition["carbohydrateContent"] = recipe.nutrition.carbohydrate_content
nutrition["sodiumContent"] = recipe.nutrition.sodium_content
nutrition["sugarContent"] = recipe.nutrition.sugar_content
nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {}

as_schema_org = {
"@context": "https://schema.org",
Expand Down
16 changes: 13 additions & 3 deletions mealie/schema/recipe/recipe_nutrition.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
from pydantic import ConfigDict
from pydantic.alias_generators import to_camel

from mealie.schema._mealie import MealieModel


class Nutrition(MealieModel):
calories: str | None = None
fat_content: str | None = None
protein_content: str | None = None
carbohydrate_content: str | None = None
cholesterol_content: str | None = None
fat_content: str | None = None
fiber_content: str | None = None
protein_content: str | None = None
saturated_fat_content: str | None = None
sodium_content: str | None = None
sugar_content: str | None = None
model_config = ConfigDict(from_attributes=True, coerce_numbers_to_str=True)
trans_fat_content: str | None = None
unsaturated_fat_content: str | None = None

model_config = ConfigDict(
from_attributes=True,
coerce_numbers_to_str=True,
alias_generator=to_camel,
)
26 changes: 21 additions & 5 deletions mealie/services/migrations/myrecipebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
from ._migration_base import BaseMigrator
from .utils.migration_helpers import scrape_image, split_by_line_break, split_by_semicolon

nutrition_map = {
"carbohydrate": "carbohydrateContent",
"protein": "proteinContent",
"fat": "fatContent",
"saturatedfat": "saturatedFatContent",
"transfat": "transFatContent",
"sodium": "sodiumContent",
"fiber": "fiberContent",
"sugar": "sugarContent",
"unsaturatedfat": "unsaturatedFatContent",
}


class MyRecipeBoxMigrator(BaseMigrator):
def __init__(self, **kwargs):
Expand Down Expand Up @@ -53,22 +65,26 @@ def parse_time(self, time: Any) -> str | None:
except Exception:
return None

def parse_nutrition(self, input: Any) -> dict | None:
if not input or not isinstance(input, str):
def parse_nutrition(self, input_: Any) -> dict | None:
if not input_ or not isinstance(input_, str):
return None

nutrition = {}

vals = [x.strip() for x in input.split(",") if x]
vals = (x.strip() for x in input_.split("\n") if x)
for val in vals:
try:
key, value = val.split(":", maxsplit=1)
key, value = (x.strip() for x in val.split(":", maxsplit=1))

if not (key and value):
continue

key = nutrition_map.get(key.lower(), key)

except ValueError:
continue

nutrition[key.strip()] = value.strip()
nutrition[key] = value

return cleaner.clean_nutrition(nutrition) if nutrition else None

Expand Down
24 changes: 14 additions & 10 deletions mealie/services/migrations/plantoeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ def get_value_as_string_or_none(dictionary: dict, key: str):
return None


nutrition_map = {
"Calories": "calories",
"Fat": "fatContent",
"Saturated Fat": "saturatedFatContent",
"Cholesterol": "cholesterolContent",
"Sodium": "sodiumContent",
"Sugar": "sugarContent",
"Carbohydrate": "carbohydrateContent",
"Fiber": "fiberContent",
"Protein": "proteinContent",
}


class PlanToEatMigrator(BaseMigrator):
def __init__(self, **kwargs):
super().__init__(**kwargs)
Expand All @@ -63,16 +76,7 @@ def __init__(self, **kwargs):

def _parse_recipe_nutrition_from_row(self, row: dict) -> dict:
"""Parses the nutrition data from the row"""

nut_dict: dict = {}

nut_dict["calories"] = get_value_as_string_or_none(row, "Calories")
nut_dict["fatContent"] = get_value_as_string_or_none(row, "Fat")
nut_dict["proteinContent"] = get_value_as_string_or_none(row, "Protein")
nut_dict["carbohydrateContent"] = get_value_as_string_or_none(row, "Carbohydrate")
nut_dict["fiberContent"] = get_value_as_string_or_none(row, "Fiber")
nut_dict["sodiumContent"] = get_value_as_string_or_none(row, "Sodium")
nut_dict["sugarContent"] = get_value_as_string_or_none(row, "Sugar")
nut_dict = {normalized_k: row[k] for k, normalized_k in nutrition_map.items() if k in row}

return cleaner.clean_nutrition(nut_dict)

Expand Down
11 changes: 6 additions & 5 deletions mealie/services/scraper/cleaner.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]:
list of valid keys

Assumptionas:
- All units are supplied in grams, expect sodium which maybe be in milligrams
- All units are supplied in grams, expect sodium and cholesterol which maybe be in milligrams

Returns:
dict[str, str]: If the argument is None, or not a dictionary, an empty dictionary is returned
Expand All @@ -509,9 +509,10 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]:
if matched_digits := MATCH_DIGITS.search(val):
output_nutrition[key] = matched_digits.group(0).replace(",", ".")

if sodium := nutrition.get("sodiumContent", None):
if isinstance(sodium, str) and "m" not in sodium and "g" in sodium:
with contextlib.suppress(AttributeError, TypeError):
output_nutrition["sodiumContent"] = str(float(output_nutrition["sodiumContent"]) * 1000)
for key in ["sodiumContent", "cholesterolContent"]:
if val := nutrition.get(key, None):
if isinstance(val, str) and "m" not in val and "g" in val:
with contextlib.suppress(AttributeError, TypeError):
output_nutrition[key] = str(float(output_nutrition[key]) * 1000)

return output_nutrition
13 changes: 11 additions & 2 deletions tests/fixtures/fixture_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ def g2_user(session: Session, admin_token, api_client: TestClient):
pass


@fixture(scope="module")
def unique_user(session: Session, api_client: TestClient):
def _unique_user(session: Session, api_client: TestClient):
registration = utils.user_registration_factory()
response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True))
assert response.status_code == 201
Expand Down Expand Up @@ -213,6 +212,16 @@ def unique_user(session: Session, api_client: TestClient):
pass


@fixture(scope="function")
def unique_user_fn_scoped(session: Session, api_client: TestClient):
yield from _unique_user(session, api_client)


@fixture(scope="module")
def unique_user(session: Session, api_client: TestClient):
yield from _unique_user(session, api_client)


@fixture(scope="module")
def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]:
group_name = utils.random_string()
Expand Down
Loading
Loading