Skip to content

Commit f1e208e

Browse files
authored
ordered ContractPage programs (#3079)
1 parent d37727e commit f1e208e

File tree

15 files changed

+361
-24
lines changed

15 files changed

+361
-24
lines changed

b2b/admin.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from b2b.models import (
66
ContractPage,
7+
ContractProgramItem,
78
DiscountContractAttachmentRedemption,
89
OrganizationPage,
910
)
@@ -46,10 +47,12 @@ class DiscountContractAttachmentRedemptionAdmin(ReadOnlyModelAdmin):
4647
class ContractPageProgramInline(admin.TabularInline):
4748
"""Inline to display programs for contract pages."""
4849

49-
model = ContractPage.programs.through
50+
model = ContractProgramItem
5051
extra = 0
5152
verbose_name = "Contract Program"
5253
verbose_name_plural = "Contract Programs"
54+
fields = ["program", "sort_order"]
55+
readonly_fields = ["program", "sort_order"]
5356

5457
def has_add_permission(self, request, obj): # noqa: ARG002
5558
"""Turn off add permission. These admins are supposed to be read-only."""

b2b/management/commands/b2b_courseware.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.core.management import BaseCommand, CommandError
1111

1212
from b2b.api import create_contract_run
13-
from b2b.models import ContractPage
13+
from b2b.models import ContractPage, ContractProgramItem
1414
from courses.api import resolve_courseware_object_from_id
1515
from courses.models import CourseRun
1616

@@ -159,7 +159,6 @@ def handle_add(self, contract, coursewares, **kwargs):
159159
contract.save()
160160
managed += prog_add
161161
skipped += prog_skip
162-
contract.programs.add(courseware)
163162
self.stdout.write(
164163
self.style.SUCCESS(f"Added {courseware.readable_id} to {contract}.")
165164
)
@@ -245,7 +244,9 @@ def handle_remove(self, contract, coursewares, **kwargs):
245244
)
246245
)
247246

248-
contract.programs.remove(courseware)
247+
ContractProgramItem.objects.filter(
248+
contract=contract, program=courseware
249+
).delete()
249250
self.stdout.write(
250251
self.style.SUCCESS(
251252
f"Removed program {courseware.readable_id} from contract {contract}."
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 4.2.25 on 2025-11-07 18:37
2+
3+
import django.db.models.deletion
4+
import modelcluster.fields
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("courses", "0074_populate_issue_date"),
11+
("b2b", "0013_fix_contract_attachment_related_names"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="ContractProgramItem",
17+
fields=[
18+
(
19+
"id",
20+
models.BigAutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
(
28+
"sort_order",
29+
models.IntegerField(blank=True, editable=False, null=True),
30+
),
31+
(
32+
"contract",
33+
modelcluster.fields.ParentalKey(
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="contract_programs",
36+
to="b2b.contractpage",
37+
),
38+
),
39+
(
40+
"program",
41+
models.ForeignKey(
42+
on_delete=django.db.models.deletion.CASCADE,
43+
related_name="contract_memberships",
44+
to="courses.program",
45+
),
46+
),
47+
],
48+
options={
49+
"verbose_name": "Contract Program Item",
50+
"verbose_name_plural": "Contract Program Items",
51+
"ordering": ["sort_order"],
52+
"unique_together": {("contract", "program")},
53+
},
54+
),
55+
]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated migration to move programs from M2M to ContractProgramItem
2+
3+
from django.db import migrations
4+
5+
6+
def migrate_programs_to_items(apps, schema_editor):
7+
"""Migrate existing programs from M2M relationship to ContractProgramItem"""
8+
ContractPage = apps.get_model("b2b", "ContractPage")
9+
ContractProgramItem = apps.get_model("b2b", "ContractProgramItem")
10+
11+
for contract in ContractPage.objects.all():
12+
# Get existing programs from the M2M relationship
13+
programs = contract.programs.all()
14+
15+
# Create ContractProgramItem entries for each program
16+
for index, program in enumerate(programs):
17+
ContractProgramItem.objects.get_or_create(
18+
contract=contract, program=program, defaults={"sort_order": index}
19+
)
20+
21+
22+
def reverse_migration(apps, schema_editor):
23+
"""Reverse: copy data back from ContractProgramItem to M2M"""
24+
ContractPage = apps.get_model("b2b", "ContractPage")
25+
ContractProgramItem = apps.get_model("b2b", "ContractProgramItem")
26+
27+
for contract in ContractPage.objects.all():
28+
# Get programs from ContractProgramItem
29+
items = ContractProgramItem.objects.filter(contract=contract).order_by(
30+
"sort_order"
31+
)
32+
33+
# Add them back to the M2M relationship
34+
for item in items:
35+
contract.programs.add(item.program)
36+
37+
38+
class Migration(migrations.Migration):
39+
dependencies = [
40+
("b2b", "0014_contractprogramitem"),
41+
]
42+
43+
operations = [
44+
migrations.RunPython(migrate_programs_to_items, reverse_migration),
45+
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Generated migration to remove old programs M2M field
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("b2b", "0015_migrate_programs_to_contractprogramitem"),
9+
]
10+
11+
operations = [
12+
migrations.RemoveField(
13+
model_name="contractpage",
14+
name="programs",
15+
),
16+
]

b2b/models.py

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
from django.utils.text import slugify
1111
from mitol.common.models import TimestampedModel
1212
from mitol.common.utils import now_in_utc
13+
from modelcluster.fields import ParentalKey
1314
from requests.exceptions import HTTPError
14-
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
15+
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
1516
from wagtail.fields import RichTextField
16-
from wagtail.models import Page
17+
from wagtail.models import ClusterableModel, Orderable, Page
1718

1819
from b2b.constants import (
1920
CONTRACT_MEMBERSHIP_AUTOS,
@@ -24,6 +25,7 @@
2425
)
2526
from b2b.exceptions import TargetCourseRunExistsError
2627
from b2b.tasks import queue_enrollment_code_check
28+
from courses.models import Program
2729

2830
log = logging.getLogger(__name__)
2931

@@ -188,7 +190,53 @@ class Meta:
188190
]
189191

190192

191-
class ContractPage(Page):
193+
class ContractProgramItem(Orderable):
194+
"""Intermediate model to store programs in a contract with ordering"""
195+
196+
contract = ParentalKey(
197+
"ContractPage", on_delete=models.CASCADE, related_name="contract_programs"
198+
)
199+
program = models.ForeignKey(
200+
Program, on_delete=models.CASCADE, related_name="contract_memberships"
201+
)
202+
203+
panels = [
204+
FieldPanel("program"),
205+
]
206+
207+
class Meta:
208+
ordering = ["sort_order"]
209+
unique_together = ("contract", "program")
210+
verbose_name = "Contract Program Item"
211+
verbose_name_plural = "Contract Program Items"
212+
213+
def __str__(self):
214+
return (
215+
f"{self.contract.title} - {self.program.title} (order: {self.sort_order})"
216+
)
217+
218+
def save(self, *args, skip_run_creation=False, **kwargs):
219+
"""
220+
Queue async task to create contract runs for new program associations.
221+
222+
Args:
223+
skip_run_creation: Skip task if runs are created synchronously.
224+
"""
225+
is_new = self.pk is None
226+
super().save(*args, **kwargs)
227+
228+
if is_new and not skip_run_creation:
229+
from b2b.tasks import create_program_contract_runs
230+
231+
create_program_contract_runs.delay(self.contract.id, self.program.id)
232+
log.info(
233+
"Queued contract run creation for program %s in contract %s",
234+
self.program.id,
235+
self.contract.id,
236+
)
237+
238+
239+
class ContractPage(Page, ClusterableModel):
192240
"""Stores information about a contract with an organization."""
193241

194242
parent_page_types = ["b2b.OrganizationPage"]
@@ -248,11 +296,16 @@ class ContractPage(Page):
248296
null=True,
249297
help_text="The fixed price for enrollment under this contract. (Set to zero or leave blank for free.)",
250298
)
251-
programs = models.ManyToManyField(
252-
"courses.Program",
253-
help_text="The programs associated with this contract.",
254-
related_name="contracts",
255-
)
299+
300+
@property
301+
def programs(self):
302+
"""
303+
Returns programs in the contract ordered by their order field.
304+
This replaces the old ManyToManyField with ordered access via ContractProgramItem.
305+
"""
306+
return Program.objects.filter(contract_memberships__contract=self).order_by(
307+
"contract_memberships__sort_order"
308+
)
256309

