diff --git a/rca/enquire_to_study/tests/test_forms.py b/rca/enquire_to_study/tests/test_forms.py
index b3056e9c7..64a78e3c3 100644
--- a/rca/enquire_to_study/tests/test_forms.py
+++ b/rca/enquire_to_study/tests/test_forms.py
@@ -10,13 +10,21 @@
EnquiryFormSubmission,
EnquiryFormSubmissionProgrammesOrderable,
)
-from rca.programmes.factories import ProgrammePageFactory
+from rca.programmes.factories import (
+ ProgrammePageFactory,
+ ProgrammePageProgrammeTypeFactory,
+ ProgrammeTypeFactory,
+)
class TestEnquireToStudyForm(TestCase):
def setUp(self):
self.start_date = StartDateFactory(qs_code="test-code")
self.enquiry_reason = EnquiryReasonFactory()
+ page = ProgrammePageFactory(qs_code=1)
+ ProgrammePageProgrammeTypeFactory(
+ page=page, programme_type=ProgrammeTypeFactory()
+ )
self.form_data = {
"first_name": "Monty",
"last_name": "python",
@@ -25,7 +33,7 @@ def setUp(self):
"country_of_residence": "GB",
"city": "Bristol",
"country_of_citizenship": "GB",
- "programmes": [ProgrammePageFactory(qs_code=1, programme_type__pk=2).pk],
+ "programmes": [page.pk],
"start_date": self.start_date.pk,
"enquiry_reason": self.enquiry_reason.pk,
"enquiry_questions": "What is your name?",
diff --git a/rca/enquire_to_study/tests/test_views.py b/rca/enquire_to_study/tests/test_views.py
index 0eb18ce74..e6dfa0799 100644
--- a/rca/enquire_to_study/tests/test_views.py
+++ b/rca/enquire_to_study/tests/test_views.py
@@ -22,7 +22,11 @@
from rca.enquire_to_study.models import EnquireToStudySettings, EnquiryFormSubmission
from rca.enquire_to_study.views import EnquireToStudyFormView
from rca.enquire_to_study.wagtail_hooks import EnquiryFormSubmissionAdmin
-from rca.programmes.factories import ProgrammePageFactory
+from rca.programmes.factories import (
+ ProgrammePageFactory,
+ ProgrammePageProgrammeTypeFactory,
+ ProgrammeTypeFactory,
+)
class EnquireToStudyFormViewTest(TestCase):
@@ -54,6 +58,10 @@ def setUp(self):
email_content="Test email content",
site_id=Site.objects.get().pk,
)
+ page = ProgrammePageFactory(qs_code=1)
+ ProgrammePageProgrammeTypeFactory(
+ page=page, programme_type=ProgrammeTypeFactory()
+ )
self.form_data = {
"first_name": "Monty",
@@ -63,7 +71,7 @@ def setUp(self):
"country_of_residence": "GB",
"city": "Bristol",
"country_of_citizenship": "GB",
- "programmes": [ProgrammePageFactory(qs_code=1, programme_type__pk=2).pk],
+ "programmes": [page.pk],
"start_date": StartDateFactory(qs_code="test-code").pk,
"enquiry_reason": EnquiryReasonFactory().pk,
"enquiry_questions": "What is your name?",
diff --git a/rca/programmes/factories.py b/rca/programmes/factories.py
index 64d138b26..1fecdb8f5 100644
--- a/rca/programmes/factories.py
+++ b/rca/programmes/factories.py
@@ -2,7 +2,12 @@
import wagtail_factories
from faker import Factory as FakerFactory
-from .models import DegreeLevel, ProgrammePage, ProgrammeType
+from .models import (
+ DegreeLevel,
+ ProgrammePage,
+ ProgrammePageProgrammeType,
+ ProgrammeType,
+)
faker = FakerFactory.create()
@@ -31,4 +36,10 @@ class Meta:
scholarships_information = factory.Faker("text", max_nb_chars=100)
search_description = factory.Faker("text", max_nb_chars=25)
degree_level = factory.SubFactory(DegreeLevelFactory)
- programme_type = factory.SubFactory(ProgrammeTypeFactory)
+
+
+class ProgrammePageProgrammeTypeFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = ProgrammePageProgrammeType
+
+ page = factory.SubFactory(ProgrammePageFactory)
diff --git a/rca/programmes/migrations/0093_programmepageprogrammetype.py b/rca/programmes/migrations/0093_programmepageprogrammetype.py
new file mode 100644
index 000000000..88bed3e19
--- /dev/null
+++ b/rca/programmes/migrations/0093_programmepageprogrammetype.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.2.16 on 2025-01-14 14:23
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("programmes", "0092_link_block_url_label"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ProgrammePageProgrammeType",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "page",
+ modelcluster.fields.ParentalKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="programme_types",
+ to="programmes.programmepage",
+ ),
+ ),
+ (
+ "programme_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="programmes.programmetype",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/rca/programmes/migrations/0094_migrate_programme_type_to_programme_types.py b/rca/programmes/migrations/0094_migrate_programme_type_to_programme_types.py
new file mode 100644
index 000000000..04f4aa7d6
--- /dev/null
+++ b/rca/programmes/migrations/0094_migrate_programme_type_to_programme_types.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2025-01-14 14:25
+
+from django.db import migrations
+
+def migrate_programme_type_to_programme_types(apps, schema_editor):
+ # Get the models
+ ProgrammePage = apps.get_model("programmes", "ProgrammePage")
+ ProgrammePageProgrammeType = apps.get_model("programmes", "ProgrammePageProgrammeType")
+
+ for page in ProgrammePage.objects.all():
+ if programme_type := page.programme_type:
+ ProgrammePageProgrammeType.objects.create(
+ page_id=page.id,
+ programme_type=programme_type,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("programmes", "0093_programmepageprogrammetype"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_programme_type_to_programme_types, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/rca/programmes/migrations/0095_remove_programme_type_field.py b/rca/programmes/migrations/0095_remove_programme_type_field.py
new file mode 100644
index 000000000..9f24b10c9
--- /dev/null
+++ b/rca/programmes/migrations/0095_remove_programme_type_field.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.16 on 2025-01-17 08:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("programmes", "0094_migrate_programme_type_to_programme_types"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="programmepage",
+ name="programme_type",
+ ),
+ ]
diff --git a/rca/programmes/models.py b/rca/programmes/models.py
index 9b3d92b35..a4bf2ed14 100644
--- a/rca/programmes/models.py
+++ b/rca/programmes/models.py
@@ -113,6 +113,18 @@ def get_fake_slug(self):
return slugify(self.display_name)
+class ProgrammePageProgrammeType(models.Model):
+ page = ParentalKey("programmes.ProgrammePage", related_name="programme_types")
+ programme_type = models.ForeignKey(
+ "programmes.ProgrammeType",
+ on_delete=models.CASCADE,
+ )
+ panels = [FieldPanel("programme_type")]
+
+ def __str__(self):
+ return self.programme_type.title
+
+
class ProgrammePageRelatedSchoolsAndResearchPages(RelatedPage):
source_page = ParentalKey(
"ProgrammePage", related_name="related_schools_and_research_pages"
@@ -330,13 +342,6 @@ class ProgrammePage(TapMixin, ContactFieldsMixin, BasePage):
degree_level = models.ForeignKey(
DegreeLevel, on_delete=models.SET_NULL, blank=False, null=True, related_name="+"
)
- programme_type = models.ForeignKey(
- ProgrammeType,
- on_delete=models.SET_NULL,
- blank=False,
- null=True,
- related_name="+",
- )
hero_image = models.ForeignKey(
"images.CustomImage",
null=True,
@@ -615,10 +620,7 @@ class ProgrammePage(TapMixin, ContactFieldsMixin, BasePage):
# Taxonomy, relationships etc
FieldPanel("degree_level"),
InlinePanel("subjects", label="Subjects"),
- FieldPanel(
- "programme_type",
- help_text="Used to show content related to this programme page",
- ),
+ InlinePanel("programme_types", label="Programme Types"),
MultiFieldPanel(
[
FieldPanel("hero_image"),
@@ -844,7 +846,14 @@ class ProgrammePage(TapMixin, ContactFieldsMixin, BasePage):
index.SearchField("scholarship_accordion_items"),
index.SearchField("scholarship_information_blocks"),
index.SearchField("more_information_blocks", boost=2),
- index.RelatedFields("programme_type", [index.SearchField("display_name")]),
+ index.RelatedFields(
+ "programme_types",
+ [
+ index.RelatedFields(
+ "programme_type", [index.SearchField("display_name")]
+ )
+ ],
+ ),
index.RelatedFields(
"programme_locations",
[index.RelatedFields("programme_location", [index.SearchField("title")])],
@@ -876,7 +885,7 @@ class ProgrammePage(TapMixin, ContactFieldsMixin, BasePage):
api_fields = [
# Fields for filtering and display, shared with shortcourses.ShortCoursePage.
APIField("subjects"),
- APIField("programme_type"),
+ APIField("programme_types"),
APIField("related_schools_and_research_pages"),
APIField(
"summary",
@@ -1169,7 +1178,7 @@ def get_context(self, request, *args, **kwargs):
filters = [
{"id": "subjects", "title": "Subject", "items": subjects},
- {"id": "programme_type", "title": "Type", "items": programme_types},
+ {"id": "programme_types", "title": "Type", "items": programme_types},
{
"id": "related_schools_and_research_pages",
"title": "Schools & centres",
diff --git a/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes--large.html b/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes--large.html
index 78e60f8b0..2ce61c070 100644
--- a/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes--large.html
+++ b/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes--large.html
@@ -26,7 +26,17 @@
- {% firstof related_item.degree_level related_item.programme_type %}
+ {% with programme_types=related_item.programme_types.all %}
+ {% if related_item.degree_levels %}
+ {{ related_item.degree_level }}
+ {% else %}
+
+ {% for item in programme_types %}
+ {{ item.programme_type.display_name }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
{% firstof related_item.programme_description_subtitle related_item.introduction related_item.listing_summary %}
diff --git a/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes.html b/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes.html
index 6535ab5b4..098b86846 100644
--- a/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes.html
+++ b/rca/project_styleguide/templates/patterns/molecules/relatedcontent/relatedprogrammes.html
@@ -39,15 +39,25 @@
- {% if related_item.degree_level %}
- {{ related_item.degree_level }}
- {% elif related_item.programme_type %}
- {{ related_item.booking_summary|default:related_item.programme_type }}
- {% elif related_item.meta %}
- {{ related_item.meta }}
- {% elif related_item.get_verbose_name == 'Guide page' %}
- Guide
- {% endif %}
+ {% with programme_types=related_item.programme_types.all %}
+ {% if related_item.degree_level %}
+ {{ related_item.degree_level }}
+ {% elif programme_types %}
+ {% if related_item.booking_summary %}
+ {{ related_item.booking_summary }}
+ {% else %}
+
+ {% for item in programme_types %}
+ {{ item.programme_type.display_name }}{% if not forloop.last %}, {% endif %}
+ {% endfor %}
+
+ {% endif %}
+ {% elif related_item.meta %}
+ {{ related_item.meta }}
+ {% elif related_item.get_verbose_name == 'Guide page' %}
+ Guide
+ {% endif %}
+ {% endwith %}
{% firstof related_item.programme_description_subtitle related_item.introduction|default:''|striptags related_item.listing_summary %}
diff --git a/rca/schools/models.py b/rca/schools/models.py
index e19c095d4..8070c78b3 100644
--- a/rca/schools/models.py
+++ b/rca/schools/models.py
@@ -557,7 +557,7 @@ def get_short_courses_index_link(self):
).first()
if short_course_type:
return (
- f"{self.get_programme_index_link()}?category=programme_type&"
+ f"{self.get_programme_index_link()}?category=programme_types&"
f"value={short_course_type.id}-{slugify(short_course_type.display_name)}"
)
diff --git a/rca/shortcourses/factories.py b/rca/shortcourses/factories.py
index c839b3890..461040de8 100644
--- a/rca/shortcourses/factories.py
+++ b/rca/shortcourses/factories.py
@@ -3,7 +3,7 @@
from rca.programmes.factories import ProgrammeTypeFactory
-from .models import ShortCoursePage
+from .models import ShortCoursePage, ShortCourseProgrammeType
class ShortCoursePageFactory(wagtail_factories.PageFactory):
@@ -11,5 +11,12 @@ class Meta:
model = ShortCoursePage
title = factory.Faker("text", max_nb_chars=25)
- programme_type = factory.SubFactory(ProgrammeTypeFactory)
show_register_link = False
+
+
+class ShortCourseProgrammeTypeFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = ShortCourseProgrammeType
+
+ page = factory.SubFactory(ShortCoursePageFactory)
+ programme_type = factory.SubFactory(ProgrammeTypeFactory)
diff --git a/rca/shortcourses/migrations/0039_shortcourseprogrammetype.py b/rca/shortcourses/migrations/0039_shortcourseprogrammetype.py
new file mode 100644
index 000000000..1746294f8
--- /dev/null
+++ b/rca/shortcourses/migrations/0039_shortcourseprogrammetype.py
@@ -0,0 +1,46 @@
+# Generated by Django 4.2.16 on 2025-01-14 14:45
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("programmes", "0094_migrate_programme_type_to_programme_types"),
+ ("shortcourses", "0038_shortcoursepage_dates"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ShortCourseProgrammeType",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "page",
+ modelcluster.fields.ParentalKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="programme_types",
+ to="shortcourses.shortcoursepage",
+ ),
+ ),
+ (
+ "programme_type",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="short_course",
+ to="programmes.programmetype",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/rca/shortcourses/migrations/0040_migrate_programme_type_to_programme_types.py b/rca/shortcourses/migrations/0040_migrate_programme_type_to_programme_types.py
new file mode 100644
index 000000000..41c920c47
--- /dev/null
+++ b/rca/shortcourses/migrations/0040_migrate_programme_type_to_programme_types.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2025-01-14 14:45
+
+from django.db import migrations
+
+
+def migrate_programme_type_to_programme_types(apps, schema_editor):
+ # Get the models
+ ShortCoursePage = apps.get_model("shortcourses", "ShortCoursePage")
+ ShortCourseProgrammeType = apps.get_model("shortcourses", "ShortCourseProgrammeType")
+
+ for page in ShortCoursePage.objects.all():
+ if programme_type := page.programme_type:
+ ShortCourseProgrammeType.objects.create(
+ page_id=page.id,
+ programme_type=programme_type,
+ )
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("shortcourses", "0039_shortcourseprogrammetype"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_programme_type_to_programme_types, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/rca/shortcourses/migrations/0041_remove_programme_type_field.py b/rca/shortcourses/migrations/0041_remove_programme_type_field.py
new file mode 100644
index 000000000..1d7ebbf0f
--- /dev/null
+++ b/rca/shortcourses/migrations/0041_remove_programme_type_field.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.16 on 2025-01-17 08:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("shortcourses", "0040_migrate_programme_type_to_programme_types"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="shortcoursepage",
+ name="programme_type",
+ ),
+ ]
diff --git a/rca/shortcourses/models.py b/rca/shortcourses/models.py
index cd18ea1f5..4c0379ffe 100644
--- a/rca/shortcourses/models.py
+++ b/rca/shortcourses/models.py
@@ -23,7 +23,6 @@
from wagtail.models import Orderable
from wagtail.search import index
-from rca.programmes.models import ProgrammeType
from rca.utils.blocks import (
AccordionBlockWithTitle,
GalleryBlock,
@@ -81,6 +80,16 @@ class ShortCourseSubjectPlacement(models.Model):
panels = [FieldPanel("subject")]
+class ShortCourseProgrammeType(models.Model):
+ page = ParentalKey("ShortCoursePage", related_name="programme_types")
+ programme_type = models.ForeignKey(
+ "programmes.ProgrammeType",
+ on_delete=models.CASCADE,
+ related_name="short_course",
+ )
+ panels = [FieldPanel("programme_type")]
+
+
class ShortCourseManualDate(Orderable):
start_date = models.DateField(null=True)
end_date = models.DateField(null=True)
@@ -172,13 +181,6 @@ class ShortCoursePage(ContactFieldsMixin, BasePage):
help_text="If selected, an automatic 'Register your interest' link "
"will be visible in the key details section",
)
- programme_type = models.ForeignKey(
- ProgrammeType,
- on_delete=models.SET_NULL,
- blank=False,
- null=True,
- related_name="+",
- )
dates = RichTextField(blank=True, features=["link"])
location = RichTextField(blank=True, features=["link"])
introduction = models.CharField(max_length=500, blank=True)
@@ -247,7 +249,7 @@ class ShortCoursePage(ContactFieldsMixin, BasePage):
heading=_("Course Introduction"),
),
FieldPanel("about"),
- FieldPanel("programme_type"),
+ InlinePanel("programme_types", label="Programme types"),
FieldPanel("quote_carousel"),
MultiFieldPanel(
[FieldPanel("staff_title"), InlinePanel("related_staff", label="Staff")],
@@ -308,7 +310,14 @@ class ShortCoursePage(ContactFieldsMixin, BasePage):
index.SearchField("body"),
index.SearchField("about"),
index.SearchField("location"),
- index.RelatedFields("programme_type", [index.SearchField("display_name")]),
+ index.RelatedFields(
+ "programme_types",
+ [
+ index.RelatedFields(
+ "programme_type", [index.SearchField("display_name")]
+ )
+ ],
+ ),
index.RelatedFields(
"subjects",
[index.RelatedFields("subject", [index.SearchField("title")])],
@@ -330,7 +339,7 @@ class ShortCoursePage(ContactFieldsMixin, BasePage):
api_fields = [
# Fields for filtering and display, shared with programmes.ProgrammePage.
APIField("subjects"),
- APIField("programme_type"),
+ APIField("programme_types"),
APIField("related_schools_and_research_pages"),
APIField("summary", serializer=CharFieldSerializer(source="introduction")),
APIField(
diff --git a/rca/shortcourses/tests/test_models.py b/rca/shortcourses/tests/test_models.py
index 82d104ada..ffa2a38c0 100644
--- a/rca/shortcourses/tests/test_models.py
+++ b/rca/shortcourses/tests/test_models.py
@@ -44,7 +44,6 @@ def setUp(self):
self.short_course_page = ShortCoursePage(
title="Short course title",
slug="short-course",
- programme_type_id=1,
contact_model_url="https://rca.ac.uk",
contact_model_text="Read more",
show_register_link=0,
diff --git a/rca/wagtailapi/filters.py b/rca/wagtailapi/filters.py
index fd23d09b8..3fca7aed4 100644
--- a/rca/wagtailapi/filters.py
+++ b/rca/wagtailapi/filters.py
@@ -43,6 +43,26 @@ def filter_queryset(self, request, queryset, view):
return queryset
+class ProgrammeTypesFilter(filters.BaseFilterBackend):
+ def filter_queryset(self, request, queryset, view):
+ try:
+ queryset.model._meta.get_field("programme_types")
+ programme_type_ids = [
+ int(id) for id in request.GET.getlist("programme_types", [])
+ ]
+ if programme_type_ids:
+ queryset = (
+ queryset.model.objects.filter(
+ programme_types__programme_type_id__in=programme_type_ids
+ )
+ .order_by("title")
+ .live()
+ )
+ return queryset
+ except FieldDoesNotExist:
+ return queryset
+
+
class RelatedSchoolsFilter(filters.BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
try:
diff --git a/rca/wagtailapi/views.py b/rca/wagtailapi/views.py
index 9b4c305af..64d936b1c 100644
--- a/rca/wagtailapi/views.py
+++ b/rca/wagtailapi/views.py
@@ -20,6 +20,7 @@ class PagesAPIViewSet(views.PagesAPIViewSet):
DescendantOfFilter,
filters.RelatedSchoolsFilter,
filters.SubjectsFilter,
+ filters.ProgrammeTypesFilter,
filters.StudyModeFilter,
filters.DistinctFilter,
filters.SearchFilter,