Repository: mealie-recipes/mealie (Python)

Issue:
[BUG] - Copying shoppings lists results in blank output
### First Check

- [X] This is not a feature request.
- [X] I added a very descriptive title to this issue (title field is above this).
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Mealie documentation, with the integrated search.
- [X] I already read the docs and didn't find an answer.
- [X] This issue can be replicated on the demo site (https://demo.mealie.io/).

### What is the issue you are experiencing?

When copying shopping list items (either as text or markdown), each line in the output is just a blank string. Having had a look at the `copyListItems` function, it uses the `display` property on each list item to populate the copy output. The display property seems to always come back as blank in the `nightly` build of Mealie but *not* in the `latest` image, so it must be a recent regression. Looks like a change has been made on the backend side which is breaking this display property.

### Steps to Reproduce

Go to the demo mealie site and log in. Go to [any shopping list](https://demo.mealie.io/shopping-lists/5ad01392-93aa-4fd9-939d-5fc7af1a4fc8) with some items and click "Copy as text". The output will be a set of blank lines equal to the number of items in the list.

### Please provide relevant logs

No logs, UI only issue

### Mealie Version

Nightly, https://github.com/mealie-recipes/mealie/commit/5ac7645350ba580f212fbd1c0d29eb96c1317076

### Deployment

Docker (Linux)

### Additional Deployment Details

Docker compose on Linux with Postgres DB


Discussion:

Patch:
diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue
index c5bdd2cc824..b4c31f32c2e 100644
--- a/frontend/pages/shopping-lists/_id.vue
+++ b/frontend/pages/shopping-lists/_id.vue
@@ -337,21 +337,50 @@ export default defineComponent({
     const copy = useCopyList();
 
     function copyListItems(copyType: CopyTypes) {
-      const items = shoppingList.value?.listItems?.filter((item) => !item.checked);
+      const text: string[] = [];
+
+      if (preferences.value.viewByLabel) {
+        // if we're sorting by label, we want the copied text in subsections
+        Object.entries(itemsByLabel.value).forEach(([label, items], idx) => {
+          // for every group except the first, add a blank line
+          if (idx) {
+            text.push("")
+          }
 
-      if (!items) {
-        return;
+          // add an appropriate heading for the label depending on the copy format
+          text.push(formatCopiedLabelHeading(copyType, label))
+
+          // now add the appropriately formatted list items with the given label
+          items.forEach((item) => text.push(formatCopiedListItem(copyType, item)))
+        })
+      } else {
+        // labels are toggled off, so just copy in the order they come in
+        const items = shoppingList.value?.listItems?.filter((item) => !item.checked)
+
+        items?.forEach((item) => {
+          text.push(formatCopiedListItem(copyType, item))
+        });
       }
 
-      const text: string[] = items.map((itm) => itm.display || "");
+      copy.copyPlain(text);
+    }
 
+    function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string {
+      const display = item.display || ""
       switch (copyType) {
         case "markdown":
-          copy.copyMarkdownCheckList(text);
-          break;
+          return `- [ ] ${display}`
         default:
-          copy.copyPlain(text);
-          break;
+          return display
+      }
+    }
+
+    function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string {
+      switch (copyType) {
+        case "markdown":
+          return `# ${label}`
+        default:
+          return `[${label}]`
       }
     }
 
diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py
index ae5a27da5ec..99bb0afe62c 100644
--- a/mealie/schema/group/group_shopping_list.py
+++ b/mealie/schema/group/group_shopping_list.py
@@ -101,7 +101,7 @@ class ShoppingListItemOut(ShoppingListItemBase):
     update_at: datetime | None = None
 
     @model_validator(mode="after")
-    def post_validate(self):
+    def populate_missing_label(self):
         # if we're missing a label, but the food has a label, use that as the label
         if (not self.label) and (self.food and self.food.label):
             self.label = self.food.label
diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py
index 483247d07f2..e0914ed080a 100644
--- a/mealie/schema/recipe/recipe.py
+++ b/mealie/schema/recipe/recipe.py
@@ -184,13 +184,13 @@ def image_dir(self) -> Path:
     model_config = ConfigDict(from_attributes=True)
 
     @model_validator(mode="after")
-    def post_validate(self):
-        # the ingredient disable_amount property is unreliable,
-        # so we set it here and recalculate the display property
+    def calculate_missing_food_flags_and_format_display(self):
         disable_amount = self.settings.disable_amount if self.settings else True
         for ingredient in self.recipe_ingredient:
             ingredient.disable_amount = disable_amount
             ingredient.is_food = not ingredient.disable_amount
+
+            # recalculate the display property, since it depends on the disable_amount flag
             ingredient.display = ingredient._format_display()
 
         return self
diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py
index 5eaa0e9f12c..7609fa5486e 100644
--- a/mealie/schema/recipe/recipe_ingredient.py
+++ b/mealie/schema/recipe/recipe_ingredient.py
@@ -145,7 +145,7 @@ class RecipeIngredientBase(MealieModel):
     """
 
     @model_validator(mode="after")
-    def post_validate(self):
+    def calculate_missing_food_flags(self):
         # calculate missing is_food and disable_amount values
         # we can't do this in a validator since they depend on each other
         if self.is_food is None and self.disable_amount is not None:
@@ -156,7 +156,10 @@ def post_validate(self):
             self.is_food = bool(self.food)
             self.disable_amount = not self.is_food
 
-        # format the display property
+        return self
+
+    @model_validator(mode="after")
+    def format_display(self):
         if not self.display:
             self.display = self._format_display()
 


Test patch:
diff --git a/tests/unit_tests/schema_tests/test_shopping_list_ingredient.py b/tests/unit_tests/schema_tests/test_shopping_list_ingredient.py
new file mode 100644
index 00000000000..408c25e8cb6
--- /dev/null
+++ b/tests/unit_tests/schema_tests/test_shopping_list_ingredient.py
@@ -0,0 +1,37 @@
+from mealie.schema.group.group_shopping_list import ShoppingListItemOut
+
+
+def test_shopping_list_ingredient_validation():
+    db_obj = {
+        "quantity": 8,
+        "unit": None,
+        "food": {
+            "id": "4cf32eeb-d136-472d-86c7-287b6328d21f",
+            "name": "bell peppers",
+            "pluralName": None,
+            "description": "",
+            "extras": {},
+            "labelId": None,
+            "aliases": [],
+            "label": None,
+            "createdAt": "2024-02-26T18:29:46.190754",
+            "updateAt": "2024-02-26T18:29:46.190758",
+        },
+        "note": "",
+        "isFood": True,
+        "disableAmount": False,
+        "shoppingListId": "dc8bce82-2da9-49f0-94e6-6d69d311490e",
+        "checked": False,
+        "position": 5,
+        "foodId": "4cf32eeb-d136-472d-86c7-287b6328d21f",
+        "labelId": None,
+        "unitId": None,
+        "extras": {},
+        "id": "80f4df25-6139-4d30-be0c-4100f50e5396",
+        "label": None,
+        "recipeReferences": [],
+        "createdAt": "2024-02-27T10:18:19.274677",
+        "updateAt": "2024-02-27T11:26:32.643392",
+    }
+    out = ShoppingListItemOut.model_validate(db_obj)
+    assert out.display == "8 bell peppers"