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 @@ + + + + + + + + + + + + {% for course in courses %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
# + + Course Key + {% if current_sort == 'key' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + + + + Course Title + {% if current_sort == 'title' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + + + + Vertical + {% if current_sort == 'vertical' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + + + + Sub-Vertical + {% if current_sort == 'sub_vertical' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + +
{{ forloop.counter }}{{ course.key }} + + {{ course.title }} + + + {% if course.product_vertical and course.product_vertical.vertical %} + + {{ course.product_vertical.vertical.name }} + + {% else %} + None + {% endif %} + + {% if course.product_vertical and course.product_vertical.sub_vertical %} + + {{ course.product_vertical.sub_vertical.name }} + + {% else %} + None + {% endif %} +
No courses found.
+ +{% 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

+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + +{% 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 %} +
+

Sub-Vertical List

+ + + + + + + + + + {% for sub_vertical in sub_verticals %} + + + + + + {% endfor %} + +
+ + Sub-Vertical + {% if current_sort == 'name' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + + Parent VerticalAssigned Courses
+ + {{ sub_vertical.name }} + + + + {{ sub_vertical.vertical.name }} + + + {% with sub_vertical.coursevertical_sub_verticals.count as course_count %} + {{ course_count }} course{% if course_count != 1 %}s{% endif %} + {% endwith %} +
+
+{% 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

+ + +

Tagged Courses

+ +
+{% 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 %} +
+

Verticals List

+ + + + + + + + + {% for vertical in verticals %} + + + + + {% endfor %} + +
+ + Vertical Name + {% if current_sort == 'name' %} + {% if current_direction == 'asc' %}▲{% else %}▼{% endif %} + {% endif %} + + Assigned Courses
+ + {{ vertical.name }} + + + {% with vertical.coursevertical_verticals.count as course_count %} + {{ course_count }} course{% if course_count != 1 %}s{% endif %} + {% endwith %} +
+
+{% 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