From 11bedbed27ec82a3ba2ac44fd552152039cb4c1e Mon Sep 17 00:00:00 2001 From: Joe Russack Date: Wed, 10 Jun 2026 13:59:52 -0700 Subject: [PATCH 1/2] fix: enforce loan preparation availability on insert The #7665 fix added an early return that skipped the loanprep_quantity_must_be_lte_availability rule whenever the row was new, disabling server-side over-loan protection on every creation path (Add Unassociated Item, REST API, WorkBench uploads). Remove the early return so the rule validates inserts again, matching the gift and exchangeout sibling rules. The null-safe int(x or 0) handling that actually fixed #7665 is unchanged. --- .../businessrules/rules/interaction_rules.py | 3 - .../tests/test_loanpreparation.py | 104 ++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 specifyweb/backend/businessrules/tests/test_loanpreparation.py diff --git a/specifyweb/backend/businessrules/rules/interaction_rules.py b/specifyweb/backend/businessrules/rules/interaction_rules.py index 23b79bb375f..d93ec58fbef 100644 --- a/specifyweb/backend/businessrules/rules/interaction_rules.py +++ b/specifyweb/backend/businessrules/rules/interaction_rules.py @@ -29,9 +29,6 @@ def get_availability(prep, iprepid, iprepid_fld): @orm_signal_handler('pre_save', 'Loanpreparation') def loanprep_quantity_must_be_lte_availability(ipreparation): - if ipreparation.id is None: - return - if ipreparation.preparation is not None: available = get_availability( ipreparation.preparation, ipreparation.id, "loanpreparationid") or 0 diff --git a/specifyweb/backend/businessrules/tests/test_loanpreparation.py b/specifyweb/backend/businessrules/tests/test_loanpreparation.py new file mode 100644 index 00000000000..f44280833df --- /dev/null +++ b/specifyweb/backend/businessrules/tests/test_loanpreparation.py @@ -0,0 +1,104 @@ +from specifyweb.specify import models +from specifyweb.specify.tests.test_api import ApiTests +from ..exceptions import BusinessRuleException + + +class LoanPreparationTests(ApiTests): + def setUp(self): + super().setUp() + self.preptype = models.Preptype.objects.create( + name='testPrepType', + isloanable=True, + collection=self.collection, + ) + self.preparation = models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + preptype=self.preptype, + countamt=1, + ) + self.loan_a = models.Loan.objects.create( + loannumber='1', + discipline=self.discipline) + self.loan_b = models.Loan.objects.create( + loannumber='2', + discipline=self.discipline) + + def test_insert_cannot_exceed_availability(self): + models.Loanpreparation.objects.create( + loan=self.loan_a, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + with self.assertRaises(BusinessRuleException): + models.Loanpreparation.objects.create( + loan=self.loan_b, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + def test_insert_within_availability(self): + self.preparation.countamt = 2 + self.preparation.save() + + models.Loanpreparation.objects.create( + loan=self.loan_a, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + models.Loanpreparation.objects.create( + loan=self.loan_b, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + def test_resolved_loan_frees_availability(self): + models.Loanpreparation.objects.create( + loan=self.loan_a, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=1, + isresolved=True) + + models.Loanpreparation.objects.create( + loan=self.loan_b, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + def test_null_quantityresolved_treated_as_zero(self): + # A null Quantity Resolved must not crash the rule (#7665)... + models.Loanpreparation.objects.create( + loan=self.loan_a, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=None) + + # ...and the loan still counts as fully unresolved. + with self.assertRaises(BusinessRuleException): + models.Loanpreparation.objects.create( + loan=self.loan_b, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=None) + + def test_update_cannot_exceed_availability(self): + lp = models.Loanpreparation.objects.create( + loan=self.loan_a, + discipline=self.discipline, + preparation=self.preparation, + quantity=1, + quantityresolved=0) + + lp.quantity = 2 + with self.assertRaises(BusinessRuleException): + lp.save() From b5811e8de23ba93ff30591504c45726ba44390c5 Mon Sep 17 00:00:00 2001 From: Joe Russack Date: Wed, 10 Jun 2026 14:23:55 -0700 Subject: [PATCH 2/2] fix: treat null loan quantities as zero in availability SQL A loanpreparation row with a NULL quantityresolved (e.g. created by a WorkBench upload with Quantity Resolved unmapped) made the whole quantity-quantityresolved term NULL, so the row contributed nothing to the amount on loan and availability was overstated. Coalesce both fields to 0, matching the int(x or 0) handling on the Python side. --- specifyweb/backend/businessrules/rules/interaction_rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/businessrules/rules/interaction_rules.py b/specifyweb/backend/businessrules/rules/interaction_rules.py index d93ec58fbef..9eea53ea0f8 100644 --- a/specifyweb/backend/businessrules/rules/interaction_rules.py +++ b/specifyweb/backend/businessrules/rules/interaction_rules.py @@ -6,7 +6,7 @@ def get_availability(prep, iprepid, iprepid_fld): args = [prep.id] sql = """ - select p.countAmt - coalesce(sum(lp.quantity-lp.quantityresolved),0) - coalesce(sum(gp.quantity),0) - coalesce(sum(ep.quantity),0) + select p.countAmt - coalesce(sum(coalesce(lp.quantity,0)-coalesce(lp.quantityresolved,0)),0) - coalesce(sum(gp.quantity),0) - coalesce(sum(ep.quantity),0) from preparation p left join loanpreparation lp on lp.preparationid = p.preparationid left join giftpreparation gp on gp.preparationid = p.preparationid