diff --git a/course_discovery/apps/tagging/management/commands/tests/test_update_course_verticals.py b/course_discovery/apps/tagging/management/commands/tests/test_update_course_verticals.py
index da8909f6e9..e1a209af92 100644
--- a/course_discovery/apps/tagging/management/commands/tests/test_update_course_verticals.py
+++ b/course_discovery/apps/tagging/management/commands/tests/test_update_course_verticals.py
@@ -87,10 +87,10 @@ def test_success(self, has_existing_verticals):
self.course1.refresh_from_db()
self.course2.refresh_from_db()
- assert self.course1.vertical.vertical == self.ai_vertical
- assert self.course1.vertical.sub_vertical == self.python_subvertical
- assert self.course2.vertical.vertical == self.literature_vertical
- assert self.course2.vertical.sub_vertical == self.kafka_subvertical
+ assert self.course1.product_vertical.vertical == self.ai_vertical
+ assert self.course1.product_vertical.sub_vertical == self.python_subvertical
+ assert self.course2.product_vertical.vertical == self.literature_vertical
+ assert self.course2.product_vertical.sub_vertical == self.kafka_subvertical
assert CourseVertical.objects.count() == 2
assert Vertical.objects.count() == 2
assert SubVertical.objects.count() == 2
@@ -103,8 +103,8 @@ def test_empty_subvertical(self):
UpdateCourseVerticalsConfigFactory(enabled=True, csv_file=csv)
call_command('update_course_verticals')
- assert self.course1.vertical.vertical == self.ai_vertical
- assert self.course1.vertical.sub_vertical is None
+ assert self.course1.product_vertical.vertical == self.ai_vertical
+ assert self.course1.product_vertical.sub_vertical is None
assert not hasattr(self.course2, 'vertical')
assert CourseVertical.objects.count() == 1
self.assert_email_content(success_count=1, failure_count=0)
@@ -116,8 +116,8 @@ def test_nonexistent_vertical(self):
call_command('update_course_verticals')
assert not hasattr(self.course1, 'vertical')
- assert self.course2.vertical.vertical == self.literature_vertical
- assert self.course2.vertical.sub_vertical == self.kafka_subvertical
+ assert self.course2.product_vertical.vertical == self.literature_vertical
+ assert self.course2.product_vertical.sub_vertical == self.kafka_subvertical
assert CourseVertical.objects.count() == 1
self.assert_email_content(
success_count=1, failure_count=1, failure_reasons={f"{self.course1.key}": "ValueError"}
@@ -131,8 +131,8 @@ def test_inactive_vertical(self):
UpdateCourseVerticalsConfigFactory(enabled=True, csv_file=csv)
call_command('update_course_verticals')
- assert self.course1.vertical.vertical == self.ai_vertical
- assert self.course1.vertical.sub_vertical == self.python_subvertical
+ assert self.course1.product_vertical.vertical == self.ai_vertical
+ assert self.course1.product_vertical.sub_vertical == self.python_subvertical
assert not hasattr(self.course2, 'vertical')
assert CourseVertical.objects.count() == 1
self.assert_email_content(
diff --git a/course_discovery/apps/tagging/migrations/0003_alter_coursevertical_course.py b/course_discovery/apps/tagging/migrations/0003_alter_coursevertical_course.py
new file mode 100644
index 0000000000..51aecdd237
--- /dev/null
+++ b/course_discovery/apps/tagging/migrations/0003_alter_coursevertical_course.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.11 on 2025-01-28 09:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tagging', '0002_updatecourseverticalsconfig'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='coursevertical',
+ name='course',
+ field=models.OneToOneField(limit_choices_to={'draft': False}, on_delete=django.db.models.deletion.CASCADE, related_name='product_vertical', to='course_metadata.course'),
+ ),
+ ]
diff --git a/course_discovery/apps/tagging/mixins.py b/course_discovery/apps/tagging/mixins.py
new file mode 100644
index 0000000000..04a0635516
--- /dev/null
+++ b/course_discovery/apps/tagging/mixins.py
@@ -0,0 +1,23 @@
+from django.conf import settings
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.core.exceptions import PermissionDenied
+
+
+class VerticalTaggingAdministratorPermissionRequiredMixin(LoginRequiredMixin):
+ """
+ A mixin to enforce permission on VERTICALS_MANAGEMENT_GROUPS for class-based views.
+ """
+
+ def dispatch(self, request, *args, **kwargs):
+ response = super().dispatch(request, *args, **kwargs)
+ if response.status_code == 403:
+ return response
+
+ in_vertical_management_group = request.user.groups.filter(
+ name__in=settings.VERTICALS_MANAGEMENT_GROUPS
+ ).exists()
+
+ if not request.user.is_superuser and not in_vertical_management_group:
+ raise PermissionDenied("You do not have permission to access this page.")
+
+ return response
diff --git a/course_discovery/apps/tagging/models.py b/course_discovery/apps/tagging/models.py
index acd345bd45..b43ff06dc1 100644
--- a/course_discovery/apps/tagging/models.py
+++ b/course_discovery/apps/tagging/models.py
@@ -85,7 +85,7 @@ class CourseVertical(ProductVertical):
Model for assigning vertical and sub verticals to courses
"""
course = models.OneToOneField(
- Course, on_delete=models.CASCADE, related_name="vertical", limit_choices_to={'draft': False}
+ Course, on_delete=models.CASCADE, related_name="product_vertical", limit_choices_to={'draft': False}
)
def clean(self):
diff --git a/course_discovery/apps/tagging/templates/partials/course_table.html b/course_discovery/apps/tagging/templates/partials/course_table.html
new file mode 100644
index 0000000000..c164032c75
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/partials/course_table.html
@@ -0,0 +1,109 @@
+
+
+{% if is_paginated %}
+
+{% endif %}
+
diff --git a/course_discovery/apps/tagging/templates/partials/message.html b/course_discovery/apps/tagging/templates/partials/message.html
new file mode 100644
index 0000000000..d5a5fbe8ff
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/partials/message.html
@@ -0,0 +1,6 @@
+{% if success %}
+ {{ success }}
+{% endif %}
+{% if error %}
+ {{ error }}
+{% endif %}
diff --git a/course_discovery/apps/tagging/templates/partials/sub_vertical_options.html b/course_discovery/apps/tagging/templates/partials/sub_vertical_options.html
new file mode 100644
index 0000000000..7b8ed00e22
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/partials/sub_vertical_options.html
@@ -0,0 +1,4 @@
+
+{% for sub_vertical in sub_verticals %}
+
+{% endfor %}
diff --git a/course_discovery/apps/tagging/templates/tagging/base.html b/course_discovery/apps/tagging/templates/tagging/base.html
new file mode 100644
index 0000000000..59f357ea88
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/base.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {% block title %}Course Tagging{% endblock %}
+
+
+
+
+
+
+
+
+ {% block content %}
+ {% endblock %}
+
+
+
diff --git a/course_discovery/apps/tagging/templates/tagging/course_list.html b/course_discovery/apps/tagging/templates/tagging/course_list.html
new file mode 100644
index 0000000000..b7474dcd98
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/course_list.html
@@ -0,0 +1,18 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+
Courses
+
+
+
+
+ {% include "partials/course_table.html" %}
+
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/templates/tagging/course_tagging_detail.html b/course_discovery/apps/tagging/templates/tagging/course_tagging_detail.html
new file mode 100644
index 0000000000..758f194535
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/course_tagging_detail.html
@@ -0,0 +1,90 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+
Course: {{ course.title }}
+
Key: {{ course.key }}
+
+
Assign or Edit Vertical and Sub-Vertical
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/templates/tagging/sub_vertical_detail.html b/course_discovery/apps/tagging/templates/tagging/sub_vertical_detail.html
new file mode 100644
index 0000000000..8e89b6542a
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/sub_vertical_detail.html
@@ -0,0 +1,27 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+
Sub-Vertical: {{ sub_vertical.name }}
+
Parent Vertical:
+
+ {{ sub_vertical.vertical.name }}
+
+
+
Tagged Courses
+
+ {% if courses %}
+
+ {% else %}
+
No courses assigned to this sub-vertical.
+ {% endif %}
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/templates/tagging/sub_vertical_list.html b/course_discovery/apps/tagging/templates/tagging/sub_vertical_list.html
new file mode 100644
index 0000000000..1fcef632cd
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/sub_vertical_list.html
@@ -0,0 +1,44 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/templates/tagging/vertical_detail.html b/course_discovery/apps/tagging/templates/tagging/vertical_detail.html
new file mode 100644
index 0000000000..7e7a6830d9
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/vertical_detail.html
@@ -0,0 +1,32 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+
Vertical: {{ vertical.name }}
+
Sub-Verticals
+
+ {% for sub_vertical in vertical.sub_verticals.all %}
+ -
+
+ {{ sub_vertical.name }}
+
+
+ {% empty %}
+ - No sub-verticals assigned.
+ {% endfor %}
+
+
+
Tagged Courses
+
+ {% for course in courses %}
+ -
+
+ {{ course.title }}
+
+
+ {% empty %}
+ - No courses assigned to this vertical.
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/templates/tagging/vertical_list.html b/course_discovery/apps/tagging/templates/tagging/vertical_list.html
new file mode 100644
index 0000000000..d693e547ee
--- /dev/null
+++ b/course_discovery/apps/tagging/templates/tagging/vertical_list.html
@@ -0,0 +1,38 @@
+{% extends "tagging/base.html" %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/course_discovery/apps/tagging/tests/test_mixins.py b/course_discovery/apps/tagging/tests/test_mixins.py
new file mode 100644
index 0000000000..f88eeecbd1
--- /dev/null
+++ b/course_discovery/apps/tagging/tests/test_mixins.py
@@ -0,0 +1,67 @@
+from django.conf import settings
+from django.contrib.auth.models import AnonymousUser, Group
+from django.core.exceptions import PermissionDenied
+from django.http.response import HttpResponse
+from django.test import RequestFactory, TestCase
+from django.views import View
+
+from course_discovery.apps.core.tests.factories import UserFactory
+from course_discovery.apps.tagging.mixins import VerticalTaggingAdministratorPermissionRequiredMixin
+
+
+class MockView(VerticalTaggingAdministratorPermissionRequiredMixin, View):
+ """A mock view to test the mixin."""
+
+ def get(self, request, *args, **kwargs):
+ return HttpResponse("Success!")
+
+
+class VerticalTaggingAdministratorPermissionRequiredMixinTests(TestCase):
+ """Tests for VerticalTaggingAdministratorPermissionRequiredMixin."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+ self.view = MockView.as_view()
+
+ self.superuser = UserFactory(is_staff=True, is_superuser=True)
+ self.vertical_admin = UserFactory(is_staff=True, is_superuser=False)
+ self.regular_user = UserFactory(is_staff=False, is_superuser=False)
+
+ self.allowed_group = Group.objects.create(name=settings.VERTICALS_MANAGEMENT_GROUPS[0])
+ self.vertical_admin.groups.add(self.allowed_group)
+
+ def test_user_not_authenticated(self):
+ """Test that unauthenticated users are forbidden."""
+ request = self.factory.get("/")
+ request.user = AnonymousUser()
+
+ with self.assertRaisesMessage(PermissionDenied, "You do not have permission to access this page."):
+ self.view(request)
+
+ def test_regular_user(self):
+ """Test that users not in the allowed group or superuser are forbidden."""
+ request = self.factory.get("/")
+ request.user = self.regular_user
+
+ with self.assertRaisesMessage(PermissionDenied, "You do not have permission to access this page."):
+ self.view(request)
+
+ def test_user_in_allowed_group(self):
+ """Test that users in the allowed group can access the view."""
+ request = self.factory.get("/")
+ request.user = self.vertical_admin
+
+ response = self.view(request)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content.decode(), "Success!")
+
+ def test_superuser_access(self):
+ """Test that superusers can access the view."""
+ self.superuser.groups.clear()
+
+ request = self.factory.get("/")
+ request.user = self.superuser
+
+ response = self.view(request)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content.decode(), "Success!")
diff --git a/course_discovery/apps/tagging/tests/test_views.py b/course_discovery/apps/tagging/tests/test_views.py
new file mode 100644
index 0000000000..973195038b
--- /dev/null
+++ b/course_discovery/apps/tagging/tests/test_views.py
@@ -0,0 +1,203 @@
+from ddt import data, ddt
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.test import TestCase
+from django.urls import reverse
+
+from course_discovery.apps.core.tests.factories import UserFactory
+from course_discovery.apps.course_metadata.tests.factories import CourseFactory
+from course_discovery.apps.tagging.models import CourseVertical
+from course_discovery.apps.tagging.tests.factories import CourseVerticalFactory, SubVerticalFactory, VerticalFactory
+
+
+class BaseViewsTestCase(TestCase):
+ """Base test class for views requiring superuser and VERTICALS_MANAGEMENT_GROUPS permissions."""
+
+ def setUp(self):
+ super().setUp()
+ self.superuser = UserFactory(is_staff=True, is_superuser=True)
+ self.regular_user = UserFactory(is_staff=True, is_superuser=False)
+
+ self.allowed_group = Group.objects.create(name=settings.VERTICALS_MANAGEMENT_GROUPS)
+
+ self.regular_user.groups.add(self.allowed_group)
+
+
+class CourseTaggingDetailViewTests(BaseViewsTestCase):
+ """Tests for the CourseTaggingDetailView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.course = CourseFactory(title="Advanced Python")
+ self.vertical = VerticalFactory(name="AI")
+ self.sub_vertical = SubVerticalFactory(name="Machine Learning", vertical=self.vertical)
+ self.url = reverse("tagging:course_tagging_detail", kwargs={"uuid": self.course.uuid})
+
+ def test_get_course_tagging_detail(self):
+ """Tests GET request to course tagging detail view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/course_tagging_detail.html")
+
+ def test_post_valid_vertical_assignment(self):
+ """Tests POST request to assign vertical and sub-vertical."""
+ mock_response_data = {
+ "vertical": self.vertical.slug,
+ "sub_vertical": self.sub_vertical.slug,
+ }
+ response = self.client.post(self.url, data=mock_response_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Vertical and Sub-Vertical assigned successfully.")
+
+ course_vertical = CourseVertical.objects.get(course=self.course)
+ self.assertEqual(course_vertical.vertical, self.vertical)
+ self.assertEqual(course_vertical.sub_vertical, self.sub_vertical)
+
+ def test_post_invalid_sub_vertical(self):
+ """Tests POST request with mismatched sub-vertical and vertical."""
+ other_vertical = VerticalFactory(name='Business')
+ mismatched_sub_vertical = SubVerticalFactory(name='Innovation', vertical=other_vertical)
+
+ mock_response_data = {
+ 'vertical': self.vertical.slug,
+ 'sub_vertical': mismatched_sub_vertical.slug,
+ }
+ response = self.client.post(self.url, data=mock_response_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Sub-vertical does not belong to the selected vertical.')
+
+
+@ddt
+class CourseListViewTests(BaseViewsTestCase):
+ """Tests for the CourseListView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.course1 = CourseFactory(title="Advanced Python")
+ self.course2 = CourseFactory(title="Python Basics")
+ self.url = reverse("tagging:course_list")
+
+ def test_get_course_list(self):
+ """Tests GET request to course list view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/course_list.html")
+
+ def test_search_course(self):
+ """Tests searching for courses in the course list view."""
+ response = self.client.get(self.url, {'search': 'Basics'})
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'Python Basics')
+ self.assertNotContains(response, 'Advanced Python')
+
+ @data(('asc', ["Advanced Python", "Python Basics"]), ('desc', ["Python Basics", "Advanced Python"]))
+ def test_sort_courses(self, direction_and_order):
+ """Tests sorting courses by title in ascending and descending order."""
+ direction, expected_order = direction_and_order
+ response = self.client.get(self.url, {'sort': 'title', 'direction': direction})
+ self.assertEqual(response.status_code, 200)
+ courses = response.context['courses']
+
+ self.assertEqual([course.title for course in courses], expected_order)
+
+
+@ddt
+class VerticalListViewTests(BaseViewsTestCase):
+ """Tests for the VerticalListView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.vertical1 = VerticalFactory(name="AI")
+ self.vertical2 = VerticalFactory(name="Business")
+ self.url = reverse("tagging:vertical_list")
+
+ def test_get_vertical_list(self):
+ """Tests GET request to vertical list view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/vertical_list.html")
+
+ @data(('asc', ["AI", "Business"]), ('desc', ["Business", "AI"]))
+ def test_sort_verticals(self, direction_and_order):
+ """Tests sorting verticals by name in descending order."""
+ direction, expected_order = direction_and_order
+ response = self.client.get(self.url, {'sort': 'name', 'direction': direction})
+ self.assertEqual(response.status_code, 200)
+ verticals = response.context['verticals']
+ self.assertEqual([vertical.name for vertical in verticals], expected_order)
+
+
+class VerticalDetailViewTests(BaseViewsTestCase):
+ """Tests for the VerticalDetailView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.vertical = VerticalFactory(name="AI")
+ self.sub_vertical = SubVerticalFactory(name="Python", vertical=self.vertical)
+ self.course = CourseFactory(title="Machine Learning")
+ _ = CourseVerticalFactory(course=self.course, vertical=self.vertical, sub_vertical=self.sub_vertical)
+ self.url = reverse("tagging:vertical_detail", kwargs={"slug": self.vertical.slug})
+
+ def test_get_vertical_detail(self):
+ """Tests GET request to vertical detail view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/vertical_detail.html")
+ self.assertContains(response, self.vertical.name)
+ self.assertContains(response, self.sub_vertical.name)
+ self.assertContains(response, self.course.title)
+
+
+@ddt
+class SubVerticalListViewTests(BaseViewsTestCase):
+ """Tests for the SubVerticalListView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.vertical = VerticalFactory(name="Technology")
+ self.sub_vertical1 = SubVerticalFactory(name="AI", vertical=self.vertical)
+ self.sub_vertical2 = SubVerticalFactory(name="Business", vertical=self.vertical)
+ self.url = reverse("tagging:sub_vertical_list")
+
+ def test_get_sub_vertical_list(self):
+ """Tests GET request to sub-vertical list view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/sub_vertical_list.html")
+ self.assertIn("sub_verticals", response.context)
+
+ @data(('asc', ["AI", "Business"]), ('desc', ["Business", "AI"]))
+ def test_sort_sub_verticals(self, direction_and_order):
+ """Tests sorting sub-verticals by name in ascending and descending order."""
+ direction, expected_order = direction_and_order
+ response = self.client.get(self.url, {"sort": "name", "direction": direction})
+ self.assertEqual(response.status_code, 200)
+ sub_verticals = response.context["sub_verticals"]
+ self.assertEqual([sub_vertical.name for sub_vertical in sub_verticals], expected_order)
+
+
+class SubVerticalDetailViewTests(BaseViewsTestCase):
+ """Tests for the SubVerticalDetailView."""
+
+ def setUp(self):
+ super().setUp()
+ self.client.force_login(self.superuser)
+ self.vertical = VerticalFactory(name="AI")
+ self.sub_vertical = SubVerticalFactory(name="Python", vertical=self.vertical)
+ self.course = CourseFactory(title="Deep Learning")
+ _ = CourseVerticalFactory(course=self.course, vertical=self.vertical, sub_vertical=self.sub_vertical)
+ self.url = reverse("tagging:sub_vertical_detail", kwargs={"slug": self.sub_vertical.slug})
+
+ def test_get_sub_vertical_detail(self):
+ """Tests GET request to sub-vertical detail view."""
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "tagging/sub_vertical_detail.html")
+ self.assertContains(response, self.sub_vertical.name)
+ self.assertContains(response, self.vertical.name)
+ self.assertContains(response, self.course.title)
diff --git a/course_discovery/apps/tagging/urls.py b/course_discovery/apps/tagging/urls.py
new file mode 100644
index 0000000000..cb7318cb81
--- /dev/null
+++ b/course_discovery/apps/tagging/urls.py
@@ -0,0 +1,17 @@
+from django.urls import path
+
+from course_discovery.apps.tagging.views import (
+ CourseListView, CourseTaggingDetailView, SubVerticalDetailView, SubVerticalListView, VerticalDetailView,
+ VerticalListView
+)
+
+app_name = 'tagging'
+
+urlpatterns = [
+ path('courses//', CourseTaggingDetailView.as_view(), name='course_tagging_detail'),
+ path('verticals//', VerticalDetailView.as_view(), name='vertical_detail'),
+ path('sub_verticals//', SubVerticalDetailView.as_view(), name='sub_vertical_detail'),
+ path('courses/', CourseListView.as_view(), name='course_list'),
+ path('verticals/', VerticalListView.as_view(), name='vertical_list'),
+ path('sub_verticals/', SubVerticalListView.as_view(), name='sub_vertical_list'),
+]
diff --git a/course_discovery/apps/tagging/views.py b/course_discovery/apps/tagging/views.py
index e69de29bb2..28b3bf0ffb 100644
--- a/course_discovery/apps/tagging/views.py
+++ b/course_discovery/apps/tagging/views.py
@@ -0,0 +1,172 @@
+from django.db.models import Count
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, render
+from django.template.loader import render_to_string
+from django.views import View
+from django.views.generic import DetailView, ListView
+
+from course_discovery.apps.tagging.mixins import VerticalTaggingAdministratorPermissionRequiredMixin
+from course_discovery.apps.tagging.models import Course, CourseVertical, SubVertical, Vertical
+
+
+class CourseTaggingDetailView(VerticalTaggingAdministratorPermissionRequiredMixin, View):
+ """
+ Handles displaying course tagging details and assigning verticals and sub-verticals to a course.
+ """
+
+ def get(self, request, uuid):
+ course = get_object_or_404(Course, uuid=uuid, draft=False)
+ verticals = Vertical.objects.all()
+ all_sub_verticals = SubVertical.objects.select_related('vertical')
+ return render(request, "tagging/course_tagging_detail.html", {
+ "course": course,
+ "verticals": verticals,
+ "all_sub_verticals": all_sub_verticals,
+ })
+
+ def post(self, request, uuid):
+ course = get_object_or_404(Course, uuid=uuid, draft=False)
+ vertical_slug = request.POST.get('vertical')
+ sub_vertical_slug = request.POST.get('sub_vertical')
+
+ vertical = Vertical.objects.filter(slug=vertical_slug).first() if vertical_slug else None
+ sub_vertical = SubVertical.objects.filter(slug=sub_vertical_slug).first() if sub_vertical_slug else None
+
+ if sub_vertical and sub_vertical.vertical != vertical:
+ html = render_to_string("partials/message.html", {
+ "error": "Sub-vertical does not belong to the selected vertical."
+ }, request)
+ return HttpResponse(html, status=200)
+
+ CourseVertical.objects.update_or_create(
+ course=course,
+ defaults={"vertical": vertical, "sub_vertical": sub_vertical}
+ )
+
+ html = render_to_string("partials/message.html", {
+ "success": "Vertical and Sub-Vertical assigned successfully."
+ }, request)
+ return HttpResponse(html, status=200)
+
+
+class CourseListView(VerticalTaggingAdministratorPermissionRequiredMixin, ListView):
+ """
+ Renders a list of all Courses with search, sort, and pagination capabilities.
+ """
+ model = Course
+ template_name = "tagging/course_list.html"
+ context_object_name = "courses"
+ paginate_by = 20
+
+ def get_queryset(self):
+ search_query = self.request.GET.get('search', '').strip()
+ sort_by = self.request.GET.get('sort', 'title')
+ direction = self.request.GET.get('direction', 'asc')
+
+ sort_fields = {
+ 'key': 'key',
+ 'title': 'title',
+ 'vertical': 'product_vertical__vertical__name',
+ 'sub_vertical': 'product_vertical__sub_vertical__name',
+ }
+ sort_field = sort_fields.get(sort_by, 'title')
+ if direction == 'desc':
+ sort_field = f'-{sort_field}'
+
+ queryset = Course.objects.prefetch_related("product_vertical__vertical", "product_vertical__sub_vertical")
+ if search_query:
+ queryset = queryset.filter(title__icontains=search_query)
+ return queryset.order_by(sort_field)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['current_sort'] = self.request.GET.get('sort', 'title')
+ context['current_direction'] = self.request.GET.get('direction', 'asc')
+ return context
+
+ def render_to_response(self, context, **response_kwargs):
+ if self.request.headers.get('HX-Request'):
+ course_table_html = render_to_string("partials/course_table.html", context, request=self.request)
+ return HttpResponse(course_table_html)
+ return super().render_to_response(context, **response_kwargs)
+
+
+class BaseSortableListView(VerticalTaggingAdministratorPermissionRequiredMixin, ListView):
+ """
+ Base view to add sorting capabilities for list views.
+ """
+ default_sort_field = 'name'
+
+ def get_annotated_queryset(self):
+ """
+ Subclasses should override this method to provide their own annotated queryset.
+ """
+ raise NotImplementedError("Subclasses must implement `get_annotated_queryset`.")
+
+ def get_queryset(self):
+ sort_by = self.request.GET.get('sort', self.default_sort_field)
+ direction = self.request.GET.get('direction', 'asc')
+
+ if direction == 'desc':
+ sort_by = f'-{sort_by}'
+
+ queryset = self.get_annotated_queryset()
+ return queryset.order_by(sort_by)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['current_sort'] = self.request.GET.get('sort', self.default_sort_field)
+ context['current_direction'] = self.request.GET.get('direction', 'asc')
+ return context
+
+
+class VerticalListView(BaseSortableListView):
+ """
+ Renders a list of all Verticals with their assigned courses.
+ """
+ model = Vertical
+ template_name = "tagging/vertical_list.html"
+ context_object_name = "verticals"
+
+ def get_annotated_queryset(self):
+ return Vertical.objects.annotate(course_count=Count('coursevertical_verticals'))
+
+
+class SubVerticalListView(BaseSortableListView):
+ """
+ Renders a list of all SubVerticals with their assigned courses.
+ """
+ model = SubVertical
+ template_name = "tagging/sub_vertical_list.html"
+ context_object_name = "sub_verticals"
+
+ def get_annotated_queryset(self):
+ return SubVertical.objects.annotate(course_count=Count('coursevertical_sub_verticals'))
+
+
+class VerticalDetailView(VerticalTaggingAdministratorPermissionRequiredMixin, DetailView):
+ """
+ Render details of a specific vertical and associated courses.
+ """
+ model = Vertical
+ template_name = "tagging/vertical_detail.html"
+ context_object_name = "vertical"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["courses"] = Course.objects.filter(product_vertical__vertical=self.object).distinct()
+ return context
+
+
+class SubVerticalDetailView(VerticalTaggingAdministratorPermissionRequiredMixin, DetailView):
+ """
+ Render details of a specific sub-vertical and associated courses.
+ """
+ model = SubVertical
+ template_name = "tagging/sub_vertical_detail.html"
+ context_object_name = "sub_vertical"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["courses"] = Course.objects.filter(product_vertical__sub_vertical=self.object).distinct()
+ return context
diff --git a/course_discovery/settings/base.py b/course_discovery/settings/base.py
index d458635460..e4abde9a1e 100644
--- a/course_discovery/settings/base.py
+++ b/course_discovery/settings/base.py
@@ -68,6 +68,7 @@
'nested_admin',
'openedx_events',
'multi_email_field',
+ 'django_htmx',
]
ALGOLIA = {
@@ -120,6 +121,7 @@
'edx_django_utils.cache.middleware.TieredCacheMiddleware',
'edx_rest_framework_extensions.middleware.RequestMetricsMiddleware',
'edx_rest_framework_extensions.auth.jwt.middleware.EnsureJWTAuthSettingsMiddleware',
+ 'django_htmx.middleware.HtmxMiddleware',
)
ROOT_URLCONF = 'course_discovery.urls'
diff --git a/course_discovery/urls.py b/course_discovery/urls.py
index c4d1a865f3..0e0e2ab629 100644
--- a/course_discovery/urls.py
+++ b/course_discovery/urls.py
@@ -58,6 +58,7 @@
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
path('taggit_autosuggest/', include('taggit_autosuggest.urls')),
path('api/', include('course_discovery.apps.learner_pathway.api.urls', namespace='learner_pathway_api')),
+ path('tagging/', include('course_discovery.apps.tagging.urls', namespace='tagging')),
]
# edx-drf-extensions csrf app
diff --git a/requirements/base.in b/requirements/base.in
index e28c73844f..f7fa2dc227 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -80,3 +80,4 @@ xss-utils
taxonomy-connector
importlib-metadata
snowflake-connector-python
+django-htmx
diff --git a/requirements/local.txt b/requirements/local.txt
index e9f551b9ef..664b33550b 100644
--- a/requirements/local.txt
+++ b/requirements/local.txt
@@ -264,6 +264,8 @@ django-fsm==3.0.0
# via -r requirements/base.in
django-guardian==2.4.0
# via -r requirements/base.in
+django-htmx==1.21.0
+ # via -r requirements/base.in
django-libsass==0.9
# via -r requirements/base.in
django-localflavor==4.0
diff --git a/requirements/production.txt b/requirements/production.txt
index aaf4eeb18b..3a5079c70d 100644
--- a/requirements/production.txt
+++ b/requirements/production.txt
@@ -202,6 +202,8 @@ django-fsm==3.0.0
# via -r requirements/base.in
django-guardian==2.4.0
# via -r requirements/base.in
+django-htmx==1.21.0
+ # via -r requirements/base.in
django-libsass==0.9
# via -r requirements/base.in
django-localflavor==4.0