Skip to content

Commit

Permalink
feat: add vertical models in tagging app (#4544)
Browse files Browse the repository at this point in the history
  • Loading branch information
AfaqShuaib09 authored Jan 21, 2025
1 parent a03c683 commit 8ac5a04
Show file tree
Hide file tree
Showing 9 changed files with 665 additions and 0 deletions.
93 changes: 93 additions & 0 deletions course_discovery/apps/tagging/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
""" This module contains the admin classes for the tagging app models """
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from simple_history.admin import SimpleHistoryAdmin

from course_discovery.apps.tagging.models import CourseVertical, SubVertical, Vertical


class SubVerticalInline(admin.TabularInline):
"""
Inline form for SubVertical under VerticalAdmin.
"""
model = SubVertical
extra = 0
fields = ('name', 'is_active', 'slug')
readonly_fields = ('slug',)
show_change_link = True


@admin.register(Vertical)
class VerticalAdmin(SimpleHistoryAdmin):
"""
Admin class for Vertical model.
"""
list_display = ('name', 'is_active', 'slug',)
search_fields = ('name',)
inlines = [SubVerticalInline]

def save_model(self, request, obj, form, change):
"""
Override the save_model method to restrict non-superuser from saving the model
"""
if not request.user.is_superuser:
raise PermissionDenied("You are not authorized to perform this action.")
super().save_model(request, obj, form, change)


@admin.register(SubVertical)
class SubVerticalAdmin(SimpleHistoryAdmin):
"""
Admin class for SubVertical model.
"""
list_display = ('name', 'is_active', 'slug', 'vertical')
list_filter = ('vertical', )
search_fields = ('name',)
ordering = ('name',)

def save_model(self, request, obj, form, change):
"""
Override the save_model method to restrict non-superuser from saving the model
"""
if not request.user.is_superuser:
raise PermissionDenied("You are not authorized to perform this action.")
super().save_model(request, obj, form, change)


@admin.register(CourseVertical)
class CourseVerticalAdmin(SimpleHistoryAdmin):
"""
Admin class for CourseVertical model.
"""
list_display = ('course', 'vertical', 'sub_vertical')
list_filter = ('vertical', 'sub_vertical')
search_fields = ('course__title', 'vertical__name', 'sub_vertical__name')
ordering = ('course__title',)
autocomplete_fields = ('course',)

def get_queryset(self, request):
"""
Override the get_queryset method to select related fields and resolve N+1 queries.
"""
return super().get_queryset(request).select_related('course', 'vertical', 'sub_vertical')

def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""
Override the formfield_for_foreignkey method to filter non-draft entry of courses and active vertical and
sub-vertical filters.
"""
if db_field.name == 'vertical':
kwargs['queryset'] = Vertical.objects.filter(is_active=True)
elif db_field.name == 'sub_vertical':
kwargs['queryset'] = SubVertical.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)

def save_model(self, request, obj, form, change):
"""
Override the save_model method to allow only superuser and users in allowed groups to save the model.
"""
allowed_groups = getattr(settings, 'VERTICALS_MANAGEMENT_GROUPS', [])
if not (request.user.is_superuser or request.user.groups.filter(name__in=allowed_groups).exists()):
raise PermissionDenied("You are not authorized to perform this action.")
super().save_model(request, obj, form, change)
133 changes: 133 additions & 0 deletions course_discovery/apps/tagging/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Generated by Django 4.2.14 on 2025-01-20 12:04

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_extensions.db.fields
import model_utils.fields
import simple_history.models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('course_metadata', '0346_archivecoursesconfig'),
]

operations = [
migrations.CreateModel(
name='Vertical',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(max_length=255, unique=True)),
('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name', unique=True)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SubVertical',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(max_length=255, unique=True)),
('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from='name', unique=True)),
('is_active', models.BooleanField(default=True)),
('vertical', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_verticals', to='tagging.vertical')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='HistoricalVertical',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(db_index=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical vertical',
'verbose_name_plural': 'historical verticals',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalSubVertical',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(db_index=True, max_length=255)),
('is_active', models.BooleanField(default=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('vertical', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='tagging.vertical')),
],
options={
'verbose_name': 'historical sub vertical',
'verbose_name_plural': 'historical sub verticals',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalCourseVertical',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('course', models.ForeignKey(blank=True, db_constraint=False, limit_choices_to={'draft': False}, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='course_metadata.course')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('sub_vertical', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='tagging.subvertical')),
('vertical', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='tagging.vertical')),
],
options={
'verbose_name': 'historical course vertical',
'verbose_name_plural': 'historical course verticals',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='CourseVertical',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('course', models.OneToOneField(limit_choices_to={'draft': False}, on_delete=django.db.models.deletion.CASCADE, related_name='vertical', to='course_metadata.course')),
('sub_vertical', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_sub_verticals', to='tagging.subvertical')),
('vertical', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_verticals', to='tagging.vertical')),
],
options={
'abstract': False,
},
),
]
113 changes: 113 additions & 0 deletions course_discovery/apps/tagging/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from django.core.exceptions import ValidationError
from django.db import models
from django_extensions.db.fields import AutoSlugField
from model_utils.models import TimeStampedModel
from simple_history.models import HistoricalRecords

from course_discovery.apps.course_metadata.models import Course


class Vertical(TimeStampedModel):
"""
Model for defining verticals used to categorize products
"""
name = models.CharField(max_length=255, unique=True)
slug = AutoSlugField(populate_from='name', max_length=255, unique=True, db_index=True)
is_active = models.BooleanField(default=True)
history = HistoricalRecords(excluded_fields=['slug'])

def __str__(self):
return self.name

class Meta:
ordering = ['name']

def save(self, *args, **kwargs):
"""
Override the save method to deactivate related sub-verticals when `is_active` is set to False.
"""
if self.pk:
cur_instance = Vertical.objects.get(pk=self.pk)
if cur_instance.is_active and not self.is_active:
self.sub_verticals.update(is_active=False)

super().save(*args, **kwargs)


class SubVertical(TimeStampedModel):
"""
Model for defining sub-verticals used to categorize products under specific verticals.
"""
name = models.CharField(max_length=255, unique=True)
slug = AutoSlugField(populate_from='name', max_length=255, unique=True, db_index=True)
is_active = models.BooleanField(default=True)
vertical = models.ForeignKey(Vertical, on_delete=models.CASCADE, related_name='sub_verticals')
history = HistoricalRecords(excluded_fields=['slug'])

def __str__(self):
return self.name


class ProductVertical(TimeStampedModel):
"""
Abstract base model for assigning vertical and sub verticals to product types.
"""
vertical = models.ForeignKey(
Vertical, on_delete=models.CASCADE, null=True, blank=True, related_name="%(class)s_verticals"
)
sub_vertical = models.ForeignKey(
SubVertical, on_delete=models.CASCADE, null=True, blank=True, related_name="%(class)s_sub_verticals"
)
history = HistoricalRecords(inherit=True)

class Meta:
abstract = True

def __str__(self):
"""
Returns a string representing the object.
"""
vertical = self.vertical.name if self.vertical else "None"
sub_vertical = self.sub_vertical.name if self.sub_vertical else "None"
return f'{self.get_object_title()} - {vertical} - {sub_vertical}'

def get_object_title(self):
"""
Returns a string representing the title of the object.
"""
raise NotImplementedError("Subclasses must implement `get_object_title`.")


class CourseVertical(ProductVertical):
"""
Model for assigning vertical and sub verticals to courses
"""
course = models.OneToOneField(
Course, on_delete=models.CASCADE, related_name="vertical", limit_choices_to={'draft': False}
)

def clean(self):
"""
Validate that the sub_vertical belongs to the selected vertical.
Automatically set the vertical if only sub_vertical is set.
"""
super().clean()
if hasattr(self, 'sub_vertical') and self.sub_vertical:
if not self.vertical:
self.vertical = self.sub_vertical.vertical # Auto-assign vertical if it's not set

if self.sub_vertical.vertical and self.sub_vertical.vertical != self.vertical:
raise ValidationError({
'sub_vertical': f'Sub-vertical "{self.sub_vertical.name}" does not belong to '
f'vertical "{self.vertical.name}".'
})

def save(self, *args, **kwargs):
"""
Call full_clean before saving to ensure validation is always run
"""
self.full_clean()
super().save(*args, **kwargs)

def get_object_title(self):
return self.course.title
File renamed without changes.
42 changes: 42 additions & 0 deletions course_discovery/apps/tagging/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
""" Factories for tagging app models """
import factory
from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyText

from course_discovery.apps.course_metadata.tests.factories import CourseFactory
from course_discovery.apps.tagging.models import CourseVertical, SubVertical, Vertical


class VerticalFactory(DjangoModelFactory):
"""
Factory for Vertical model
"""
class Meta:
model = Vertical

name = FuzzyText()
is_active = True


class SubVerticalFactory(DjangoModelFactory):
"""
Factory for SubVertical model
"""
class Meta:
model = SubVertical

name = FuzzyText()
vertical = factory.SubFactory(VerticalFactory)
is_active = True


class CourseVerticalFactory(DjangoModelFactory):
"""
Factory for CourseVertical model
"""
class Meta:
model = CourseVertical

course = factory.SubFactory(CourseFactory)
vertical = factory.SubFactory(VerticalFactory)
sub_vertical = factory.SubFactory(SubVerticalFactory)
Loading

0 comments on commit 8ac5a04

Please sign in to comment.