-
Notifications
You must be signed in to change notification settings - Fork 171
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add vertical models in tagging app (#4544)
- Loading branch information
1 parent
a03c683
commit 8ac5a04
Showing
9 changed files
with
665 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
133
course_discovery/apps/tagging/migrations/0001_initial.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.