|
10 | 10 | from django.utils.text import slugify |
11 | 11 | from mitol.common.models import TimestampedModel |
12 | 12 | from mitol.common.utils import now_in_utc |
| 13 | +from modelcluster.fields import ParentalKey |
13 | 14 | from requests.exceptions import HTTPError |
14 | | -from wagtail.admin.panels import FieldPanel, MultiFieldPanel |
| 15 | +from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel |
15 | 16 | from wagtail.fields import RichTextField |
16 | | -from wagtail.models import Page |
| 17 | +from wagtail.models import ClusterableModel, Orderable, Page |
17 | 18 |
|
18 | 19 | from b2b.constants import ( |
19 | 20 | CONTRACT_MEMBERSHIP_AUTOS, |
|
24 | 25 | ) |
25 | 26 | from b2b.exceptions import TargetCourseRunExistsError |
26 | 27 | from b2b.tasks import queue_enrollment_code_check |
| 28 | +from courses.models import Program |
27 | 29 |
|
28 | 30 | log = logging.getLogger(__name__) |
29 | 31 |
|
@@ -188,7 +190,53 @@ class Meta: |
188 | 190 | ] |
189 | 191 |
|
190 | 192 |
|
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): |
192 | 240 | """Stores information about a contract with an organization.""" |
193 | 241 |
|
194 | 242 | parent_page_types = ["b2b.OrganizationPage"] |
@@ -248,11 +296,16 @@ class ContractPage(Page): |
248 | 296 | null=True, |
249 | 297 | help_text="The fixed price for enrollment under this contract. (Set to zero or leave blank for free.)", |
250 | 298 | ) |
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 | + ) |
256 | 309 |
|
257 | 310 | content_panels = [ |
258 | 311 | FieldPanel("name"), |
@@ -285,6 +338,11 @@ class ContractPage(Page): |
285 | 338 | heading="Availability", |
286 | 339 | icon="calendar-alt", |
287 | 340 | ), |
| 341 | + InlinePanel( |
| 342 | + "contract_programs", |
| 343 | + heading="Programs", |
| 344 | + help_text="Add and order programs in this contract", |
| 345 | + ), |
288 | 346 | ] |
289 | 347 |
|
290 | 348 | promote_panels = [] |
@@ -377,7 +435,7 @@ def get_discounts(self): |
377 | 435 |
|
378 | 436 | return Discount.objects.filter(products__product__in=self.get_products()).all() |
379 | 437 |
|
380 | | - def add_program_courses(self, program): |
| 438 | + def add_program_courses(self, program, order=None): |
381 | 439 | """ |
382 | 440 | Add a program, and then queue adding all its courses. |
383 | 441 |
|
@@ -414,7 +472,17 @@ def add_program_courses(self, program): |
414 | 472 | except TargetCourseRunExistsError: # noqa: PERF203 |
415 | 473 | skipped_run_creation += 1 |
416 | 474 |
|
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) |
418 | 486 |
|
419 | 487 | return (managed, skipped_run_creation, no_source) |
420 | 488 |
|
|
0 commit comments