Repository: boxwise/boxtribute
Pull request #2149: Update back-end when re-enabling standard-product
Author: login: pylipp
Created: 2025-05-08T15:26:50Z | Merged: 2025-05-16T12:49:34Z

- Change logic to find already enabled standard product instantiation
- Re-use previous instantiation when enabling standard product
- Set created_* instead of last_modified_* fields in re-enabled instantiation



Diff:
diff --git a/back/boxtribute_server/business_logic/warehouse/product/crud.py b/back/boxtribute_server/business_logic/warehouse/product/crud.py
index 6c725e5c99..e49ab117d0 100644
--- a/back/boxtribute_server/business_logic/warehouse/product/crud.py
+++ b/back/boxtribute_server/business_logic/warehouse/product/crud.py
@@ -1,5 +1,7 @@
 from dataclasses import dataclass
 
+from sentry_sdk import capture_message as emit_sentry_message
+
 from ....db import db
 from ....enums import BoxState, ProductType
 from ....errors import (
@@ -171,34 +173,69 @@ def enable_standard_product(
     if price < 0:
         return InvalidPrice(value=price)
 
-    if (
-        product_id := Product.get_or_none(
+    # Find previous instantiations
+    previous_instantiations = (
+        Product.select()
+        .where(
             Product.base == base_id,
-            Product.deleted_on.is_null(),
             Product.standard_product == standard_product_id,
         )
-    ) is not None:
-        return StandardProductAlreadyEnabledForBase(product_id=product_id)
+        .order_by(Product.deleted_on.desc())
+    )
+    # Add data integrity check
+    if len(previous_instantiations) > 1:  # pragma: no cover
+        emit_sentry_message(
+            "Invalid standard product instantiation data",
+            level="warning",
+            extras={
+                "base_id": base_id,
+                "standard_product_id": standard_product_id,
+                "instantiations": [
+                    {"id": p.id, "deleted_on": p.deleted_on}
+                    for p in previous_instantiations
+                ],
+            },
+        )
+    # Check if there's still an enabled one
+    enabled_instantiation_ids = [
+        p.id for p in previous_instantiations if p.deleted_on is None
+    ]
+    if enabled_instantiation_ids:
+        return StandardProductAlreadyEnabledForBase(
+            product_id=enabled_instantiation_ids[0]
+        )
 
     standard_product = StandardProduct.get_by_id(standard_product_id)
 
     if standard_product.superceded_by_product_id is not None:
         return OutdatedStandardProductVersion(standard_product.superceded_by_product_id)
 
-    product = Product()
-    product.base = base_id
-    product.category = standard_product.category_id
-    product.gender = standard_product.gender_id
-    product.name = standard_product.name
+    # If available, re-use most recent disabled instantiation. This will create another
+    # "Record created" history entry for the same product ID
+    if previous_instantiations:
+        product = previous_instantiations[0]
+        product.deleted_on = None
+        product.last_modified_on = now
+        product.last_modified_by = user_id
+
+    else:
+        product = Product()
+        product.base = base_id
+        product.category = standard_product.category_id
+        product.gender = standard_product.gender_id
+        product.name = standard_product.name
+        product.created_on = now
+        product.created_by = user_id
+        product.standard_product = standard_product
+
+    # When re-enabling a previous instantiation, re-set input values
     product.size_range = (
         standard_product.size_range_id if size_range_id is None else size_range_id
     )
     product.price = price
     product.comment = comment
     product.in_shop = in_shop
-    product.created_on = now
-    product.created_by = user_id
-    product.standard_product = standard_product
+
     with db.database.atomic():
         product.save()
         return product
diff --git a/back/test/endpoint_tests/test_product.py b/back/test/endpoint_tests/test_product.py
index 322c0fe8d1..2888a479ca 100644
--- a/back/test/endpoint_tests/test_product.py
+++ b/back/test/endpoint_tests/test_product.py
@@ -580,6 +580,33 @@ def test_standard_product_instantiation_mutations(
     assert response["lastModifiedOn"] is None
     assert response["lastModifiedBy"] is None
 
+    # Test case 8.2.60: Re-enable previously disabled instantiation
+    mutation = _enable_mutation(enable_input)
+    created_product = assert_successful_request(client, mutation)
+    assert created_product.pop("createdOn").startswith(today)
+    assert created_product.pop("lastModifiedOn").startswith(today)
+    assert created_product == {
+        "id": product_id,
+        "name": another_standard_product["name"],
+        "type": ProductType.StandardInstantiation.name,
+        "category": {"id": str(another_standard_product["category"])},
+        "sizeRange": {"id": str(another_standard_product["size_range"])},
+        "gender": ProductGender(another_standard_product["gender"]).name,
+        "base": {"id": base_id},
+        "price": 0.0,
+        "comment": None,
+        "inShop": False,
+        "createdBy": {"id": user_id},
+        "lastModifiedBy": {"id": user_id},
+        "deletedOn": None,
+    }
+    # Disable again such that test case 8.2.61 doesn't fail due to
+    # StandardProductAlreadyEnabledForBaseError
+    mutation = f"""mutation {{ disableStandardProduct(instantiationId: {product_id}) {{
+                    ...on Product {{ deletedOn }} }} }}"""
+    response = assert_successful_request(client, mutation)
+    assert response["deletedOn"].startswith(today)
+
     # Test case 8.2.85
     custom_product_id = str(default_product["id"])
     mutation = f"""mutation {{
@@ -603,9 +630,10 @@ def test_standard_product_instantiation_mutations(
             }}"""
     mutation = _enable_mutation(enable_input)
     created_product = assert_successful_request(client, mutation)
-    another_product_id = created_product.pop("id")
     assert created_product.pop("createdOn").startswith(today)
+    assert created_product.pop("lastModifiedOn").startswith(today)
     assert created_product == {
+        "id": product_id,
         "name": another_standard_product["name"],
         "type": ProductType.StandardInstantiation.name,
         "category": {"id": str(another_standard_product["category"])},
@@ -616,8 +644,7 @@ def test_standard_product_instantiation_mutations(
         "comment": comment,
         "inShop": in_shop,
         "createdBy": {"id": user_id},
-        "lastModifiedBy": None,
-        "lastModifiedOn": None,
+        "lastModifiedBy": {"id": user_id},
         "deletedOn": None,
     }
 
@@ -661,7 +688,7 @@ def _create_update_mutation(field, value):
         if isinstance(value, bool):
             value = "true" if value else "false"
 
-        update_input = f"id: {another_product_id}, {field}: {value}"
+        update_input = f"id: {product_id}, {field}: {value}"
         if field.endswith("Id"):
             field = f"{field.rstrip('Id')} {{ id }}"
         return f"""mutation {{
@@ -691,7 +718,7 @@ def _create_update_mutation(field, value):
     assert response == {"inShop": in_shop}
 
     # Verify update of last_modified_* fields
-    query = f"""query {{ product(id: {another_product_id}) {{
+    query = f"""query {{ product(id: {product_id}) {{
                     lastModifiedOn
                     lastModifiedBy {{ id }}
                 }} }}"""
@@ -759,31 +786,43 @@ def _create_update_mutation(field, value):
         },
         {
             "changes": HISTORY_CREATION_MESSAGE,
-            "record_id": int(another_product_id),
+            "record_id": int(product_id),
+            "from_int": None,
+            "to_int": None,
+        },
+        {
+            "changes": HISTORY_DELETION_MESSAGE,
+            "record_id": int(product_id),
+            "from_int": None,
+            "to_int": None,
+        },
+        {
+            "changes": HISTORY_CREATION_MESSAGE,
+            "record_id": int(product_id),
             "from_int": None,
             "to_int": None,
         },
         {
             "changes": "sizegroup_id",
-            "record_id": int(another_product_id),
+            "record_id": int(product_id),
             "from_int": 1,
             "to_int": 2,
         },
         {
             "changes": "value",
-            "record_id": int(another_product_id),
+            "record_id": int(product_id),
             "from_int": 12,
             "to_int": 40,
         },
         {
             "changes": 'comments changed from "new" to "from Germany";',
-            "record_id": int(another_product_id),
+            "record_id": int(product_id),
             "from_int": None,
             "to_int": None,
         },
         {
             "changes": "stockincontainer",
-            "record_id": int(another_product_id),
+            "record_id": int(product_id),
             "from_int": 1,
             "to_int": 0,
         },


Reviews:
user: login: HaGuesto
body: # coding review passed

@pylipp will react to the two small comments.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T11:49:22Z
user: login: HaGuesto
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T11:54:18Z

Review comments:
diff_hunk: @@ -171,25 +171,43 @@ def enable_standard_product(
     if price < 0:
         return InvalidPrice(value=price)
 
-    if (
-        product_id := Product.get_or_none(
+    # Find previous instantiations
+    previous_instantiations = (
+        Product.select()
+        .where(
             Product.base == base_id,
-            Product.deleted_on.is_null(),
             Product.standard_product == standard_product_id,
         )
-    ) is not None:
-        return StandardProductAlreadyEnabledForBase(product_id=product_id)
+        .order_by(Product.deleted_on.desc())
+    )
+    # Check if there's still an enabled one
+    enabled_instantiation_ids = [
+        p.id for p in previous_instantiations if p.deleted_on is None
+    ]
path: back/boxtribute_server/business_logic/warehouse/product/crud.py
user: login: HaGuesto
body: add notification for us if enabled_instantiation_ids has more than one entry
created_at: 2025-05-16T11:47:53Z
author_association: MEMBER
diff_hunk: @@ -171,25 +171,43 @@ def enable_standard_product(
     if price < 0:
         return InvalidPrice(value=price)
 
-    if (
-        product_id := Product.get_or_none(
+    # Find previous instantiations
+    previous_instantiations = (
path: back/boxtribute_server/business_logic/warehouse/product/crud.py
user: login: HaGuesto
body: this should be max. 1 maybe add silent notfication to sentry if there are more than one
created_at: 2025-05-16T11:48:25Z
author_association: MEMBER
diff_hunk: @@ -171,25 +171,43 @@ def enable_standard_product(
     if price < 0:
         return InvalidPrice(value=price)
 
-    if (
-        product_id := Product.get_or_none(
+    # Find previous instantiations
+    previous_instantiations = (
+        Product.select()
+        .where(
             Product.base == base_id,
-            Product.deleted_on.is_null(),
             Product.standard_product == standard_product_id,
         )
-    ) is not None:
-        return StandardProductAlreadyEnabledForBase(product_id=product_id)
+        .order_by(Product.deleted_on.desc())
+    )
+    # Check if there's still an enabled one
+    enabled_instantiation_ids = [
+        p.id for p in previous_instantiations if p.deleted_on is None
+    ]
+    if enabled_instantiation_ids:
+        return StandardProductAlreadyEnabledForBase(
+            product_id=enabled_instantiation_ids[0]
+        )
 
     standard_product = StandardProduct.get_by_id(standard_product_id)
 
     if standard_product.superceded_by_product_id is not None:
         return OutdatedStandardProductVersion(standard_product.superceded_by_product_id)
 
-    product = Product()
-    product.base = base_id
-    product.category = standard_product.category_id
-    product.gender = standard_product.gender_id
-    product.name = standard_product.name
+    # If available, re-use most recent disabled instantiation
+    if previous_instantiations:
+        product = previous_instantiations[0]
+        product.deleted_on = None
+
path: back/boxtribute_server/business_logic/warehouse/product/crud.py
user: login: HaGuesto
body: add last modified
created_at: 2025-05-16T11:54:18Z
author_association: MEMBER

PR comments:
user: login: pylipp
created_at: 2025-05-08T15:27:49Z
body: https://trello.com/c/o4qB07KG/1748-20-be-re-enable-an-standard-product-small
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-08T15:35:13Z
body: ## [Codecov](https://app.codecov.io/gh/boxwise/boxtribute/pull/2149?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 77.32%. Comparing base [(`15e724e`)](https://app.codecov.io/gh/boxwise/boxtribute/commit/15e724e9e8c0a3e555970ad33287892db16184d5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise) to head [(`682bd61`)](https://app.codecov.io/gh/boxwise/boxtribute/commit/682bd61277bce0bf5210bec3b38ca5a9cad8d80c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise).
> Report is 4 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #2149   +/-   ##
=======================================
  Coverage   77.31%   77.32%           
=======================================
  Files         269      269           
  Lines       18157    18165    +8     
  Branches     1743     1744    +1     
=======================================
+ Hits        14038    14046    +8     
  Misses       4081     4081           
  Partials       38       38           
```

| [Flag](https://app.codecov.io/gh/boxwise/boxtribute/pull/2149/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise) | Coverage Δ | |
|---|---|---|
| [backend](https://app.codecov.io/gh/boxwise/boxtribute/pull/2149/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise) | `99.66% <ø> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/boxwise/boxtribute/pull/2149?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=boxwise).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE