mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: adding the rest ofthe nutrition properties from schema.org (#4301)
This commit is contained in:
		| @@ -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 ### | ||||||
| @@ -17,6 +17,14 @@ export function useNutritionLabels() { | |||||||
|       label: i18n.tc("recipe.calories"), |       label: i18n.tc("recipe.calories"), | ||||||
|       suffix: i18n.tc("recipe.calories-suffix"), |       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: { |     fatContent: { | ||||||
|       label: i18n.tc("recipe.fat-content"), |       label: i18n.tc("recipe.fat-content"), | ||||||
|       suffix: i18n.tc("recipe.grams"), |       suffix: i18n.tc("recipe.grams"), | ||||||
| @@ -29,6 +37,10 @@ export function useNutritionLabels() { | |||||||
|       label: i18n.tc("recipe.protein-content"), |       label: i18n.tc("recipe.protein-content"), | ||||||
|       suffix: i18n.tc("recipe.grams"), |       suffix: i18n.tc("recipe.grams"), | ||||||
|     }, |     }, | ||||||
|  |     saturatedFatContent: { | ||||||
|  |       label: i18n.tc("recipe.saturated-fat-content"), | ||||||
|  |       suffix: i18n.tc("recipe.grams"), | ||||||
|  |     }, | ||||||
|     sodiumContent: { |     sodiumContent: { | ||||||
|       label: i18n.tc("recipe.sodium-content"), |       label: i18n.tc("recipe.sodium-content"), | ||||||
|       suffix: i18n.tc("recipe.milligrams"), |       suffix: i18n.tc("recipe.milligrams"), | ||||||
| @@ -37,8 +49,12 @@ export function useNutritionLabels() { | |||||||
|       label: i18n.tc("recipe.sugar-content"), |       label: i18n.tc("recipe.sugar-content"), | ||||||
|       suffix: i18n.tc("recipe.grams"), |       suffix: i18n.tc("recipe.grams"), | ||||||
|     }, |     }, | ||||||
|     carbohydrateContent: { |     transFatContent: { | ||||||
|       label: i18n.tc("recipe.carbohydrate-content"), |       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"), |       suffix: i18n.tc("recipe.grams"), | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
|   | |||||||
| @@ -461,6 +461,7 @@ | |||||||
|     "calories-suffix": "calories", |     "calories-suffix": "calories", | ||||||
|     "carbohydrate-content": "Carbohydrate", |     "carbohydrate-content": "Carbohydrate", | ||||||
|     "categories": "Categories", |     "categories": "Categories", | ||||||
|  |     "cholesterol-content": "Cholesterol", | ||||||
|     "comment-action": "Comment", |     "comment-action": "Comment", | ||||||
|     "comment": "Comment", |     "comment": "Comment", | ||||||
|     "comments": "Comments", |     "comments": "Comments", | ||||||
| @@ -507,6 +508,7 @@ | |||||||
|     "recipe-updated": "Recipe updated", |     "recipe-updated": "Recipe updated", | ||||||
|     "remove-from-favorites": "Remove from Favorites", |     "remove-from-favorites": "Remove from Favorites", | ||||||
|     "remove-section": "Remove Section", |     "remove-section": "Remove Section", | ||||||
|  |     "saturated-fat-content": "Saturated fat", | ||||||
|     "save-recipe-before-use": "Save recipe before use", |     "save-recipe-before-use": "Save recipe before use", | ||||||
|     "section-title": "Section Title", |     "section-title": "Section Title", | ||||||
|     "servings": "Servings", |     "servings": "Servings", | ||||||
| @@ -517,7 +519,9 @@ | |||||||
|     "sugar-content": "Sugar", |     "sugar-content": "Sugar", | ||||||
|     "title": "Title", |     "title": "Title", | ||||||
|     "total-time": "Total Time", |     "total-time": "Total Time", | ||||||
|  |     "trans-fat-content": "Trans-fat", | ||||||
|     "unable-to-delete-recipe": "Unable to Delete Recipe", |     "unable-to-delete-recipe": "Unable to Delete Recipe", | ||||||
|  |     "unsaturated-fat-content": "Unsaturated fat", | ||||||
|     "no-recipe": "No Recipe", |     "no-recipe": "No Recipe", | ||||||
|     "locked-by-owner": "Locked by Owner", |     "locked-by-owner": "Locked by Owner", | ||||||
|     "join-the-conversation": "Join the Conversation", |     "join-the-conversation": "Join the Conversation", | ||||||
|   | |||||||
| @@ -194,12 +194,16 @@ export interface MergeUnit { | |||||||
| } | } | ||||||
| export interface Nutrition { | export interface Nutrition { | ||||||
|   calories?: string | null; |   calories?: string | null; | ||||||
|   fatContent?: string | null; |  | ||||||
|   proteinContent?: string | null; |  | ||||||
|   carbohydrateContent?: string | null; |   carbohydrateContent?: string | null; | ||||||
|  |   cholesterolContent?: string | null; | ||||||
|  |   fatContent?: string | null; | ||||||
|   fiberContent?: string | null; |   fiberContent?: string | null; | ||||||
|  |   proteinContent?: string | null; | ||||||
|  |   saturatedFatContent?: string | null; | ||||||
|   sodiumContent?: string | null; |   sodiumContent?: string | null; | ||||||
|   sugarContent?: string | null; |   sugarContent?: string | null; | ||||||
|  |   transFatContent?: string | null; | ||||||
|  |   unsaturatedFatContent?: string | null; | ||||||
| } | } | ||||||
| export interface ParsedIngredient { | export interface ParsedIngredient { | ||||||
|   input?: string | null; |   input?: string | null; | ||||||
| @@ -486,7 +490,7 @@ export interface ScrapeRecipeTest { | |||||||
|   url: string; |   url: string; | ||||||
|   useOpenAI?: boolean; |   useOpenAI?: boolean; | ||||||
| } | } | ||||||
| export interface SlugResponse {} | export interface SlugResponse { } | ||||||
| export interface TagIn { | export interface TagIn { | ||||||
|   name: string; |   name: string; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -9,28 +9,52 @@ class Nutrition(SqlAlchemyBase): | |||||||
|     __tablename__ = "recipe_nutrition" |     __tablename__ = "recipe_nutrition" | ||||||
|     id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) |     id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) | ||||||
|     recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) |     recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) | ||||||
|  |  | ||||||
|     calories: Mapped[str | None] = mapped_column(sa.String) |     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) |     fat_content: Mapped[str | None] = mapped_column(sa.String) | ||||||
|     fiber_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) |     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) |     sodium_content: Mapped[str | None] = mapped_column(sa.String) | ||||||
|     sugar_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__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         calories=None, |         calories=None, | ||||||
|  |         carbohydrate_content=None, | ||||||
|  |         cholesterol_content=None, | ||||||
|         fat_content=None, |         fat_content=None, | ||||||
|         fiber_content=None, |         fiber_content=None, | ||||||
|         protein_content=None, |         protein_content=None, | ||||||
|  |         saturated_fat_content=None, | ||||||
|         sodium_content=None, |         sodium_content=None, | ||||||
|         sugar_content=None, |         sugar_content=None, | ||||||
|         carbohydrate_content=None, |         trans_fat_content=None, | ||||||
|  |         unsaturated_fat_content=None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.calories = calories |         self.calories = calories | ||||||
|  |         self.carbohydrate_content = carbohydrate_content | ||||||
|  |         self.cholesterol_content = cholesterol_content | ||||||
|         self.fat_content = fat_content |         self.fat_content = fat_content | ||||||
|         self.fiber_content = fiber_content |         self.fiber_content = fiber_content | ||||||
|         self.protein_content = protein_content |         self.protein_content = protein_content | ||||||
|  |         self.saturated_fat_content = saturated_fat_content | ||||||
|         self.sodium_content = sodium_content |         self.sodium_content = sodium_content | ||||||
|         self.sugar_content = sugar_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 | ||||||
|   | |||||||
| @@ -187,7 +187,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): | |||||||
|         settings: dict | None = None, |         settings: dict | None = None, | ||||||
|         **_, |         **_, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() |         self.nutrition = Nutrition(**(nutrition or {})) | ||||||
|  |  | ||||||
|         if recipe_instructions is not None: |         if recipe_instructions is not None: | ||||||
|             self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] |             self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] | ||||||
| @@ -198,7 +198,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): | |||||||
|         if assets: |         if assets: | ||||||
|             self.assets = [RecipeAsset(**a) for a in assets] |             self.assets = [RecipeAsset(**a) for a in assets] | ||||||
|  |  | ||||||
|         self.settings = RecipeSettings(**settings) if settings else RecipeSettings() |         self.settings = RecipeSettings(**(settings or {})) | ||||||
|  |  | ||||||
|         if notes: |         if notes: | ||||||
|             self.notes = [Note(**n) for n in notes] |             self.notes = [Note(**n) for n in notes] | ||||||
|   | |||||||
| @@ -104,15 +104,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: | |||||||
|  |  | ||||||
|             ingredients.append(s) |             ingredients.append(s) | ||||||
|  |  | ||||||
|     nutrition: dict[str, str | None] = {} |     nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {} | ||||||
|     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 |  | ||||||
|  |  | ||||||
|     as_schema_org = { |     as_schema_org = { | ||||||
|         "@context": "https://schema.org", |         "@context": "https://schema.org", | ||||||
|   | |||||||
| @@ -1,14 +1,24 @@ | |||||||
| from pydantic import ConfigDict | from pydantic import ConfigDict | ||||||
|  | from pydantic.alias_generators import to_camel | ||||||
|  |  | ||||||
| from mealie.schema._mealie import MealieModel | from mealie.schema._mealie import MealieModel | ||||||
|  |  | ||||||
|  |  | ||||||
| class Nutrition(MealieModel): | class Nutrition(MealieModel): | ||||||
|     calories: str | None = None |     calories: str | None = None | ||||||
|     fat_content: str | None = None |  | ||||||
|     protein_content: str | None = None |  | ||||||
|     carbohydrate_content: str | None = None |     carbohydrate_content: str | None = None | ||||||
|  |     cholesterol_content: str | None = None | ||||||
|  |     fat_content: str | None = None | ||||||
|     fiber_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 |     sodium_content: str | None = None | ||||||
|     sugar_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, | ||||||
|  |     ) | ||||||
|   | |||||||
| @@ -12,6 +12,18 @@ from mealie.services.scraper import cleaner | |||||||
| from ._migration_base import BaseMigrator | from ._migration_base import BaseMigrator | ||||||
| from .utils.migration_helpers import scrape_image, split_by_line_break, split_by_semicolon | 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): | class MyRecipeBoxMigrator(BaseMigrator): | ||||||
|     def __init__(self, **kwargs): |     def __init__(self, **kwargs): | ||||||
| @@ -53,22 +65,26 @@ class MyRecipeBoxMigrator(BaseMigrator): | |||||||
|         except Exception: |         except Exception: | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|     def parse_nutrition(self, input: Any) -> dict | None: |     def parse_nutrition(self, input_: Any) -> dict | None: | ||||||
|         if not input or not isinstance(input, str): |         if not input_ or not isinstance(input_, str): | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         nutrition = {} |         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: |         for val in vals: | ||||||
|             try: |             try: | ||||||
|                 key, value = val.split(":", maxsplit=1) |                 key, value = (x.strip() for x in val.split(":", maxsplit=1)) | ||||||
|  |  | ||||||
|                 if not (key and value): |                 if not (key and value): | ||||||
|                     continue |                     continue | ||||||
|  |  | ||||||
|  |                 key = nutrition_map.get(key.lower(), key) | ||||||
|  |  | ||||||
|             except ValueError: |             except ValueError: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|             nutrition[key.strip()] = value.strip() |             nutrition[key] = value | ||||||
|  |  | ||||||
|         return cleaner.clean_nutrition(nutrition) if nutrition else None |         return cleaner.clean_nutrition(nutrition) if nutrition else None | ||||||
|  |  | ||||||
|   | |||||||
| @@ -37,6 +37,19 @@ def get_value_as_string_or_none(dictionary: dict, key: str): | |||||||
|         return None |         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): | class PlanToEatMigrator(BaseMigrator): | ||||||
|     def __init__(self, **kwargs): |     def __init__(self, **kwargs): | ||||||
|         super().__init__(**kwargs) |         super().__init__(**kwargs) | ||||||
| @@ -63,16 +76,7 @@ class PlanToEatMigrator(BaseMigrator): | |||||||
|  |  | ||||||
|     def _parse_recipe_nutrition_from_row(self, row: dict) -> dict: |     def _parse_recipe_nutrition_from_row(self, row: dict) -> dict: | ||||||
|         """Parses the nutrition data from the row""" |         """Parses the nutrition data from the row""" | ||||||
|  |         nut_dict = {normalized_k: row[k] for k, normalized_k in nutrition_map.items() if k in 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") |  | ||||||
|  |  | ||||||
|         return cleaner.clean_nutrition(nut_dict) |         return cleaner.clean_nutrition(nut_dict) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -495,7 +495,7 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]: | |||||||
|     list of valid keys |     list of valid keys | ||||||
|  |  | ||||||
|     Assumptionas: |     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: |     Returns: | ||||||
|         dict[str, str]: If the argument is None, or not a dictionary, an empty dictionary is returned |         dict[str, str]: If the argument is None, or not a dictionary, an empty dictionary is returned | ||||||
| @@ -509,9 +509,10 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]: | |||||||
|             if matched_digits := MATCH_DIGITS.search(val): |             if matched_digits := MATCH_DIGITS.search(val): | ||||||
|                 output_nutrition[key] = matched_digits.group(0).replace(",", ".") |                 output_nutrition[key] = matched_digits.group(0).replace(",", ".") | ||||||
|  |  | ||||||
|     if sodium := nutrition.get("sodiumContent", None): |     for key in ["sodiumContent", "cholesterolContent"]: | ||||||
|         if isinstance(sodium, str) and "m" not in sodium and "g" in sodium: |         if val := nutrition.get(key, None): | ||||||
|             with contextlib.suppress(AttributeError, TypeError): |             if isinstance(val, str) and "m" not in val and "g" in val: | ||||||
|                 output_nutrition["sodiumContent"] = str(float(output_nutrition["sodiumContent"]) * 1000) |                 with contextlib.suppress(AttributeError, TypeError): | ||||||
|  |                     output_nutrition[key] = str(float(output_nutrition[key]) * 1000) | ||||||
|  |  | ||||||
|     return output_nutrition |     return output_nutrition | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								tests/fixtures/fixture_users.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								tests/fixtures/fixture_users.py
									
									
									
									
										vendored
									
									
								
							| @@ -173,8 +173,7 @@ def g2_user(session: Session, admin_token, api_client: TestClient): | |||||||
