diff --git a/course_discovery/apps/tagging/__init__.py b/course_discovery/apps/tagging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/course_discovery/apps/tagging/admin.py b/course_discovery/apps/tagging/admin.py new file mode 100644 index 0000000000..90b111bede --- /dev/null +++ b/course_discovery/apps/tagging/admin.py @@ -0,0 +1,127 @@ +import csv + +from django.contrib import admin, messages +from django.shortcuts import redirect, render +from django.urls import reverse + +from course_discovery.apps.course_metadata.models import Course +from course_discovery.apps.tagging.forms import CSVUploadForm +from course_discovery.apps.tagging.models import CourseVerticalFilters, SubVericalFilter, VerticalFilter, ProgramVerticalFilters, VerticalFilterTags + + +@admin.register(VerticalFilter) +class VerticalFilterAdmin(admin.ModelAdmin): + """ + Admin class for VerticalFilter model. + """ + list_display = ('name', 'is_active', 'description', 'slug',) + search_fields = ('name',) + + +@admin.register(SubVericalFilter) +class SubVericalFilterAdmin(admin.ModelAdmin): + """ + Admin class for SubVerticalFilter model. + """ + list_display = ('name', 'is_active', 'slug', 'description', 'vertical_filters') + list_filter = ('vertical_filters', ) + search_fields = ('name',) + ordering = ('name',) + + +@admin.register(CourseVerticalFilters) +class CourseVerticalFiltersAdmin(admin.ModelAdmin): + """ + Admin class for CourseVerticalFilters 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 the course field based on draft status. + """ + if db_field.name == 'course': + kwargs['queryset'] = Course.objects.filter(draft=False) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def changelist_view(self, request, extra_context=None): + """ + Override the changelist_view method to add a custom upload CSV button. + """ + extra_context = extra_context or {} + extra_context['upload_csv_url'] = reverse('admin:courseverticalfilters_upload_csv') + return super().changelist_view(request, extra_context=extra_context) + + def get_urls(self): + """ + Override the get_urls method to add a custom upload CSV URL. + """ + from django.urls import path + urls = super().get_urls() + custom_urls = [ + path('upload-csv/', self.upload_csv, name='courseverticalfilters_upload_csv'), + ] + return custom_urls + urls + + def upload_csv(self, request): + """ + Custom view to upload a CSV file and process it to update the course vertical filters to support bulk updates. + """ + if request.method == 'POST' and request.FILES.get('csv_file'): + form = CSVUploadForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = form.cleaned_data['csv_file'] + try: + decoded_file = csv_file.read().decode('utf-8').splitlines() + reader = csv.reader(decoded_file) + next(reader) # Skip the header row + for row in reader: + course_key = row[0] + vertical_name = row[1] + sub_vertical_name = row[2] + + course = Course.objects.get(key=course_key) + vertical, _ = VerticalFilter.objects.get_or_create(name=vertical_name) + sub_vertical, _ = SubVericalFilter.objects.get_or_create( + name=sub_vertical_name, vertical_filters=vertical + ) + + CourseVerticalFilters.objects.update_or_create( + course=course, + defaults={'vertical': vertical, 'sub_vertical': sub_vertical} + ) + + messages.success(request, "CSV uploaded and processed successfully.") + except Exception as e: + messages.error(request, f"Error processing CSV: {str(e)}") + return redirect('admin:tagging_courseverticalfilters_changelist') + else: + form = CSVUploadForm() + + return render( + request, + 'admin/tagging/courseverticalfilters/upload_csv_form.html', + context={'form': form} + ) + +@admin.register(ProgramVerticalFilters) +class ProgramVericalFiltersAdmin(admin.ModelAdmin): + """ + Admin class for Program Vertical Filters model. + """ + list_display = ('program', 'vertical', 'sub_vertical') + list_filter = ('vertical', 'sub_vertical') + search_fields = ('program__title', 'vertical__name', 'sub_vertical__name') + ordering = ('program__title',) + +@admin.register(VerticalFilterTags) +class VerticalFilterTagsAdmin(admin.ModelAdmin): + """ + Admin class for VerticalFilterTags model. + """ + list_display = ('content_type', 'object_id', 'vertical', 'sub_vertical') + list_filter = ('vertical', 'sub_vertical', 'content_type') + search_fields = ('object_id', ) diff --git a/course_discovery/apps/tagging/apps.py b/course_discovery/apps/tagging/apps.py new file mode 100644 index 0000000000..802f074c09 --- /dev/null +++ b/course_discovery/apps/tagging/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TaggingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'course_discovery.apps.tagging' diff --git a/course_discovery/apps/tagging/forms.py b/course_discovery/apps/tagging/forms.py new file mode 100644 index 0000000000..9cae41fa75 --- /dev/null +++ b/course_discovery/apps/tagging/forms.py @@ -0,0 +1,8 @@ +from django import forms + + +class CSVUploadForm(forms.Form): + """ + Form for uploading CSV file to support bulk upload feature + """ + csv_file = forms.FileField(label="Upload CSV File") 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..617a7d21fe --- /dev/null +++ b/course_discovery/apps/tagging/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# Generated by Django 4.2.14 on 2024-12-24 12:22 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_extensions.db.fields +import model_utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course_metadata', '0345_courserun_translation_languages_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='VerticalFilter', + 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)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name_plural': 'Vertical Filters', + 'ordering': ['name'], + 'unique_together': {('name',)}, + }, + ), + migrations.CreateModel( + name='SubVericalFilter', + 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)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('vertical_filters', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_vertical_filters', to='tagging.verticalfilter')), + ], + options={ + 'verbose_name_plural': 'Sub Vertical Filters', + 'ordering': ['name'], + 'unique_together': {('name',)}, + }, + ), + migrations.CreateModel( + name='VerticalFilterTags', + 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')), + ('content_type', models.CharField(choices=[('course', 'Course'), ('program', 'Program'), ('degree', 'Degree')], max_length=10)), + ('object_id', models.UUIDField()), + ('sub_vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_vertical_filters', to='tagging.subvericalfilter')), + ('vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vertical_filters', to='tagging.verticalfilter')), + ], + options={ + 'verbose_name_plural': 'Vertical Filter Tags', + 'ordering': ['content_type', 'object_id'], + 'unique_together': {('content_type', 'object_id', 'vertical')}, + }, + ), + migrations.CreateModel( + name='ProgramVerticalFilters', + 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')), + ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_vertical_filters', to='course_metadata.program')), + ('sub_vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_sub_vertical_filters', to='tagging.subvericalfilter')), + ('vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_vertical_filters', to='tagging.verticalfilter')), + ], + options={ + 'verbose_name_plural': 'Program Vertical Filters', + 'ordering': ['vertical', 'sub_vertical'], + 'abstract': False, + 'unique_together': {('program',)}, + }, + ), + migrations.CreateModel( + name='CourseVerticalFilters', + 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(on_delete=django.db.models.deletion.CASCADE, related_name='vertical_filters', to='course_metadata.course')), + ('sub_vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_sub_vertical_filters', to='tagging.subvericalfilter')), + ('vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_vertical_filters', to='tagging.verticalfilter')), + ], + options={ + 'verbose_name_plural': 'Course Vertical Filters', + 'ordering': ['vertical', 'sub_vertical'], + 'abstract': False, + 'unique_together': {('course',)}, + }, + ), + ] diff --git a/course_discovery/apps/tagging/migrations/__init__.py b/course_discovery/apps/tagging/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/course_discovery/apps/tagging/models.py b/course_discovery/apps/tagging/models.py new file mode 100644 index 0000000000..35dc89207f --- /dev/null +++ b/course_discovery/apps/tagging/models.py @@ -0,0 +1,131 @@ +from django.db import models +from django_extensions.db.fields import AutoSlugField +from model_utils.models import TimeStampedModel + +from course_discovery.apps.course_metadata.models import Course, Program, Degree + +class VerticalFilter(TimeStampedModel): + """ + This model is used to store the vertical mapping for the courses. + """ + name = models.CharField(max_length=255, unique=True) + slug = AutoSlugField(populate_from='name', max_length=255, unique=True) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name_plural = 'Vertical Filters' + ordering = ['name'] + unique_together = ['name'] + + +class SubVericalFilter(TimeStampedModel): + """ + This model is used to store the sub vertical mapping for the courses. + """ + name = models.CharField(max_length=255, unique=True) + slug = AutoSlugField(populate_from='name', max_length=255, unique=True) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + vertical_filters = models.ForeignKey(VerticalFilter, on_delete=models.CASCADE, related_name='sub_vertical_filters') + + def __str__(self): + return self.name + + class Meta: + verbose_name_plural = 'Sub Vertical Filters' + ordering = ['name'] + unique_together = ['name'] + +class BaseVerticalFilter(TimeStampedModel): + """ + Abstract base model for vertical and sub-vertical filters with timestamps. + """ + vertical = models.ForeignKey( + VerticalFilter, on_delete=models.CASCADE, related_name="%(class)s_vertical_filters" + ) + sub_vertical = models.ForeignKey( + SubVericalFilter, on_delete=models.CASCADE, related_name="%(class)s_sub_vertical_filters" + ) + + class Meta: + abstract = True + ordering = ["vertical", "sub_vertical"] + + def __str__(self): + return f'{self.get_object_title()} - {self.vertical.name} - {self.sub_vertical.name}' + + def get_object_title(self): + """ + Should be implemented in the derived models to return the object's title. + """ + raise NotImplementedError("Subclasses must implement `get_object_title`.") + +class CourseVerticalFilters(BaseVerticalFilter): + course = models.OneToOneField( + Course, on_delete=models.CASCADE, related_name="vertical_filters" + ) + + class Meta(BaseVerticalFilter.Meta): + verbose_name_plural = "Course Vertical Filters" + unique_together = ["course"] + + def get_object_title(self): + return self.course.title + + +class ProgramVerticalFilters(BaseVerticalFilter): + program = models.ForeignKey( + Program, on_delete=models.CASCADE, related_name="program_vertical_filters" + ) + + class Meta(BaseVerticalFilter.Meta): + verbose_name_plural = "Program Vertical Filters" + unique_together = ["program"] + + def get_object_title(self): + return self.program.title + +class VerticalFilterTags(TimeStampedModel): + """ + Model used to assign vertical and sub-vertical filters to Course, Program, or Degree. + """ + CONTENT_TYPE_CHOICES = [ + ('course', 'Course'), + ('program', 'Program'), + ('degree', 'Degree'), + ] + + content_type = models.CharField(max_length=10, choices=CONTENT_TYPE_CHOICES) + object_id = models.UUIDField() + + vertical = models.ForeignKey(VerticalFilter, on_delete=models.CASCADE, related_name='vertical_filters') + sub_vertical = models.ForeignKey( + SubVericalFilter, + on_delete=models.CASCADE, + related_name='sub_vertical_filters', + ) + + def __str__(self): + return f'{self.get_object_title()} - {self.vertical.name} - {self.sub_vertical.name}' + + def get_object_title(self): + """ + Retrieve the title of the related object based on its type. + """ + if self.content_type == 'course': + return Course.objects.filter(uuid=self.object_id).first().title + elif self.content_type == 'program': + return Program.objects.filter(uuid=self.object_id).first().title + elif self.content_type == 'degree': + return Degree.objects.filter(uuid=self.object_id).first().title + else: + return None + + class Meta: + verbose_name_plural = 'Vertical Filter Tags' + ordering = ['content_type', 'object_id'] + unique_together = ['content_type', 'object_id', 'vertical'] diff --git a/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/change_list.html b/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/change_list.html new file mode 100644 index 0000000000..ef14d1a932 --- /dev/null +++ b/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/change_list.html @@ -0,0 +1,13 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} + +{{ block.super }} +
+




