Browse Source

feat: automatic smart unique slug generation for video and podcast models and serializers

master
Mohsen Taba 3 weeks ago
parent
commit
c40d783822
  1. 18
      apps/podcast/migrations/0005_alter_podcast_slug.py
  2. 2
      apps/podcast/models.py
  3. 45
      apps/podcast/serializers_admin.py
  4. 18
      apps/video/migrations/0005_alter_video_slug.py
  5. 2
      apps/video/models.py
  6. 43
      apps/video/serializers_admin.py
  7. 14
      utils/slug.py

18
apps/podcast/migrations/0005_alter_podcast_slug.py

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-08-27 11:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcast', '0004_alter_podcastcollection_slug'),
]
operations = [
migrations.AlterField(
model_name='podcast',
name='slug',
field=models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, unique=True),
),
]

2
apps/podcast/models.py

@ -73,7 +73,7 @@ class Podcast(LowercaseSlugMixin, models.Model):
slug_source_field = 'title' slug_source_field = 'title'
title = models.CharField(max_length=255, null=True) title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
slug = models.SlugField(max_length=255, allow_unicode=True, unique=True, blank=True, null=True)
thumbnail = models.ImageField(upload_to='podcast_thumbnails/', null=True, blank=True, help_text=_('image allowed')) thumbnail = models.ImageField(upload_to='podcast_thumbnails/', null=True, blank=True, help_text=_('image allowed'))
description = models.TextField(null=True) description = models.TextField(null=True)

45
apps/podcast/serializers_admin.py

@ -2,6 +2,7 @@ from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file from utils.image_compression import maybe_compress_uploaded_file
from utils import absolute_https_url from utils import absolute_https_url
from utils.slug import generate_smart_slug
from .models import PlaylistItem, Podcast, PodcastCategory, PodcastCollection, PodcastPlaylist from .models import PlaylistItem, Podcast, PodcastCategory, PodcastCollection, PodcastPlaylist
@ -39,20 +40,43 @@ class AbsoluteFileField(serializers.FileField):
class AdminPodcastCategorySerializer(serializers.ModelSerializer): class AdminPodcastCategorySerializer(serializers.ModelSerializer):
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
class Meta: class Meta:
model = PodcastCategory model = PodcastCategory
fields = ["id", "title", "slug", "status", "created_at", "updated_at"] fields = ["id", "title", "slug", "status", "created_at", "updated_at"]
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, PodcastCategory, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, PodcastCategory, instance=self.instance)
return attrs
class AdminPodcastCollectionSerializer(serializers.ModelSerializer): class AdminPodcastCollectionSerializer(serializers.ModelSerializer):
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
class Meta: class Meta:
model = PodcastCollection model = PodcastCollection
fields = ["id", "title", "slug", "summary", "display_position", "status", "order"] fields = ["id", "title", "slug", "summary", "display_position", "status", "order"]
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, PodcastCollection, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, PodcastCollection, instance=self.instance)
return attrs
class AdminPodcastListSerializer(serializers.ModelSerializer): class AdminPodcastListSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
audio_file = AbsoluteFileField(required=False, allow_null=True) audio_file = AbsoluteFileField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True) categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True)
collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True)
@ -77,6 +101,7 @@ class AdminPodcastListSerializer(serializers.ModelSerializer):
class AdminPodcastDetailSerializer(serializers.ModelSerializer): class AdminPodcastDetailSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
audio_file = AbsoluteFileField(required=False, allow_null=True) audio_file = AbsoluteFileField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
audio_time = serializers.TimeField(required=False) audio_time = serializers.TimeField(required=False)
categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.objects.all(), many=True, required=False) categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.objects.all(), many=True, required=False)
collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False) collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False)
@ -116,6 +141,15 @@ class AdminPodcastDetailSerializer(serializers.ModelSerializer):
from apps.bookmark.models.bookmark import Bookmark from apps.bookmark.models.bookmark import Bookmark
return Bookmark.objects.filter(service=Bookmark.ServiceChoices.PODCAST, content_id=obj.id, status=True).count() return Bookmark.objects.filter(service=Bookmark.ServiceChoices.PODCAST, content_id=obj.id, status=True).count()
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, Podcast, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, Podcast, instance=self.instance)
return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data.pop("remove_thumbnail", False) validated_data.pop("remove_thumbnail", False)
validated_data.pop("remove_audio_file", False) validated_data.pop("remove_audio_file", False)
@ -183,6 +217,7 @@ class AdminPodcastPlaylistItemSerializer(serializers.ModelSerializer):
class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer): class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True) categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True)
collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True)
items_count = serializers.SerializerMethodField() items_count = serializers.SerializerMethodField()
@ -215,6 +250,7 @@ class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer):
class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer): class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.objects.all(), many=True, required=False) categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.objects.all(), many=True, required=False)
collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False) collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False)
categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True) categories_detail = AdminPodcastCategorySerializer(source="categories", many=True, read_only=True)
@ -253,6 +289,15 @@ class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer):
from apps.bookmark.models.bookmark import Bookmark from apps.bookmark.models.bookmark import Bookmark
return Bookmark.objects.filter(service=Bookmark.ServiceChoices.PODCAST_PLAYLIST, content_id=obj.id, status=True).count() return Bookmark.objects.filter(service=Bookmark.ServiceChoices.PODCAST_PLAYLIST, content_id=obj.id, status=True).count()
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, PodcastPlaylist, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, PodcastPlaylist, instance=self.instance)
return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data.pop("remove_thumbnail", False) validated_data.pop("remove_thumbnail", False)
categories = validated_data.pop("categories", []) categories = validated_data.pop("categories", [])

