diff --git a/course_discovery/apps/tagging/admin.py b/course_discovery/apps/tagging/admin.py index e69de29bb2..5a879490c6 100644 --- a/course_discovery/apps/tagging/admin.py +++ b/course_discovery/apps/tagging/admin.py @@ -0,0 +1,85 @@ +""" This module contains the admin classes for the tagging app models """ +from django.conf import settings +from django.contrib import admin +from django.core.exceptions import PermissionDenied + +from course_discovery.apps.tagging.models import CourseVertical, SubVertical, Vertical + + +class SubVerticalInline(admin.TabularInline): + """ + Inline form for SubVertical under VerticalAdmin. + """ + model = SubVertical + extra = 0 + fields = ('name', 'is_active', 'slug') + readonly_fields = ('slug',) + show_change_link = True + + +@admin.register(Vertical) +class VerticalAdmin(admin.ModelAdmin): + """ + Admin class for Vertical model. + """ + list_display = ('name', 'is_active', 'slug',) + search_fields = ('name',) + inlines = [SubVerticalInline] + + def save_model(self, request, obj, form, change): + """ + Override the save_model method to restrict non-superuser from saving the model + """ + if not request.user.is_superuser: + raise PermissionDenied("You are not authorized to perform this action.") + super().save_model(request, obj, form, change) + + +@admin.register(SubVertical) +class SubVerticalAdmin(admin.ModelAdmin): + """ + Admin class for SubVertical model. + """ + list_display = ('name', 'is_active', 'slug', 'vertical') + list_filter = ('vertical', ) + search_fields = ('name',) + ordering = ('name',) + + def save_model(self, request, obj, form, change): + """ + Override the save_model method to restrict non-superuser from saving the model + """ + if not request.user.is_superuser: + raise PermissionDenied("You are not authorized to perform this action.") + super().save_model(request, obj, form, change) + + +@admin.register(CourseVertical) +class CourseVerticalAdmin(admin.ModelAdmin): + """ + Admin class for CourseVertical model. + """ + list_display = ('course', 'vertical', 'sub_vertical') + list_filter = ('vertical', 'sub_vertical') + search_fields = ('course__title', 'vertical__name', 'sub_vertical__name') + ordering = ('course__title',) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """ + Override the formfield_for_foreignkey method to filter non-draft entry of courses and active vertical and + sub-vertical filters. + """ + if db_field.name == 'vertical': + kwargs['queryset'] = Vertical.objects.filter(is_active=True) + elif db_field.name == 'sub_vertical': + kwargs['queryset'] = SubVertical.objects.filter(is_active=True) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def save_model(self, request, obj, form, change): + """ + Override the save_model method to allow only superuser and users in allowed groups to save the model. + """ + allowed_groups = getattr(settings, 'VERTICALS_MANAGEMENT_GROUPS', []) + if not (request.user.is_superuser or request.user.groups.filter(name__in=allowed_groups).exists()): + raise PermissionDenied("You are not authorized to perform this action.") + super().save_model(request, obj, form, change) diff --git a/course_discovery/apps/tagging/migrations/0001_initial.py b/course_discovery/apps/tagging/migrations/0001_initial.py new file mode 100644 index 0000000000..35c4eefed0 --- /dev/null +++ b/course_discovery/apps/tagging/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.14 on 2025-01-20 07:40 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_extensions.db.fields +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_metadata', '0346_archivecoursesconfig'), + ] + + operations = [ + migrations.CreateModel( + name='Vertical', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(max_length=255, unique=True)), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name', unique=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='SubVertical', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(max_length=255, unique=True)), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name', unique=True)), + ('is_active', models.BooleanField(default=True)), + ('vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_verticals', to='tagging.vertical')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HistoricalVertical', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=255)), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name')), + ('is_active', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical vertical', + 'verbose_name_plural': 'historical verticals', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalSubVertical', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(db_index=True, max_length=255)), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name')), + ('is_active', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('vertical', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='tagging.vertical')), + ], + options={ + 'verbose_name': 'historical sub vertical', + 'verbose_name_plural': 'historical sub verticals', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='CourseVertical', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('course', models.OneToOneField(limit_choices_to={'draft': False}, on_delete=django.db.models.deletion.CASCADE, related_name='verticals', to='course_metadata.course')), + ('sub_vertical', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_sub_verticals', to='tagging.subvertical')), + ('vertical', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_verticals', to='tagging.vertical')), + ], + options={ + 'abstract': False, + 'unique_together': {('course',)}, + }, + ), + ] diff --git a/course_discovery/apps/tagging/models.py b/course_discovery/apps/tagging/models.py index e69de29bb2..181499bdc0 100644 --- a/course_discovery/apps/tagging/models.py +++ b/course_discovery/apps/tagging/models.py @@ -0,0 +1,116 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django_extensions.db.fields import AutoSlugField +from model_utils.models import TimeStampedModel +from simple_history.models import HistoricalRecords + +from course_discovery.apps.course_metadata.models import Course + + +class Vertical(TimeStampedModel): + """ + Model for defining verticals used to categorize product types + """ + name = models.CharField(max_length=255, unique=True) + slug = AutoSlugField(populate_from='name', max_length=255, unique=True, db_index=True) + is_active = models.BooleanField(default=True) + history = HistoricalRecords() + + def __str__(self): + return self.name + + class Meta: + ordering = ['name'] + + def save(self, *args, **kwargs): + """ + Override the save method to deactivate related sub-verticals when `is_active` is set to False. + """ + if self.pk: + cur_instance = Vertical.objects.get(pk=self.pk) + if cur_instance.is_active and not self.is_active: + self.sub_verticals.update(is_active=False) + + super().save(*args, **kwargs) + + +class SubVertical(TimeStampedModel): + """ + Model for defining sub-verticals used to categorize product types under specific verticals. + """ + name = models.CharField(max_length=255, unique=True) + slug = AutoSlugField(populate_from='name', max_length=255, unique=True, db_index=True) + is_active = models.BooleanField(default=True) + vertical = models.ForeignKey(Vertical, on_delete=models.CASCADE, related_name='sub_verticals') + history = HistoricalRecords() + + def __str__(self): + return self.name + + +class ProductVertical(TimeStampedModel): + """ + Abstract base model for assigning vertical and sub verticals to product types. + """ + vertical = models.ForeignKey( + Vertical, on_delete=models.CASCADE, null=True, blank=True, related_name="%(class)s_verticals" + ) + sub_vertical = models.ForeignKey( + SubVertical, on_delete=models.CASCADE, null=True, blank=True, related_name="%(class)s_sub_verticals" + ) + history = HistoricalRecords() + + class Meta: + abstract = True + + def __str__(self): + """ + Returns a string representing the object. + """ + vertical = self.vertical.name if self.vertical else "None" + sub_vertical = self.sub_vertical.name if self.sub_vertical else "None" + return f'{self.get_object_title()} - {vertical} - {sub_vertical}' + + def get_object_title(self): + """ + Returns a string representing the title of the object. + """ + raise NotImplementedError("Subclasses must implement `get_object_title`.") + + +class CourseVertical(ProductVertical): + """ + Model for assigning vertical and sub verticals to courses + """ + course = models.OneToOneField( + Course, on_delete=models.CASCADE, related_name="verticals", limit_choices_to={'draft': False} + ) + + class Meta: + unique_together = ["course"] + + def clean(self): + """ + Validate that the sub_vertical belongs to the selected vertical. + Automatically set the vertical if only sub_vertical is set. + """ + super().clean() + if hasattr(self, 'sub_vertical') and self.sub_vertical: + if not self.vertical: + self.vertical = self.sub_vertical.vertical # Auto-assign vertical if it's not set + + if self.sub_vertical.vertical and self.sub_vertical.vertical != self.vertical: + raise ValidationError({ + 'sub_vertical': f'Sub-vertical "{self.sub_vertical.name}" does not belong to ' + f'vertical "{self.vertical.name}".' + }) + + def save(self, *args, **kwargs): + """ + Call full_clean before saving to ensure validation is always run + """ + self.full_clean() + super().save(*args, **kwargs) + + def get_object_title(self): + return self.course.title diff --git a/course_discovery/apps/tagging/tests.py b/course_discovery/apps/tagging/tests/__init__.py similarity index 100% rename from course_discovery/apps/tagging/tests.py rename to course_discovery/apps/tagging/tests/__init__.py diff --git a/course_discovery/apps/tagging/tests/factories.py b/course_discovery/apps/tagging/tests/factories.py new file mode 100644 index 0000000000..0fe0a7a800 --- /dev/null +++ b/course_discovery/apps/tagging/tests/factories.py @@ -0,0 +1,42 @@ +""" Factories for tagging app models """ +import factory +from factory.django import DjangoModelFactory +from factory.fuzzy import FuzzyText + +from course_discovery.apps.course_metadata.tests.factories import CourseFactory +from course_discovery.apps.tagging.models import CourseVertical, SubVertical, Vertical + + +class VerticalFactory(DjangoModelFactory): + """ + Factory for Vertical model + """ + class Meta: + model = Vertical + + name = FuzzyText() + is_active = True + + +class SubVerticalFactory(DjangoModelFactory): + """ + Factory for SubVertical model + """ + class Meta: + model = SubVertical + + name = FuzzyText() + vertical = factory.SubFactory(VerticalFactory) + is_active = True + + +class CourseVerticalFactory(DjangoModelFactory): + """ + Factory for CourseVertical model + """ + class Meta: + model = CourseVertical + + course = factory.SubFactory(CourseFactory) + vertical = factory.SubFactory(VerticalFactory) + sub_vertical = factory.SubFactory(SubVerticalFactory) diff --git a/course_discovery/apps/tagging/tests/test_admin.py b/course_discovery/apps/tagging/tests/test_admin.py new file mode 100644 index 0000000000..28646b5ac2 --- /dev/null +++ b/course_discovery/apps/tagging/tests/test_admin.py @@ -0,0 +1,153 @@ +""" +Test the admin interface for the tagging app. +""" +from django.conf import settings +from django.contrib.admin.sites import AdminSite +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.test import TestCase + +from course_discovery.apps.course_metadata.tests.factories import CourseFactory +from course_discovery.apps.tagging.admin import CourseVerticalAdmin, SubVerticalAdmin, VerticalAdmin +from course_discovery.apps.tagging.models import CourseVertical, SubVertical, Vertical +from course_discovery.apps.tagging.tests.factories import CourseVerticalFactory, SubVerticalFactory, VerticalFactory + +User = get_user_model() + + +class MockRequest: + def __init__(self, user=None): + self.user = user + + +class BaseAdminTestCase(TestCase): + """ Base test class for admin tests """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + + # Create users and groups + cls.superuser = User.objects.create_superuser( + username='admin', + email='admin@example.com', + password='password' + ) + cls.regular_user = User.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + cls.allowed_group = Group.objects.create(name='allowed_group') + + +class VerticalAdminTests(BaseAdminTestCase): + """ Tests for VerticalAdmin """ + + def setUp(self): + super().setUp() + self.vertical_admin = VerticalAdmin(Vertical, self.site) + self.vertical = VerticalFactory() + + def test_save_model_as_superuser(self): + """ Verify that superuser can save vertical. """ + request = MockRequest(self.superuser) + self.vertical_admin.save_model(request, self.vertical, None, False) + + def test_save_model_as_regular_user(self): + """Verify that regular user cannot save vertical.""" + request = MockRequest(self.regular_user) + with self.assertRaises(PermissionDenied): + self.vertical_admin.save_model(request, self.vertical, None, False) + + +class SubVerticalAdminTests(BaseAdminTestCase): + """Tests for SubVerticalAdmin.""" + + def setUp(self): + super().setUp() + self.subvertical_admin = SubVerticalAdmin(SubVertical, self.site) + self.subvertical = SubVerticalFactory() + + def test_save_model_as_superuser(self): + """Verify that superuser can save subvertical.""" + request = MockRequest(self.superuser) + try: + self.subvertical_admin.save_model(request, self.subvertical, None, False) + except PermissionDenied: + self.fail("Superuser should be able to save subvertical") + + def test_save_model_as_regular_user(self): + """Verify that regular user cannot save subvertical.""" + request = MockRequest(self.regular_user) + with self.assertRaises(PermissionDenied): + self.subvertical_admin.save_model(request, self.subvertical, None, False) + + def test_list_display(self): + """Verify the correct list display fields.""" + self.assertEqual( + self.subvertical_admin.list_display, + ('name', 'is_active', 'slug', 'vertical') + ) + + +class CourseVerticalAdminTests(BaseAdminTestCase): + """Tests for CourseVerticalAdmin.""" + + def setUp(self): + super().setUp() + self.coursevertical_admin = CourseVerticalAdmin(CourseVertical, self.site) + self.course = CourseFactory(draft=False) + self.vertical = VerticalFactory(is_active=True) + self.subvertical = SubVerticalFactory(is_active=True, vertical=self.vertical) + self.coursevertical = CourseVerticalFactory( + course=self.course, + vertical=self.vertical, + sub_vertical=self.subvertical + ) + + def test_save_model_as_superuser(self): + """Verify that superuser can save course vertical.""" + request = MockRequest(self.superuser) + self.coursevertical_admin.save_model(request, self.coursevertical, None, False) + + def test_save_model_as_allowed_group_user(self): + """Verify that user in allowed group can save course vertical.""" + self.regular_user.groups.add(self.allowed_group) + request = MockRequest(self.regular_user) + self.coursevertical_admin.save_model(request, self.coursevertical, None, False) + + def test_save_model_as_regular_user(self): + """Verify that regular user cannot save course vertical.""" + request = MockRequest(self.regular_user) + with self.assertRaises(PermissionDenied): + self.coursevertical_admin.save_model(request, self.coursevertical, None, False) + + def test_formfield_for_foreignkey_vertical(self): + """ Verify that only active verticals are available """ + inactive_vertical = VerticalFactory(is_active=False) + request = MockRequest(self.superuser) + + formfield = self.coursevertical_admin.formfield_for_foreignkey( + CourseVertical._meta.get_field('vertical'), + request + ) + + self.assertIn(self.vertical, formfield.queryset) + self.assertNotIn(inactive_vertical, formfield.queryset) + + def test_formfield_for_foreignkey_subvertical(self): + """Verify that only active subverticals are available.""" + inactive_subvertical = SubVerticalFactory(is_active=False) + request = MockRequest(self.superuser) + + formfield = self.coursevertical_admin.formfield_for_foreignkey( + CourseVertical._meta.get_field('sub_vertical'), + request + ) + + self.assertIn(self.subvertical, formfield.queryset) + self.assertNotIn(inactive_subvertical, formfield.queryset) diff --git a/course_discovery/apps/tagging/tests/test_models.py b/course_discovery/apps/tagging/tests/test_models.py new file mode 100644 index 0000000000..70ff5d4bc2 --- /dev/null +++ b/course_discovery/apps/tagging/tests/test_models.py @@ -0,0 +1,136 @@ +""" +Tests for the models registered in tagging app +""" +from django.core.exceptions import ValidationError +from django.test import TestCase + +from course_discovery.apps.course_metadata.tests.factories import CourseFactory +from course_discovery.apps.tagging.models import CourseVertical, SubVertical +from course_discovery.apps.tagging.tests.factories import CourseVerticalFactory, SubVerticalFactory, VerticalFactory + + +class VerticalModelTests(TestCase): + def setUp(self): + super().setUp() + self.vertical = VerticalFactory(name="Test Vertical", is_active=True) + + def test_str(self): + """ Verify the string representation of a vertical """ + self.assertEqual(str(self.vertical), self.vertical.name) + + def test_unique_name(self): + """ Verify that vertical names must be unique """ + with self.assertRaises(Exception): + VerticalFactory(name="Test Vertical") + + def test_vertical_filter_creation(self): + """ Verify that vertical filter is created correctly """ + self.assertEqual(self.vertical.name, "Test Vertical") + self.assertTrue(self.vertical.is_active) + self.assertEqual(self.vertical.slug, "test-vertical") + self.assertEqual(str(self.vertical), "Test Vertical") + + def test_deactivate_sub_verticals(self): + """ Verify that deactivating a vertical also deactivates its sub-verticals """ + sub_vertical = SubVerticalFactory(vertical=self.vertical) + self.assertTrue(sub_vertical.is_active) + + self.vertical.is_active = False + self.vertical.save() + + sub_vertical.refresh_from_db() + self.assertFalse(sub_vertical.is_active) + + +class SubVerticalModelTests(TestCase): + + def setUp(self): + super().setUp() + self.vertical = VerticalFactory(name="Technology") + self.sub_vertical = SubVerticalFactory( + name="Software Engineering", vertical=self.vertical + ) + + def test_str(self): + """ Verify the string representation of a sub-vertical """ + self.assertEqual(str(self.sub_vertical), self.sub_vertical.name) + + def test_sub_vertical_filter_creation(self): + """ Verify that sub-vertical filter is created correctly """ + self.assertEqual(self.sub_vertical.name, "Software Engineering") + self.assertEqual(self.sub_vertical.vertical, self.vertical) + self.assertEqual(self.sub_vertical.slug, "software-engineering") + self.assertTrue(self.sub_vertical.is_active) + + def test_unique_name_constraint(self): + """ Verify that sub-vertical names must be unique """ + with self.assertRaises(Exception): + SubVerticalFactory(name="Software Engineering", vertical=self.vertical) + + def test_cascade_delete(self): + """ Verify that sub-verticals are deleted when their vertical is deleted """ + sub_vertical_id = self.sub_vertical.id + self.vertical.delete() + + with self.assertRaises(SubVertical.DoesNotExist): + SubVertical.objects.get(id=sub_vertical_id) + + +class CourseVerticalModelTests(TestCase): + def setUp(self): + self.course_draft = CourseFactory(title="Test Course", draft=True) + self.course = CourseFactory(title="Test Course", draft=False, draft_version_id=self.course_draft.id) + self.vertical = VerticalFactory(name="Test Vertical") + self.sub_vertical = SubVerticalFactory( + name="Test Sub Vertical", vertical=self.vertical + ) + self.course_vertical = CourseVerticalFactory( + course=self.course, vertical=self.vertical, sub_vertical=self.sub_vertical + ) + + def test_course_vertical_filter_creation(self): + self.assertEqual(self.course_vertical.course, self.course) + self.assertEqual(self.course_vertical.vertical, self.vertical) + self.assertEqual(self.course_vertical.sub_vertical, self.sub_vertical) + self.assertEqual(str(self.course_vertical), "Test Course - Test Vertical - Test Sub Vertical") + + def test_unique_course(self): + """ Verify that a course can only have one vertical assignment """ + with self.assertRaises(Exception): + CourseVerticalFactory( + course=self.course, + vertical=self.vertical, + sub_vertical=self.sub_vertical + ) + + def test_cascade_delete_vertical(self): + """Verify that course verticals are deleted when their vertical is deleted.""" + course_vertical_id = self.course_vertical.id + self.vertical.delete() + + with self.assertRaises(CourseVertical.DoesNotExist): + CourseVertical.objects.get(id=course_vertical_id) + + def test_cascade_delete_sub_vertical(self): + """ Verify that course verticals are deleted when their sub-vertical is deleted """ + course_vertical_id = self.course_vertical.id + self.sub_vertical.delete() + + with self.assertRaises(CourseVertical.DoesNotExist): + CourseVertical.objects.get(id=course_vertical_id) + + def test_get_object_title(self): + """ Verify that get_object_title returns the course title """ + self.assertEqual(self.course_vertical.get_object_title(), self.course.title) + + def test_mismatched_sub_vertical(self): + """Verify that a sub-vertical must belong to the selected vertical.""" + different_vertical = VerticalFactory() + mismatched_sub_vertical = SubVerticalFactory(vertical=different_vertical) + + with self.assertRaises(ValidationError): + CourseVertical.objects.create( + course=CourseFactory(), + vertical=self.vertical, + sub_vertical=mismatched_sub_vertical + ) diff --git a/course_discovery/settings/base.py b/course_discovery/settings/base.py index bae2abbffc..f88fad3170 100644 --- a/course_discovery/settings/base.py +++ b/course_discovery/settings/base.py @@ -801,3 +801,6 @@ RETIRED_RUN_TYPES = [] RETIRED_COURSE_TYPES = [] COURSE_ARCHIVAL_MAIL_RECIPIENTS = ['phoenix@edx.org'] + +# The list of user groups that have access to assign verticals and sub-verticals to courses +VERTICALS_MANAGEMENT_GROUPS = [] diff --git a/course_discovery/settings/test.py b/course_discovery/settings/test.py index e2246610be..99ee575d73 100644 --- a/course_discovery/settings/test.py +++ b/course_discovery/settings/test.py @@ -166,3 +166,5 @@ RETIRED_RUN_TYPES = ['paid-bootcamp', 'unpaid-bootcamp'] RETIRED_COURSE_TYPES = ['bootcamp-2u'] + +VERTICALS_MANAGEMENT_GROUPS = ['allowed_group']