|         pass |         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() |     registration = utils.user_registration_factory() | ||||||
|     response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) |     response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) | ||||||
|     assert response.status_code == 201 |     assert response.status_code == 201 | ||||||
| @@ -213,6 +212,16 @@ def unique_user(session: Session, api_client: TestClient): | |||||||
|         pass |         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") | @fixture(scope="module") | ||||||
| def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]: | def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]: | ||||||
|     group_name = utils.random_string() |     group_name = utils.random_string() | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import os | import os | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass, field | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from tempfile import TemporaryDirectory | from tempfile import TemporaryDirectory | ||||||
| from zipfile import ZipFile | from zipfile import ZipFile | ||||||
| @@ -8,6 +8,7 @@ import pytest | |||||||
| from fastapi.testclient import TestClient | from fastapi.testclient import TestClient | ||||||
|  |  | ||||||
| from mealie.schema.group.group_migration import SupportedMigrations | from mealie.schema.group.group_migration import SupportedMigrations | ||||||
|  | from mealie.schema.recipe.recipe import Recipe | ||||||
| from mealie.schema.reports.reports import ReportEntryOut | from mealie.schema.reports.reports import ReportEntryOut | ||||||
| from tests import data as test_data | from tests import data as test_data | ||||||
| from tests.utils import api_routes | from tests.utils import api_routes | ||||||
| @@ -19,18 +20,94 @@ from tests.utils.fixture_schemas import TestUser | |||||||
| class MigrationTestData: | class MigrationTestData: | ||||||
|     typ: SupportedMigrations |     typ: SupportedMigrations | ||||||
|     archive: Path |     archive: Path | ||||||
|  |     search_slug: str | ||||||
|  |  | ||||||
|  |     nutrition_filter: set[str] = field(default_factory=set) | ||||||
|  |     nutrition_entries: set[str] = field( | ||||||
|  |         default_factory=lambda: { | ||||||
|  |             "calories", | ||||||
|  |             "carbohydrateContent", | ||||||
|  |             "cholesterolContent", | ||||||
|  |             "fatContent", | ||||||
|  |             "fiberContent", | ||||||
|  |             "proteinContent", | ||||||
|  |             "saturatedFatContent", | ||||||
|  |             "sodiumContent", | ||||||
|  |             "sugarContent", | ||||||
|  |             "transFatContent", | ||||||
|  |             "unsaturatedFatContent", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| test_cases = [ | test_cases = [ | ||||||
|     MigrationTestData(typ=SupportedMigrations.nextcloud, archive=test_data.migrations_nextcloud), |     MigrationTestData( | ||||||
|     MigrationTestData(typ=SupportedMigrations.paprika, archive=test_data.migrations_paprika), |         typ=SupportedMigrations.nextcloud, | ||||||
|     MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown), |         archive=test_data.migrations_nextcloud, | ||||||
|     MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat), |         search_slug="skillet-shepherd-s-pie", | ||||||
|     MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie), |         nutrition_filter={ | ||||||
|     MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor), |             "transFatContent", | ||||||
|     MigrationTestData(typ=SupportedMigrations.plantoeat, archive=test_data.migrations_plantoeat), |             "unsaturatedFatContent", | ||||||
|     MigrationTestData(typ=SupportedMigrations.myrecipebox, archive=test_data.migrations_myrecipebox), |         }, | ||||||
|     MigrationTestData(typ=SupportedMigrations.recipekeeper, archive=test_data.migrations_recipekeeper), |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.paprika, | ||||||
|  |         archive=test_data.migrations_paprika, | ||||||
|  |         search_slug="zucchini-kartoffel-frittata", | ||||||
|  |         nutrition_entries=set(), | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.chowdown, | ||||||
|  |         archive=test_data.migrations_chowdown, | ||||||
|  |         search_slug="roasted-okra", | ||||||
|  |         nutrition_entries=set(), | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.copymethat, | ||||||
|  |         archive=test_data.migrations_copymethat, | ||||||
|  |         search_slug="spam-zoodles", | ||||||
|  |         nutrition_entries=set(), | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.mealie_alpha, | ||||||
|  |         archive=test_data.migrations_mealie, | ||||||
|  |         search_slug="old-fashioned-beef-stew", | ||||||
|  |         nutrition_filter={ | ||||||
|  |             "cholesterolContent", | ||||||
|  |             "saturatedFatContent", | ||||||
|  |             "transFatContent", | ||||||
|  |             "unsaturatedFatContent", | ||||||
|  |         }, | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.tandoor, | ||||||
|  |         archive=test_data.migrations_tandoor, | ||||||
|  |         search_slug="texas-red-chili", | ||||||
|  |         nutrition_entries=set(), | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.plantoeat, | ||||||
|  |         archive=test_data.migrations_plantoeat, | ||||||
|  |         search_slug="test-recipe", | ||||||
|  |         nutrition_filter={ | ||||||
|  |             "unsaturatedFatContent", | ||||||
|  |             "transFatContent", | ||||||
|  |         }, | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.myrecipebox, | ||||||
|  |         archive=test_data.migrations_myrecipebox, | ||||||
|  |         search_slug="beef-cheese-piroshki", | ||||||
|  |         nutrition_filter={ | ||||||
|  |             "cholesterolContent", | ||||||
|  |         }, | ||||||
|  |     ), | ||||||
|  |     MigrationTestData( | ||||||
|  |         typ=SupportedMigrations.recipekeeper, | ||||||
|  |         archive=test_data.migrations_recipekeeper, | ||||||
|  |         search_slug="zucchini-bread", | ||||||
|  |         nutrition_entries=set(), | ||||||
|  |     ), | ||||||
| ] | ] | ||||||
|  |  | ||||||
| test_ids = [ | test_ids = [ | ||||||
| @@ -47,7 +124,8 @@ test_ids = [ | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize("mig", test_cases, ids=test_ids) | @pytest.mark.parametrize("mig", test_cases, ids=test_ids) | ||||||
| def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: MigrationTestData) -> None: | def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUser, mig: MigrationTestData) -> None: | ||||||
|  |     unique_user = unique_user_fn_scoped | ||||||
|     payload = { |     payload = { | ||||||
|         "migration_type": mig.typ.value, |         "migration_type": mig.typ.value, | ||||||
|     } |     } | ||||||
| @@ -91,6 +169,19 @@ def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: Mi | |||||||
|     events = query_data["items"] |     events = query_data["items"] | ||||||
|     assert len(events) |     assert len(events) | ||||||
|  |  | ||||||
|  |     # Validate recipe content | ||||||
|  |     response = api_client.get(api_routes.recipes_slug(mig.search_slug), headers=unique_user.token) | ||||||
|  |     recipe = Recipe(**assert_deserialize(response)) | ||||||
|  |  | ||||||
|  |     if mig.nutrition_entries: | ||||||
|  |         assert recipe.nutrition is not None | ||||||
|  |         nutrition = recipe.nutrition.model_dump(by_alias=True) | ||||||
|  |  | ||||||
|  |         for k in mig.nutrition_entries.difference(mig.nutrition_filter): | ||||||
|  |             assert k in nutrition and nutrition[k] is not None | ||||||
|  |  | ||||||
|  |     # TODO: validate other types of content | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser): | def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser): | ||||||
|     with TemporaryDirectory() as tmpdir: |     with TemporaryDirectory() as tmpdir: | ||||||
|   | |||||||
| @@ -481,20 +481,24 @@ nutrition_test_cases = ( | |||||||
|         }, |         }, | ||||||
|     ), |     ), | ||||||
|     CleanerCase( |     CleanerCase( | ||||||
|         test_id="special support for sodiumContent (g -> mg)", |         test_id="special support for sodiumContent/cholesterolContent (g -> mg)", | ||||||
|         input={ |         input={ | ||||||
|  |             "cholesterolContent": "10g", | ||||||
|             "sodiumContent": "10g", |             "sodiumContent": "10g", | ||||||
|         }, |         }, | ||||||
|         expected={ |         expected={ | ||||||
|  |             "cholesterolContent": "10000.0", | ||||||
|             "sodiumContent": "10000.0", |             "sodiumContent": "10000.0", | ||||||
|         }, |         }, | ||||||
|     ), |     ), | ||||||
|     CleanerCase( |     CleanerCase( | ||||||
|         test_id="special support for sodiumContent (mg -> mg)", |         test_id="special support for sodiumContent/cholesterolContent (mg -> mg)", | ||||||
|         input={ |         input={ | ||||||
|  |             "cholesterolContent": "10000mg", | ||||||
|             "sodiumContent": "10000mg", |             "sodiumContent": "10000mg", | ||||||
|         }, |         }, | ||||||
|         expected={ |         expected={ | ||||||
|  |             "cholesterolContent": "10000", | ||||||
|             "sodiumContent": "10000", |             "sodiumContent": "10000", | ||||||
|         }, |         }, | ||||||
|     ), |     ), | ||||||
|   | |||||||
| @@ -23,6 +23,12 @@ async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase): | |||||||
|     recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator) |     recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator) | ||||||
|  |  | ||||||
|     assert recipe.slug == recipe_test_data.expected_slug |     assert recipe.slug == recipe_test_data.expected_slug | ||||||
|  |  | ||||||
|     assert len(recipe.recipe_instructions or []) == recipe_test_data.num_steps |     assert len(recipe.recipe_instructions or []) == recipe_test_data.num_steps | ||||||
|  |  | ||||||
|     assert len(recipe.recipe_ingredient) == recipe_test_data.num_ingredients |     assert len(recipe.recipe_ingredient) == recipe_test_data.num_ingredients | ||||||
|  |  | ||||||
|  |     actual = recipe.nutrition.model_dump() if recipe.nutrition else {} | ||||||
|  |     assert recipe_test_data.num_nutrition_entries == len(actual.items()) | ||||||
|  |  | ||||||
|     assert recipe.org_url == recipe_test_data.url |     assert recipe.org_url == recipe_test_data.url | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ class RecipeSiteTestCase: | |||||||
|     num_steps: int |     num_steps: int | ||||||
|     html_file: Path |     html_file: Path | ||||||
|  |  | ||||||
|  |     num_nutrition_entries: int = 0 | ||||||
|     include_tags: bool = False |     include_tags: bool = False | ||||||
|     expected_tags: set[str] | None = None |     expected_tags: set[str] | None = None | ||||||
|  |  | ||||||
| @@ -26,6 +27,7 @@ def get_recipe_test_cases(): | |||||||
|             expected_slug="taiwanese-three-cup-chicken-san-bei-ji-recipe", |             expected_slug="taiwanese-three-cup-chicken-san-bei-ji-recipe", | ||||||
|             num_ingredients=10, |             num_ingredients=10, | ||||||
|             num_steps=3, |             num_steps=3, | ||||||
|  |             num_nutrition_entries=11, | ||||||
|         ), |         ), | ||||||
|         RecipeSiteTestCase( |         RecipeSiteTestCase( | ||||||
|             url="https://www.rezeptwelt.de/backen-herzhaft-rezepte/schinken-kaese-waffeln-ohne-viel-schnickschnack/4j0bkiig-94d4d-106529-cfcd2-is97x2ml", |             url="https://www.rezeptwelt.de/backen-herzhaft-rezepte/schinken-kaese-waffeln-ohne-viel-schnickschnack/4j0bkiig-94d4d-106529-cfcd2-is97x2ml", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user