Skip to content

Commit 6bdbea1

Browse files
[Offering] Partnership: Account Benefit registration code (#2935)
* feat(#2924): Account Benefit registration code required unless linked to Partnership * feat(#2924): Fix tests * fix(#2924): Fix faker
1 parent e530402 commit 6bdbea1

File tree

12 files changed

+206
-6
lines changed

12 files changed

+206
-6
lines changed

src/extrequests/tests/test_forms.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ def test_clean__valid_data__benefit(self) -> None:
377377
account_benefit = AccountBenefit.objects.create(
378378
account=account,
379379
benefit=benefit,
380+
registration_code="test-code",
380381
start_date=date.today() - timedelta(days=1),
381382
end_date=date.today() + timedelta(days=365),
382383
allocation=2,
@@ -451,6 +452,7 @@ def test_clean__benefit_with_membership_or_auto_assign(self) -> None:
451452
account_benefit = AccountBenefit.objects.create(
452453
account=account,
453454
benefit=benefit,
455+
registration_code="test-code",
454456
start_date=date.today() - timedelta(days=1),
455457
end_date=date.today() + timedelta(days=365),
456458
allocation=2,
@@ -504,6 +506,7 @@ def test_clean__auto_assign_with_membership_or_benefit(self) -> None:
504506
account_benefit = AccountBenefit.objects.create(
505507
account=account,
506508
benefit=benefit,
509+
registration_code="test-code",
507510
start_date=date.today() - timedelta(days=1),
508511
end_date=date.today() + timedelta(days=365),
509512
allocation=2,
@@ -570,6 +573,7 @@ def test_clean__benefit_override(self) -> None:
570573
account_benefit = AccountBenefit.objects.create(
571574
account=account,
572575
benefit=benefit,
576+
registration_code="test-code",
573577
start_date=date.today() - timedelta(days=1),
574578
end_date=date.today() + timedelta(days=365),
575579
allocation=2,

src/extrequests/tests/test_training_request.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ def test_successful_matching_to_allocated_benefit(self) -> None:
414414
account_benefit = AccountBenefit.objects.create(
415415
account=account,
416416
benefit=benefit,
417+
registration_code="test-code",
417418
allocation=5,
418419
start_date=date.today(),
419420
end_date=date.today() + timedelta(days=365),

src/extrequests/tests/test_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ def test_creates_task_linked_to_benefit(self) -> None:
429429
account_benefit = AccountBenefit.objects.create(
430430
account=account,
431431
benefit=benefit,
432+
registration_code="test-code",
432433
start_date=date.today() - timedelta(days=1),
433434
end_date=date.today() + timedelta(days=30),
434435
allocation=1,
@@ -682,6 +683,7 @@ def make_account_benefit(
682683
return AccountBenefit.objects.create(
683684
account=self.account,
684685
benefit=self.benefit,
686+
registration_code="test-code",
685687
start_date=date.today() + timedelta(days=start_offset),
686688
end_date=date.today() + timedelta(days=end_offset),
687689
allocation=allocation,

src/offering/forms.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ def __init__(
187187
disable_account: bool = False,
188188
disable_partnership: bool = False,
189189
disable_dates: bool = False,
190+
disable_registration_code: bool = False,
190191
**kwargs: Any,
191192
) -> None:
192193
super().__init__(*args, **kwargs)
@@ -198,6 +199,8 @@ def __init__(
198199
if disable_dates:
199200
self.fields["start_date"].disabled = True
200201
self.fields["end_date"].disabled = True
202+
if disable_registration_code:
203+
self.fields["registration_code"].disabled = True
201204

202205
# If these fields are disabled, the browser won't send their values and this trips the validation
203206
# (unless we make them not required).
@@ -208,9 +211,22 @@ def clean(self) -> dict[str, Any]:
208211
cleaned_data = cast(dict[str, Any], super().clean())
209212
errors = {}
210213

211-
# Ensure unique registration code across Memberships and Partnerships.
214+
partnership = cleaned_data.get("partnership")
215+
216+
# `registration_code` is required when no partnership, and must be empty when partnership is set
212217
registration_code = cleaned_data.get("registration_code")
213-
if registration_code:
218+
# This repeats the logic from the model constraint, but allows for better error messages.
219+
if partnership and registration_code:
220+
errors["registration_code"] = ValidationError(
221+
"Registration code must be empty when a partnership is selected."
222+
)
223+
elif not partnership and not registration_code:
224+
errors["registration_code"] = ValidationError(
225+
"Registration code is required when no partnership is selected."
226+
)
227+
228+
# Ensure unique registration code across Memberships and Partnerships.
229+
elif registration_code and not partnership:
214230
existing_membership = Membership.objects.filter(registration_code=registration_code).first()
215231
if existing_membership:
216232
errors["registration_code"] = ValidationError(
@@ -224,7 +240,6 @@ def clean(self) -> dict[str, Any]:
224240

225241
# Verify if partnership belongs to the account
226242
account = cleaned_data["account"]
227-
partnership = cleaned_data["partnership"]
228243
if partnership and partnership.account != account:
229244
errors["partnership"] = ValidationError("Selected partnership does not belong to the selected account.")
230245

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Generated by Django 5.2.12 on 2026-03-15 16:06
2+
3+
from uuid import uuid4
4+
5+
from django.db import migrations, models
6+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
7+
from django.db.migrations.state import StateApps
8+
9+
10+
def set_registration_code_to_fulfill_constraint(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
11+
AccountBenefit = apps.get_model("offering", "AccountBenefit")
12+
13+
# To satisfy the new constraint, we need to ensure that all AccountBenefits linked to a partnership have a null
14+
# registration code, and all AccountBenefits not linked to a partnership have a non-null registration code.
15+
for account_benefit in AccountBenefit.objects.filter(partnership__isnull=True, registration_code__isnull=True):
16+
account_benefit.registration_code = str(uuid4())
17+
account_benefit.save()
18+
19+
for account_benefit in AccountBenefit.objects.filter(partnership__isnull=False, registration_code__isnull=False):
20+
account_benefit.registration_code = None
21+
account_benefit.save()
22+
23+
24+
class Migration(migrations.Migration):
25+
dependencies = [
26+
("fiscal", "0005_alter_partnership_tier"),
27+
("offering", "0006_accountbenefit_registration_code"),
28+
("workshops", "0292_alter_trainingrequest_member_code_and_more"),
29+
]
30+
31+
operations = [
32+
migrations.RunPython(
33+
set_registration_code_to_fulfill_constraint,
34+
migrations.RunPython.noop,
35+
),
36+
migrations.AlterField(
37+
model_name="accountbenefit",
38+
name="registration_code",
39+
field=models.CharField(
40+
blank=True,
41+
help_text="Unique registration code used for account benefit identification. "
42+
"Required if the account benefit is not linked to a partnership. If the account benefit is"
43+
" linked to a partnership, the registration code will be inherited from the partnership and cannot be"
44+
" set directly on the account benefit.",
45+
max_length=40,
46+
null=True,
47+
unique=True,
48+
verbose_name="Registration Code",
49+
),
50+
),
51+
migrations.AddConstraint(
52+
model_name="accountbenefit",
53+
constraint=models.CheckConstraint(
54+
condition=models.Q(
55+
models.Q(("partnership__isnull", False), ("registration_code__isnull", True)),
56+
models.Q(("partnership__isnull", True), ("registration_code__isnull", False)),
57+
_connector="OR",
58+
),
59+
name="check_partnership_registration_code",
60+
violation_error_message="Account Benefit, if linked to partnership, cannot have a registration code. "
61+
"If not linked to partnership, the registration code is required.",
62+
),
63+
),
64+
]

src/offering/models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,29 @@ class AccountBenefit(CreatedUpdatedMixin, models.Model):
135135
blank=True,
136136
unique=True,
137137
verbose_name="Registration Code",
138-
help_text="Unique registration code used for account benefit identification.",
138+
help_text="Unique registration code used for account benefit identification. "
139+
"Required if the account benefit is not linked to a partnership. If the account benefit is linked "
140+
"to a partnership, the registration code will be inherited from the partnership and cannot be set directly on "
141+
"the account benefit.",
139142
)
140143

141144
start_date = models.DateField()
142145
end_date = models.DateField()
143146
allocation = models.PositiveIntegerField()
144147
frozen = models.BooleanField(default=False)
145148

149+
class Meta:
150+
constraints = [
151+
# Account Benefit linked to a partnership cannot have a registration code (it should use partnership's code)
152+
models.CheckConstraint(
153+
condition=(Q(partnership__isnull=False) & Q(registration_code__isnull=True))
154+
| (Q(partnership__isnull=True) & Q(registration_code__isnull=False)),
155+
name="check_partnership_registration_code",
156+
violation_error_message="Account Benefit, if linked to partnership, cannot have a registration code. "
157+
"If not linked to partnership, the registration code is required.",
158+
),
159+
]
160+
146161
@property
147162
def human_daterange(self) -> str:
148163
return human_daterange(self.start_date, self.end_date)

src/offering/tests/test_forms.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ def test_fields_disabled(self) -> None:
5757
self.assertTrue(form.fields["start_date"].disabled)
5858
self.assertTrue(form.fields["end_date"].disabled)
5959

60+
def test_registration_code_field_disabled(self) -> None:
61+
# Act
62+
form = AccountBenefitForm(disable_registration_code=True)
63+
# Assert
64+
self.assertTrue(form.fields["registration_code"].disabled)
65+
66+
def test_registration_code_field_enabled_by_default(self) -> None:
67+
# Act
68+
form = AccountBenefitForm()
69+
# Assert
70+
self.assertFalse(form.fields["registration_code"].disabled)
71+
6072
def test_clean__valid(self) -> None:
6173
# Arrange
6274
org = Organization.objects.create(fullname="Test Org", domain="example.com")
@@ -72,6 +84,7 @@ def test_clean__valid(self) -> None:
7284
data = {
7385
"account": account.pk,
7486
"benefit": benefit.pk,
87+
"registration_code": "TESTCODE",
7588
"start_date": "2025-01-01",
7689
"end_date": "2025-12-31",
7790
"allocation": 10,
@@ -283,8 +296,73 @@ def test_clean__registration_code_unique(self) -> None:
283296
# Assert
284297
self.assertTrue(form.is_valid())
285298

299+
def test_clean__valid__with_partnership(self) -> None:
300+
"""Form is valid when partnership is set and registration code is absent."""
301+
# Arrange
302+
org = Organization.objects.create(fullname="Test Org", domain="example.com")
303+
account = Account.objects.create(
304+
account_type=Account.AccountTypeChoices.ORGANISATION,
305+
generic_relation=org,
306+
)
307+
partnership = Partnership.objects.create(
308+
name="Test Partnership",
309+
partner_organisation=org,
310+
account=account,
311+
agreement_start=date(2025, 1, 1),
312+
agreement_end=date(2025, 12, 31),
313+
credits=10,
314+
)
315+
benefit = Benefit.objects.create(name="Test Benefit", unit_type="seat", credits=2)
316+
data = {
317+
"account": account.pk,
318+
"partnership": partnership.pk,
319+
"benefit": benefit.pk,
320+
"registration_code": "",
321+
"allocation": 10,
322+
}
323+
324+
# Act
325+
form = AccountBenefitForm(data)
326+
327+
# Assert
328+
self.assertTrue(form.is_valid())
329+
330+
def test_clean__registration_code_present_with_partnership(self) -> None:
331+
"""Form is invalid when both a partnership and a registration code are provided."""
332+
# Arrange
333+
org = Organization.objects.create(fullname="Test Org", domain="example.com")
334+
account = Account.objects.create(
335+
account_type=Account.AccountTypeChoices.ORGANISATION,
336+
generic_relation=org,
337+
)
338+
partnership = Partnership.objects.create(
339+
name="Test Partnership",
340+
partner_organisation=org,
341+
account=account,
342+
agreement_start=date(2025, 1, 1),
343+
agreement_end=date(2025, 12, 31),
344+
credits=10,
345+
)
346+
benefit = Benefit.objects.create(name="Test Benefit", unit_type="seat", credits=2)
347+
data = {
348+
"account": account.pk,
349+
"partnership": partnership.pk,
350+
"benefit": benefit.pk,
351+
"registration_code": "SHOULD-NOT-BE-HERE",
352+
"allocation": 10,
353+
}
354+
355+
# Act
356+
form = AccountBenefitForm(data)
357+
358+
# Assert
359+
self.assertFalse(form.is_valid())
360+
self.assertIn(
361+
"Registration code must be empty when a partnership is selected.", form.errors["registration_code"]
362+
)
363+
286364
def test_clean__registration_code_empty(self) -> None:
287-
"""Form is valid when registration code is empty (it's optional)."""
365+
"""Form is invalid when registration code is empty and no partnership is selected."""
288366
# Arrange
289367
org = Organization.objects.create(fullname="Test Org", domain="example.com")
290368
account = Account.objects.create(
@@ -305,4 +383,5 @@ def test_clean__registration_code_empty(self) -> None:
305383
form = AccountBenefitForm(data)
306384

307385
# Assert
308-
self.assertTrue(form.is_valid())
386+
self.assertFalse(form.is_valid())
387+
self.assertIn("registration_code", form.errors)

src/offering/tests/test_views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,4 @@ def test_get_form_kwargs(self) -> None:
179179
self.assertTrue(result["disable_account"])
180180
self.assertTrue(result["disable_partnership"])
181181
self.assertTrue(result["disable_dates"])
182+
self.assertTrue(result["disable_registration_code"])

src/offering/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ def get_form_kwargs(self) -> dict[str, Any]:
351351
if kwargs.get("initial", {}).get("partnership") is not None:
352352
kwargs["disable_partnership"] = True
353353
kwargs["disable_dates"] = True
354+
kwargs["disable_registration_code"] = True
354355

355356
return kwargs
356357

src/static/offering_account_benefit_for_partnership_form.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,21 @@ jQuery(function () {
1818
id_end_date.removeAttr("disabled");
1919
}
2020
}
21+
function handleRegistrationCode(record) {
22+
const id_registration_code = $("#id_registration_code");
23+
if (record.id) {
24+
id_registration_code.attr("disabled", "disabled");
25+
id_registration_code.removeAttr("required");
26+
} else {
27+
id_registration_code.removeAttr("disabled");
28+
id_registration_code.attr("required", "required");
29+
}
30+
}
2131

2232
$("#id_partnership").on("change.select2", (e) => {
2333
const record = $(e.target).select2("data")[0];
2434
handleDateFields(record);
35+
handleRegistrationCode(record);
2536
e.preventDefault();
2637
});
2738

0 commit comments

Comments
 (0)