18
apps/video/migrations/0005_alter_video_slug.py

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-08-27 11:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('video', '0004_alter_videocollection_slug'),
]
operations = [
migrations.AlterField(
model_name='video',
name='slug',
field=models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, unique=True),
),
]

2
apps/video/models.py

@ -78,7 +78,7 @@ class Video(LowercaseSlugMixin, models.Model):
VIDEO_FILE = 'video_file', 'Video File' VIDEO_FILE = 'video_file', 'Video File'
title = models.CharField(max_length=255, null=True) title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
slug = models.SlugField(max_length=255, allow_unicode=True, unique=True, blank=True, null=True)
thumbnail = models.ImageField(upload_to='video/thumbnails/', null=True, blank=True, help_text=_('image allowed')) thumbnail = models.ImageField(upload_to='video/thumbnails/', null=True, blank=True, help_text=_('image allowed'))
description = models.TextField(null=True) description = models.TextField(null=True)

43
apps/video/serializers_admin.py

@ -2,6 +2,7 @@ from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file from utils.image_compression import maybe_compress_uploaded_file
from utils import absolute_https_url from utils import absolute_https_url
from utils.slug import generate_smart_slug
from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist
@ -39,20 +40,43 @@ class AbsoluteFileField(serializers.FileField):
class AdminVideoCategorySerializer(serializers.ModelSerializer): class AdminVideoCategorySerializer(serializers.ModelSerializer):
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
class Meta: class Meta:
model = VideoCategory model = VideoCategory
fields = ["id", "title", "slug", "status", "order"] fields = ["id", "title", "slug", "status", "order"]
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, VideoCategory, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, VideoCategory, instance=self.instance)
return attrs
class AdminVideoCollectionSerializer(serializers.ModelSerializer): class AdminVideoCollectionSerializer(serializers.ModelSerializer):
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
class Meta: class Meta:
model = VideoCollection model = VideoCollection
fields = ["id", "title", "slug", "summary", "display_position", "status", "order"] fields = ["id", "title", "slug", "summary", "display_position", "status", "order"]
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, VideoCollection, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, VideoCollection, instance=self.instance)
return attrs
class AdminVideoListSerializer(serializers.ModelSerializer): class AdminVideoListSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
video_file = AbsoluteFileField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
class Meta: class Meta:
model = Video model = Video
@ -77,6 +101,7 @@ class AdminVideoListSerializer(serializers.ModelSerializer):
class AdminVideoDetailSerializer(serializers.ModelSerializer): class AdminVideoDetailSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
video_file = AbsoluteFileField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False)
remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False) remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False)
@ -109,6 +134,13 @@ class AdminVideoDetailSerializer(serializers.ModelSerializer):
return Bookmark.objects.filter(service=Bookmark.ServiceChoices.VIDEO, content_id=obj.id, status=True).count() return Bookmark.objects.filter(service=Bookmark.ServiceChoices.VIDEO, content_id=obj.id, status=True).count()
def validate(self, attrs): def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, Video, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, Video, instance=self.instance)
video_type = attrs.get("video_type", getattr(self.instance, "video_type", None)) video_type = attrs.get("video_type", getattr(self.instance, "video_type", None))
video_url = attrs.get("video_url", getattr(self.instance, "video_url", None)) video_url = attrs.get("video_url", getattr(self.instance, "video_url", None))
video_file = attrs.get("video_file", getattr(self.instance, "video_file", None)) video_file = attrs.get("video_file", getattr(self.instance, "video_file", None))
@ -184,6 +216,7 @@ class AdminVideoPlaylistItemSerializer(serializers.ModelSerializer):
class AdminVideoPlaylistListSerializer(serializers.ModelSerializer): class AdminVideoPlaylistListSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True) categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True)
collections_detail = AdminVideoCollectionSerializer(source="collections", many=True, read_only=True) collections_detail = AdminVideoCollectionSerializer(source="collections", many=True, read_only=True)
items_count = serializers.SerializerMethodField() items_count = serializers.SerializerMethodField()
@ -216,6 +249,7 @@ class AdminVideoPlaylistListSerializer(serializers.ModelSerializer):
class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer): class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
slug = serializers.SlugField(required=False, allow_blank=True, validators=[])
categories = serializers.PrimaryKeyRelatedField(queryset=VideoCategory.objects.all(), many=True, required=False) categories = serializers.PrimaryKeyRelatedField(queryset=VideoCategory.objects.all(), many=True, required=False)
collections = serializers.PrimaryKeyRelatedField(queryset=VideoCollection.objects.all(), many=True, required=False) collections = serializers.PrimaryKeyRelatedField(queryset=VideoCollection.objects.all(), many=True, required=False)
categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True) categories_detail = AdminVideoCategorySerializer(source="categories", many=True, read_only=True)
@ -254,6 +288,15 @@ class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer):
from apps.bookmark.models.bookmark import Bookmark from apps.bookmark.models.bookmark import Bookmark
return Bookmark.objects.filter(service=Bookmark.ServiceChoices.VIDEO_PLAYLIST, content_id=obj.id, status=True).count() return Bookmark.objects.filter(service=Bookmark.ServiceChoices.VIDEO_PLAYLIST, content_id=obj.id, status=True).count()
def validate(self, attrs):
title = attrs.get("title") or getattr(self.instance, "title", "")
slug = attrs.get("slug")
if not slug and title:
attrs["slug"] = generate_smart_slug(title, VideoPlaylist, instance=self.instance)
elif slug:
attrs["slug"] = generate_smart_slug(slug, VideoPlaylist, instance=self.instance)
return attrs
def create(self, validated_data): def create(self, validated_data):
validated_data.pop("remove_thumbnail", False) validated_data.pop("remove_thumbnail", False)
categories = validated_data.pop("categories", []) categories = validated_data.pop("categories", [])

