diff --git a/apps/podcast/migrations/0005_alter_podcast_slug.py b/apps/podcast/migrations/0005_alter_podcast_slug.py new file mode 100644 index 0000000..05c7673 --- /dev/null +++ b/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), + ), + ] diff --git a/apps/podcast/models.py b/apps/podcast/models.py index 94fb79d..5366cb7 100644 --- a/apps/podcast/models.py +++ b/apps/podcast/models.py @@ -73,7 +73,7 @@ class Podcast(LowercaseSlugMixin, models.Model): slug_source_field = 'title' 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')) description = models.TextField(null=True) diff --git a/apps/podcast/serializers_admin.py b/apps/podcast/serializers_admin.py index 7128c2b..cb76e23 100644 --- a/apps/podcast/serializers_admin.py +++ b/apps/podcast/serializers_admin.py @@ -2,6 +2,7 @@ from rest_framework import serializers from django.core.files.uploadedfile import SimpleUploadedFile from utils.image_compression import maybe_compress_uploaded_file from utils import absolute_https_url +from utils.slug import generate_smart_slug from .models import PlaylistItem, Podcast, PodcastCategory, PodcastCollection, PodcastPlaylist @@ -39,20 +40,43 @@ class AbsoluteFileField(serializers.FileField): class AdminPodcastCategorySerializer(serializers.ModelSerializer): + slug = serializers.SlugField(required=False, allow_blank=True, validators=[]) + class Meta: model = PodcastCategory 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): + slug = serializers.SlugField(required=False, allow_blank=True, validators=[]) + class Meta: model = PodcastCollection 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): thumbnail = AbsoluteImageField(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) collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) @@ -77,6 +101,7 @@ class AdminPodcastListSerializer(serializers.ModelSerializer): class AdminPodcastDetailSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(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) categories = serializers.PrimaryKeyRelatedField(queryset=PodcastCategory.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 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): validated_data.pop("remove_thumbnail", False) validated_data.pop("remove_audio_file", False) @@ -183,6 +217,7 @@ class AdminPodcastPlaylistItemSerializer(serializers.ModelSerializer): class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer): 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) collections_detail = AdminPodcastCollectionSerializer(source="collections", many=True, read_only=True) items_count = serializers.SerializerMethodField() @@ -215,6 +250,7 @@ class AdminPodcastPlaylistListSerializer(serializers.ModelSerializer): class AdminPodcastPlaylistDetailSerializer(serializers.ModelSerializer): 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) collections = serializers.PrimaryKeyRelatedField(queryset=PodcastCollection.objects.all(), many=True, required=False) 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 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): validated_data.pop("remove_thumbnail", False) categories = validated_data.pop("categories", []) diff --git a/apps/video/migrations/0005_alter_video_slug.py b/apps/video/migrations/0005_alter_video_slug.py new file mode 100644 index 0000000..4f0c492 --- /dev/null +++ b/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), + ), + ] diff --git a/apps/video/models.py b/apps/video/models.py index 2cffb60..0b3c179 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -78,7 +78,7 @@ class Video(LowercaseSlugMixin, models.Model): VIDEO_FILE = 'video_file', 'Video File' 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')) description = models.TextField(null=True) diff --git a/apps/video/serializers_admin.py b/apps/video/serializers_admin.py index a0a9239..ffae08b 100644 --- a/apps/video/serializers_admin.py +++ b/apps/video/serializers_admin.py @@ -2,6 +2,7 @@ from rest_framework import serializers from django.core.files.uploadedfile import SimpleUploadedFile from utils.image_compression import maybe_compress_uploaded_file from utils import absolute_https_url +from utils.slug import generate_smart_slug from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist @@ -39,20 +40,43 @@ class AbsoluteFileField(serializers.FileField): class AdminVideoCategorySerializer(serializers.ModelSerializer): + slug = serializers.SlugField(required=False, allow_blank=True, validators=[]) + class Meta: model = VideoCategory 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): + slug = serializers.SlugField(required=False, allow_blank=True, validators=[]) + class Meta: model = VideoCollection 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): thumbnail = AbsoluteImageField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True) + slug = serializers.SlugField(required=False, allow_blank=True, validators=[]) class Meta: model = Video @@ -77,6 +101,7 @@ class AdminVideoListSerializer(serializers.ModelSerializer): class AdminVideoDetailSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(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_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() 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_url = attrs.get("video_url", getattr(self.instance, "video_url", 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): 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) collections_detail = AdminVideoCollectionSerializer(source="collections", many=True, read_only=True) items_count = serializers.SerializerMethodField() @@ -216,6 +249,7 @@ class AdminVideoPlaylistListSerializer(serializers.ModelSerializer): class AdminVideoPlaylistDetailSerializer(serializers.ModelSerializer): 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) collections = serializers.PrimaryKeyRelatedField(queryset=VideoCollection.objects.all(), many=True, required=False) 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 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): validated_data.pop("remove_thumbnail", False) categories = validated_data.pop("categories", []) diff --git a/utils/slug.py b/utils/slug.py index cb47e1e..b7e2acf 100644 --- a/utils/slug.py +++ b/utils/slug.py @@ -170,6 +170,12 @@ def build_slug_for_instance(instance, source_field='title', slug_field='slug', m if is_new: if not current_slug or not str(current_slug).strip(): 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: # Fetch the original source and slug values from DB 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 orig_text != text or not orig_slug or not str(orig_slug).strip(): 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: # 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( - text=text, + text=base_text, model_class=model_class, max_length=max_length, field_name=slug_field,