mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Add the ability to flag a food as "on hand", to exclude from shopping list (#3777)
This commit is contained in:
		| @@ -0,0 +1,48 @@ | |||||||
|  | """Add staple flag to foods | ||||||
|  |  | ||||||
|  | Revision ID: 32d69327997b | ||||||
|  | Revises: 7788478a0338 | ||||||
|  | Create Date: 2024-06-22 10:17:03.323966 | ||||||
|  |  | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from sqlalchemy import orm | ||||||
|  |  | ||||||
|  | from alembic import op | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # revision identifiers, used by Alembic. | ||||||
|  | revision = "32d69327997b" | ||||||
|  | down_revision = "7788478a0338" | ||||||
|  | branch_labels = None | ||||||
|  | depends_on = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def is_postgres(): | ||||||
|  |     return op.get_context().dialect.name == "postgresql" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def upgrade(): | ||||||
|  |     with op.batch_alter_table("ingredient_foods") as batch_op: | ||||||
|  |         batch_op.add_column(sa.Column("on_hand", sa.Boolean(), nullable=True, default=False)) | ||||||
|  |  | ||||||
|  |     bind = op.get_bind() | ||||||
|  |     session = orm.Session(bind=bind) | ||||||
|  |  | ||||||
|  |     with session: | ||||||
|  |         if is_postgres(): | ||||||
|  |             stmt = "UPDATE ingredient_foods SET on_hand = FALSE;" | ||||||
|  |         else: | ||||||
|  |             stmt = "UPDATE ingredient_foods SET on_hand = 0;" | ||||||
|  |  | ||||||
|  |         session.execute(sa.text(stmt)) | ||||||
|  |  | ||||||
|  |     # forbid nulls after migration | ||||||
|  |     with op.batch_alter_table("ingredient_foods") as batch_op: | ||||||
|  |         batch_op.alter_column("on_hand", nullable=False) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def downgrade(): | ||||||
|  |     with op.batch_alter_table("ingredient_foods") as batch_op: | ||||||
|  |         batch_op.drop_column("on_hand") | ||||||
| @@ -231,7 +231,7 @@ export default defineComponent({ | |||||||
|  |  | ||||||
|         const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => { |         const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => { | ||||||
|           return { |           return { | ||||||
|             checked: true, |             checked: !ing.food?.onHand, | ||||||
|             ingredient: ing, |             ingredient: ing, | ||||||
|             disableAmount: recipe.settings?.disableAmount || false, |             disableAmount: recipe.settings?.disableAmount || false, | ||||||
|           } |           } | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ export const useFoodData = function () { | |||||||
|     name: "", |     name: "", | ||||||
|     description: "", |     description: "", | ||||||
|     labelId: undefined, |     labelId: undefined, | ||||||
|  |     onHand: false, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   function reset() { |   function reset() { | ||||||
| @@ -26,6 +27,7 @@ export const useFoodData = function () { | |||||||
|     data.name = ""; |     data.name = ""; | ||||||
|     data.description = ""; |     data.description = ""; | ||||||
|     data.labelId = undefined; |     data.labelId = undefined; | ||||||
|  |     data.onHand = false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|   | |||||||
| @@ -988,7 +988,8 @@ | |||||||
|       "food-data": "Food Data", |       "food-data": "Food Data", | ||||||
|       "example-food-singular": "ex: Onion", |       "example-food-singular": "ex: Onion", | ||||||
|       "example-food-plural": "ex: Onions", |       "example-food-plural": "ex: Onions", | ||||||
|       "label-overwrite-warning": "This will assign the chosen label to all selected foods and potentially overwrite your existing labels." |       "label-overwrite-warning": "This will assign the chosen label to all selected foods and potentially overwrite your existing labels.", | ||||||
|  |       "on-hand-checkbox-label": "Setting this flag will make this food unchecked by default when adding a recipe to a shopping list." | ||||||
|     }, |     }, | ||||||
|     "units": { |     "units": { | ||||||
|       "seed-dialog-text": "Seed the database with common units based on your local language.", |       "seed-dialog-text": "Seed the database with common units based on your local language.", | ||||||
|   | |||||||
| @@ -63,6 +63,7 @@ export interface CreateIngredientFood { | |||||||
|   }; |   }; | ||||||
|   labelId?: string; |   labelId?: string; | ||||||
|   aliases?: CreateIngredientFoodAlias[]; |   aliases?: CreateIngredientFoodAlias[]; | ||||||
|  |   onHand?: boolean; | ||||||
| } | } | ||||||
| export interface CreateIngredientFoodAlias { | export interface CreateIngredientFoodAlias { | ||||||
|   name: string; |   name: string; | ||||||
| @@ -135,6 +136,7 @@ export interface IngredientFood { | |||||||
|   label?: MultiPurposeLabelSummary; |   label?: MultiPurposeLabelSummary; | ||||||
|   createdAt?: string; |   createdAt?: string; | ||||||
|   updateAt?: string; |   updateAt?: string; | ||||||
|  |   onHand?: boolean; | ||||||
| } | } | ||||||
| export interface IngredientFoodAlias { | export interface IngredientFoodAlias { | ||||||
|   name: string; |   name: string; | ||||||
| @@ -464,7 +466,7 @@ export interface ScrapeRecipe { | |||||||
| export interface ScrapeRecipeTest { | export interface ScrapeRecipeTest { | ||||||
|   url: string; |   url: string; | ||||||
| } | } | ||||||
| export interface SlugResponse {} | export interface SlugResponse { } | ||||||
| export interface TagIn { | export interface TagIn { | ||||||
|   name: string; |   name: string; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -87,6 +87,14 @@ | |||||||
|             :label="$t('data-pages.foods.food-label')" |             :label="$t('data-pages.foods.food-label')" | ||||||
|           > |           > | ||||||
|           </v-autocomplete> |           </v-autocomplete> | ||||||
|  |           <v-checkbox | ||||||
|  |             v-model="createTarget.onHand" | ||||||
|  |             hide-details | ||||||
|  |             :label="$t('tool.on-hand')" | ||||||
|  |           /> | ||||||
|  |           <p class="text-caption mt-1"> | ||||||
|  |             {{ $t("data-pages.foods.on-hand-checkbox-label") }} | ||||||
|  |           </p> | ||||||
|         </v-form> </v-card-text |         </v-form> </v-card-text | ||||||
|     ></BaseDialog> |     ></BaseDialog> | ||||||
|  |  | ||||||
| @@ -134,6 +142,14 @@ | |||||||
|             :label="$t('data-pages.foods.food-label')" |             :label="$t('data-pages.foods.food-label')" | ||||||
|           > |           > | ||||||
|           </v-autocomplete> |           </v-autocomplete> | ||||||
|  |           <v-checkbox | ||||||
|  |             v-model="editTarget.onHand" | ||||||
|  |             hide-details | ||||||
|  |             :label="$t('tool.on-hand')" | ||||||
|  |           /> | ||||||
|  |           <p class="text-caption mt-1"> | ||||||
|  |             {{ $t("data-pages.foods.on-hand-checkbox-label") }} | ||||||
|  |           </p> | ||||||
|         </v-form> |         </v-form> | ||||||
|       </v-card-text> |       </v-card-text> | ||||||
|       <template #custom-card-action> |       <template #custom-card-action> | ||||||
| @@ -243,6 +259,11 @@ | |||||||
|           {{ item.label.name }} |           {{ item.label.name }} | ||||||
|         </MultiPurposeLabel> |         </MultiPurposeLabel> | ||||||
|       </template> |       </template> | ||||||
|  |       <template #item.onHand="{ item }"> | ||||||
|  |         <v-icon :color="item.onHand ? 'success' : undefined"> | ||||||
|  |           {{ item.onHand ? $globals.icons.check : $globals.icons.close }} | ||||||
|  |         </v-icon> | ||||||
|  |       </template> | ||||||
|       <template #button-bottom> |       <template #button-bottom> | ||||||
|         <BaseButton @click="seedDialog = true"> |         <BaseButton @click="seedDialog = true"> | ||||||
|           <template #icon> {{ $globals.icons.database }} </template> |           <template #icon> {{ $globals.icons.database }} </template> | ||||||
| @@ -300,6 +321,11 @@ export default defineComponent({ | |||||||
|         value: "label", |         value: "label", | ||||||
|         show: true, |         show: true, | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         text: i18n.tc("tool.on-hand"), | ||||||
|  |         value: "onHand", | ||||||
|  |         show: true, | ||||||
|  |       }, | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|     const foodStore = useFoodStore(); |     const foodStore = useFoodStore(); | ||||||
|   | |||||||
| @@ -36,7 +36,9 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins): | |||||||
|         "RecipeIngredientModel", back_populates="unit" |         "RecipeIngredientModel", back_populates="unit" | ||||||
|     ) |     ) | ||||||
|     aliases: Mapped[list["IngredientUnitAliasModel"]] = orm.relationship( |     aliases: Mapped[list["IngredientUnitAliasModel"]] = orm.relationship( | ||||||
|         "IngredientUnitAliasModel", back_populates="unit", cascade="all, delete, delete-orphan" |         "IngredientUnitAliasModel", | ||||||
|  |         back_populates="unit", | ||||||
|  |         cascade="all, delete, delete-orphan", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Automatically updated by sqlalchemy event, do not write to this manually |     # Automatically updated by sqlalchemy event, do not write to this manually | ||||||
| @@ -144,12 +146,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins): | |||||||
|     name: Mapped[str | None] = mapped_column(String) |     name: Mapped[str | None] = mapped_column(String) | ||||||
|     plural_name: Mapped[str | None] = mapped_column(String) |     plural_name: Mapped[str | None] = mapped_column(String) | ||||||
|     description: Mapped[str | None] = mapped_column(String) |     description: Mapped[str | None] = mapped_column(String) | ||||||
|  |     on_hand: Mapped[bool] = mapped_column(Boolean) | ||||||
|  |  | ||||||
|     ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( |     ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( | ||||||
|         "RecipeIngredientModel", back_populates="food" |         "RecipeIngredientModel", back_populates="food" | ||||||
|     ) |     ) | ||||||
|     aliases: Mapped[list["IngredientFoodAliasModel"]] = orm.relationship( |     aliases: Mapped[list["IngredientFoodAliasModel"]] = orm.relationship( | ||||||
|         "IngredientFoodAliasModel", back_populates="food", cascade="all, delete, delete-orphan" |         "IngredientFoodAliasModel", | ||||||
|  |         back_populates="food", | ||||||
|  |         cascade="all, delete, delete-orphan", | ||||||
|     ) |     ) | ||||||
|     extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan") |     extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan") | ||||||
|  |  | ||||||
| @@ -162,7 +167,13 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins): | |||||||
|  |  | ||||||
|     @api_extras |     @api_extras | ||||||
|     @auto_init() |     @auto_init() | ||||||
|     def __init__(self, session: Session, name: str | None = None, plural_name: str | None = None, **_) -> None: |     def __init__( | ||||||
|  |         self, | ||||||
|  |         session: Session, | ||||||
|  |         name: str | None = None, | ||||||
|  |         plural_name: str | None = None, | ||||||
|  |         **_, | ||||||
|  |     ) -> None: | ||||||
|         if name is not None: |         if name is not None: | ||||||
|             self.name_normalized = self.normalize(name) |             self.name_normalized = self.normalize(name) | ||||||
|         if plural_name is not None: |         if plural_name is not None: | ||||||
| @@ -317,7 +328,13 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): | |||||||
|     original_text_normalized: Mapped[str | None] = mapped_column(String, index=True) |     original_text_normalized: Mapped[str | None] = mapped_column(String, index=True) | ||||||
|  |  | ||||||
|     @auto_init() |     @auto_init() | ||||||
|     def __init__(self, session: Session, note: str | None = None, orginal_text: str | None = None, **_) -> None: |     def __init__( | ||||||
|  |         self, | ||||||
|  |         session: Session, | ||||||
|  |         note: str | None = None, | ||||||
|  |         orginal_text: str | None = None, | ||||||
|  |         **_, | ||||||
|  |     ) -> None: | ||||||
|         # SQLAlchemy events do not seem to register things that are set during auto_init |         # SQLAlchemy events do not seem to register things that are set during auto_init | ||||||
|         if note is not None: |         if note is not None: | ||||||
|             self.note_normalized = self.normalize(note) |             self.note_normalized = self.normalize(note) | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ class UnitFoodBase(MealieModel): | |||||||
|     plural_name: str | None = None |     plural_name: str | None = None | ||||||
|     description: str = "" |     description: str = "" | ||||||
|     extras: dict | None = {} |     extras: dict | None = {} | ||||||
|  |     on_hand: bool = False | ||||||
|  |  | ||||||
|     @field_validator("id", mode="before") |     @field_validator("id", mode="before") | ||||||
|     def convert_empty_id_to_none(cls, v): |     def convert_empty_id_to_none(cls, v): | ||||||
| @@ -79,13 +80,19 @@ class IngredientFood(CreateIngredientFood): | |||||||
|     created_at: datetime.datetime | None = None |     created_at: datetime.datetime | None = None | ||||||
|     update_at: datetime.datetime | None = None |     update_at: datetime.datetime | None = None | ||||||
|  |  | ||||||
|     _searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"] |     _searchable_properties: ClassVar[list[str]] = [ | ||||||
|  |         "name_normalized", | ||||||
|  |         "plural_name_normalized", | ||||||
|  |     ] | ||||||
|     _normalize_search: ClassVar[bool] = True |     _normalize_search: ClassVar[bool] = True | ||||||
|     model_config = ConfigDict(from_attributes=True) |     model_config = ConfigDict(from_attributes=True) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def loader_options(cls) -> list[LoaderOption]: |     def loader_options(cls) -> list[LoaderOption]: | ||||||
|         return [joinedload(IngredientFoodModel.extras), joinedload(IngredientFoodModel.label)] |         return [ | ||||||
|  |             joinedload(IngredientFoodModel.extras), | ||||||
|  |             joinedload(IngredientFoodModel.label), | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class IngredientFoodPagination(PaginationBase): | class IngredientFoodPagination(PaginationBase): | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user