14
utils/slug.py

@ -170,6 +170,12 @@ def build_slug_for_instance(instance, source_field='title', slug_field='slug', m
if is_new: if is_new:
if not current_slug or not str(current_slug).strip(): if not current_slug or not str(current_slug).strip():
should_generate = True should_generate = True
else:
# If a slug is provided for a new record, verify uniqueness
if model_class.objects.filter(**{slug_field: str(current_slug).strip().lower()}).exists():
should_generate = True
if not text:
text = str(current_slug).strip()
else: else:
# Fetch the original source and slug values from DB # Fetch the original source and slug values from DB
orig = model_class.objects.filter(pk=instance.pk).values(source_field, slug_field).first() orig = model_class.objects.filter(pk=instance.pk).values(source_field, slug_field).first()
@ -181,11 +187,17 @@ def build_slug_for_instance(instance, source_field='title', slug_field='slug', m
# If the text from the source field changed, or the slug is empty # If the text from the source field changed, or the slug is empty
if orig_text != text or not orig_slug or not str(orig_slug).strip(): if orig_text != text or not orig_slug or not str(orig_slug).strip():
should_generate = True should_generate = True
elif current_slug and str(current_slug).strip().lower() != (orig_slug or "").lower():
# If slug was manually changed on update, check for conflict with other records
if model_class.objects.filter(**{slug_field: str(current_slug).strip().lower()}).exclude(pk=instance.pk).exists():
should_generate = True
text = str(current_slug).strip()
if should_generate: if should_generate:
# Generate new slug using generate_smart_slug to handle uniqueness, unicode, and limits safely # Generate new slug using generate_smart_slug to handle uniqueness, unicode, and limits safely
base_text = text or (str(current_slug).strip() if current_slug else "") or f"{model_class.__name__.lower()}"
new_slug = generate_smart_slug( new_slug = generate_smart_slug(
text=text,
text=base_text,
model_class=model_class, model_class=model_class,
max_length=max_length, max_length=max_length,
field_name=slug_field, field_name=slug_field,

Loading…
Cancel
Save