Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [POC] for Vertical Tagging Feature #4523

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
127 changes: 127 additions & 0 deletions course_discovery/apps/tagging/admin.py
Original file line number Diff line number Diff line change
@@ -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', )
6 changes: 6 additions & 0 deletions course_discovery/apps/tagging/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class TaggingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'course_discovery.apps.tagging'
8 changes: 8 additions & 0 deletions course_discovery/apps/tagging/forms.py
Original file line number Diff line number Diff line change
@@ -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")
105 changes: 105 additions & 0 deletions course_discovery/apps/tagging/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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',)},
},
),
]
Empty file.
131 changes: 131 additions & 0 deletions course_discovery/apps/tagging/models.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 17 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L17

Added line #L17 was not covered by tests

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

Check warning on line 36 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L36

Added line #L36 was not covered by tests

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}'

Check warning on line 59 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L59

Added line #L59 was not covered by tests

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`.")

Check warning on line 65 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L65

Added line #L65 was not covered by tests

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

Check warning on line 77 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L77

Added line #L77 was not covered by tests


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

Check warning on line 90 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L90

Added line #L90 was not covered by tests

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}'

Check warning on line 113 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L113

Added line #L113 was not covered by tests

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

Check warning on line 120 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L120

Added line #L120 was not covered by tests
elif self.content_type == 'program':
return Program.objects.filter(uuid=self.object_id).first().title

Check warning on line 122 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L122

Added line #L122 was not covered by tests
elif self.content_type == 'degree':
return Degree.objects.filter(uuid=self.object_id).first().title

Check warning on line 124 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L124

Added line #L124 was not covered by tests
else:
return None

Check warning on line 126 in course_discovery/apps/tagging/models.py

View check run for this annotation

Codecov / codecov/patch

course_discovery/apps/tagging/models.py#L126

Added line #L126 was not covered by tests

class Meta:
verbose_name_plural = 'Vertical Filter Tags'
ordering = ['content_type', 'object_id']
unique_together = ['content_type', 'object_id', 'vertical']
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% extends "admin/change_list.html" %}

{% block object-tools %}
</div>
{{ block.super }}
<div class="object-tools">
<br /> <br /> <br /> <br /> <br />
<a href="{% url 'admin:courseverticalfilters_upload_csv' %}" class="button button-primary">
Upload CSV
</a>

</div>
{% endblock %}
Loading
Loading