257310
content_panels = [
258311
FieldPanel("name"),
@@ -285,6 +338,11 @@ class ContractPage(Page):
285338
heading="Availability",
286339
icon="calendar-alt",
287340
),
341+
InlinePanel(
342+
"contract_programs",
343+
heading="Programs",
344+
help_text="Add and order programs in this contract",
345+
),
288346
]
289347

290348
promote_panels = []
@@ -377,7 +435,7 @@ def get_discounts(self):
377435

378436
return Discount.objects.filter(products__product__in=self.get_products()).all()
379437

380-
def add_program_courses(self, program):
438+
def add_program_courses(self, program, order=None):
381439
"""
382440
Add a program, and then queue adding all its courses.
383441
@@ -414,7 +472,17 @@ def add_program_courses(self, program):
414472
except TargetCourseRunExistsError: # noqa: PERF203
415473
skipped_run_creation += 1
416474

417-
self.programs.add(program)
475+
if order is None:
476+
last_item = self.contract_programs.order_by("-sort_order").first()
477+
order = (last_item.sort_order + 1) if last_item else 0
478+
479+
existing_item = ContractProgramItem.objects.filter(
480+
contract=self, program=program
481+
).first()
482+
483+
if not existing_item:
484+
item = ContractProgramItem(contract=self, program=program, sort_order=order)
485+
item.save(skip_run_creation=True)
418486

419487
return (managed, skipped_run_creation, no_source)
420488

b2b/serializers/v0/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ class ContractPageSerializer(serializers.ModelSerializer):
1414
"""
1515

1616
membership_type = serializers.CharField()
17+
programs = serializers.SerializerMethodField()
18+
19+
@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
20+
def get_programs(self, instance):
21+
"""Get the ordered list of program IDs for this contract"""
22+
return list(instance.programs.values_list("id", flat=True))
1723

1824
class Meta:
1925
model = ContractPage
@@ -31,6 +37,7 @@ class Meta:
3137
"active",
3238
"slug",
3339
"organization",
40+
"programs",
3441
]
3542
read_only_fields = [
3643
"id",
@@ -46,6 +53,7 @@ class Meta:
4653
"active",
4754
"slug",
4855
"organization",
56+
"programs",
4957
]
5058

5159

0 commit comments

Comments
 (0)