+ + Upload CSV + + +
+{% endblock %} diff --git a/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/upload_csv_form.html b/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/upload_csv_form.html new file mode 100644 index 0000000000..bbd140e8a4 --- /dev/null +++ b/course_discovery/apps/tagging/templates/admin/tagging/courseverticalfilters/upload_csv_form.html @@ -0,0 +1,10 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

Upload CSV for Course Vertical Filters

+
+ {% csrf_token %} + {{ form.as_p }} + +
+

Back to Course Vertical Filters

+{% endblock %} diff --git a/course_discovery/apps/tagging/tests.py b/course_discovery/apps/tagging/tests.py new file mode 100644 index 0000000000..7ce503c2dd --- /dev/null +++ b/course_discovery/apps/tagging/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/course_discovery/apps/tagging/views.py b/course_discovery/apps/tagging/views.py new file mode 100644 index 0000000000..91ea44a218 --- /dev/null +++ b/course_discovery/apps/tagging/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/course_discovery/settings/base.py b/course_discovery/settings/base.py index 2d578ac255..d1ba83e24e 100644 --- a/course_discovery/settings/base.py +++ b/course_discovery/settings/base.py @@ -87,6 +87,7 @@ 'course_discovery.apps.publisher_comments', 'course_discovery.apps.learner_pathway', 'course_discovery.apps.taxonomy_support', + 'course_discovery.apps.tagging', ] ES_APPS = [