From d5ae8d22c4afd645b2b3aceaf07363f713e91d06 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 14 Jul 2026 11:55:11 +0330 Subject: [PATCH] slug generator mixin upgraded with update and save auto slug create --- apps/article/models.py | 14 +- apps/hadis/models/category.py | 60 +-------- apps/hadis/models/hadis.py | 146 +-------------------- apps/hadis/models/reference.py | 94 ++----------- apps/hadis/models/transmitter.py | 217 ++----------------------------- apps/library/models.py | 22 +--- apps/podcast/models.py | 14 +- apps/video/models.py | 22 +--- utils/admin.py | 35 ----- utils/mixins.py | 32 +---- utils/slug.py | 61 +++++++++ 11 files changed, 106 insertions(+), 611 deletions(-) diff --git a/apps/article/models.py b/apps/article/models.py index 2c137f9..be88858 100755 --- a/apps/article/models.py +++ b/apps/article/models.py @@ -6,7 +6,7 @@ from utils import generate_slug_for_model from utils.mixins import LowercaseSlugMixin -class ArticleCategory(models.Model): +class ArticleCategory(LowercaseSlugMixin, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) @@ -18,18 +18,13 @@ class ArticleCategory(models.Model): def __str__(self): return self.title - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(ArticleCategory, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Article Category') verbose_name_plural = _('Article Categories') ordering = ['order'] -class ArticleCollection(models.Model): +class ArticleCollection(LowercaseSlugMixin, models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') @@ -60,11 +55,6 @@ class ArticleCollection(models.Model): def __str__(self): return f'Collection #{self.id}/{self.title}' - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(ArticleCollection, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Article Collection') verbose_name_plural = _('Articles Collections') diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py index 9f7898d..0ee0220 100644 --- a/apps/hadis/models/category.py +++ b/apps/hadis/models/category.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from mptt.models import MPTTModel, TreeForeignKey from django.utils.text import slugify from django.conf import settings +from utils.mixins import LowercaseSlugMixin class HadisSect(models.Model): @@ -63,7 +64,7 @@ class HadisSect(models.Model): ordering = ('order',) -class HadisCategory(MPTTModel): +class HadisCategory(LowercaseSlugMixin, MPTTModel): class SourceType(models.TextChoices): QURAN = 'quran', _('Quran') HADITH = 'hadith', _('Hadith') @@ -92,63 +93,10 @@ class HadisCategory(MPTTModel): f'Your sect: {self.sect.sect_type}') ) + slug_source_field = 'title' + def save(self, *args, **kwargs): self.full_clean() - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get('text', '').strip() - if title_text: - base_slug = slugify(title_text, allow_unicode=True) - slug = base_slug - counter = 1 - while HadisCategory.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback if text is empty - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"category-{suffix}" - counter = 1 - while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"category-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if structure is invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"category-{suffix}" - counter = 1 - while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"category-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if title is empty or invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"category-{suffix}" - counter = 1 - while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"category-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback on any error - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"category-{suffix}" - counter = 1 - while HadisCategory.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"category-{suffix}-{counter}" - counter += 1 - self.slug = base_slug super().save(*args, **kwargs) @property diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index d81d64f..3b46960 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -9,7 +9,8 @@ from .reference import BookReference from utils.slug import generate_smart_slug from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin -class HadisCollection(models.Model): +class HadisCollection(LowercaseSlugMixin, models.Model): + slug_source_field = 'title' title = models.JSONField(default = list , verbose_name=_('Title')) slug = models.SlugField(max_length=255, unique=True, verbose_name=_('slug'), blank=True) summary = models.JSONField(default = list , verbose_name=_('Summary')) @@ -20,64 +21,6 @@ class HadisCollection(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get('text', '').strip() - if title_text: - base_slug = slugify(title_text, allow_unicode=True) - slug = base_slug - counter = 1 - while HadisCollection.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback if text is empty - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"collection-{suffix}" - counter = 1 - while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"collection-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if structure is invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"collection-{suffix}" - counter = 1 - while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"collection-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if title is empty or invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"collection-{suffix}" - counter = 1 - while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"collection-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback on any error - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"collection-{suffix}" - counter = 1 - while HadisCollection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"collection-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) - def __str__(self): return self.title[0]['text'] if self.title else "Untitled Collection" @@ -164,72 +107,14 @@ class HadisTag(models.Model): return None -class HadisStatus(ColorPaletteMixin,models.Model): +class HadisStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model): + slug_source_field = 'title' title = models.JSONField(default = list , verbose_name=_('Title')) slug= models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique = True) # color = models.CharField(max_length=20, choices=ColorChoices.choices, verbose_name=_('color')) order = models.IntegerField(default=0, verbose_name=_('order')) description = models.JSONField(default = list , verbose_name=_('Description')) - - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get('text', '').strip() - if title_text: - slug = slugify(title_text) - # Ensure uniqueness - counter = 1 - base_slug = slug - while HadisStatus.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback if text is empty - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"status-{suffix}" - counter = 1 - while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"status-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if structure is invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"status-{suffix}" - counter = 1 - while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"status-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if title is empty or invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"status-{suffix}" - counter = 1 - while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"status-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback on any error - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"status-{suffix}" - counter = 1 - while HadisStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"status-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) def __str__(self): return self.title[0]['text'] if self.title else str(self.id) @@ -517,27 +402,8 @@ class HadisCorrection(models.Model): def save(self, *args, **kwargs): # Generate smart slug - if not self.slug and self.title: - title_text = None - if isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get("text") - - if title_text: - self.slug = generate_smart_slug( - text=title_text, model_class=HadisCorrection, - max_length=100, keep_words=8, instance=self, - ) - else: - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}" - counter = 1 - while HadisCorrection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}-{counter}" - counter += 1 - self.slug = base_slug + from utils.slug import build_slug_for_instance + build_slug_for_instance(self, source_field='title') if self.slug and self.hadis and self.hadis.slug: self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/hadith/{self.hadis.slug}/corrections/{self.slug}" diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index 93e024a..bf83ae1 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.text import slugify from django.conf import settings from typing import Optional +from utils.mixins import LowercaseSlugMixin class BookSubjectArea(models.Model): title = models.JSONField(default = list , verbose_name=_('Title')) @@ -83,7 +84,8 @@ class BookType(models.Model): return tr.get('text', '') return None -class BookReference(models.Model): +class BookReference(LowercaseSlugMixin, models.Model): + slug_source_field = 'title' """ Model for hadis book references with detailed information This is different from library books - these are reference books for hadis @@ -192,63 +194,7 @@ class BookReference(models.Model): return self._get_json_field("language" , lang) - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get('text', '').strip() - if title_text: - base_slug = slugify(title_text, allow_unicode=True) - slug = base_slug - counter = 1 - while BookReference.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback if text is empty - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"book-{suffix}" - counter = 1 - while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"book-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if structure is invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"book-{suffix}" - counter = 1 - while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"book-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if title is empty or invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"book-{suffix}" - counter = 1 - while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"book-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback on any error - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"book-{suffix}" - counter = 1 - while BookReference.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"book-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) + class BookEdition(models.Model): """ @@ -377,7 +323,8 @@ class BookReferenceDocument(models.Model): return f"{self.book_reference} - {self.title or 'Doc ' + str(self.id)}" -class BookAuthor(models.Model): +class BookAuthor(LowercaseSlugMixin, models.Model): + slug_source_field = 'name' """ Model for book reference authors """ @@ -436,32 +383,6 @@ class BookAuthor(models.Model): return None def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - try: - if self.name and isinstance(self.name, list) and len(self.name) > 0: - first_item = self.name[0] - if isinstance(first_item, dict): - name_text = first_item.get('text', '').strip() - if name_text: - base_slug = slugify(name_text, allow_unicode=True) - slug = base_slug - counter = 1 - while BookAuthor.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - import time - self.slug = f"author-{int(time.time() * 1000) % 1000000}" - else: - import time - self.slug = f"author-{int(time.time() * 1000) % 1000000}" - else: - import time - self.slug = f"author-{int(time.time() * 1000) % 1000000}" - except Exception: - import time - self.slug = f"author-{int(time.time() * 1000) % 1000000}" super().save(*args, **kwargs) self.researcher_profiles.all().update(name=self.name, slug=self.slug) @@ -528,7 +449,8 @@ class BookAttribute(models.Model): return None -class BookResearcher(models.Model): +class BookResearcher(LowercaseSlugMixin, models.Model): + slug_source_field = 'name' book_edition = models.ForeignKey( 'BookEdition', on_delete=models.CASCADE, diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 8b3c504..ce06d3d 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -7,11 +7,12 @@ from django.utils.text import slugify from typing import Optional from utils.slug import generate_smart_slug from django.conf import settings -from utils.mixins import ColorPaletteMixin +from utils.mixins import ColorPaletteMixin, LowercaseSlugMixin -class NarratorLayer(models.Model): +class NarratorLayer(LowercaseSlugMixin, models.Model): + slug_source_field = 'name' """ Model for narrator layers/classes (Tabaqat) Represents the classification level of narrators in hadis chains @@ -122,67 +123,14 @@ class NarratorLayer(models.Model): self.slug = slug super().save(*args, **kwargs) -class TransmitterReliability(ColorPaletteMixin, models.Model): +class TransmitterReliability(ColorPaletteMixin, LowercaseSlugMixin, models.Model): + slug_source_field = 'title' title = models.JSONField(default = list , verbose_name=_('Title')) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True) - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - text = first_item.get('text', '').strip() - if text: - slug = slugify(text) - # Ensure uniqueness - counter = 1 - base_slug = slug - while TransmitterReliability.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback to a timestamp-based slug - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"reliability-{suffix}" - counter = 1 - while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"reliability-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"reliability-{suffix}" - counter = 1 - while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"reliability-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"reliability-{suffix}" - counter = 1 - while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"reliability-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"reliability-{suffix}" - counter = 1 - while TransmitterReliability.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"reliability-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) + def __str__(self): return self.title[0]['text'] if self.title else str(self.id) @@ -210,7 +158,8 @@ class TransmitterReliability(ColorPaletteMixin, models.Model): -class Transmitters(models.Model): +class Transmitters(LowercaseSlugMixin, models.Model): + slug_source_field = 'full_name' # class ReliabilityLevel(models.TextChoices): # VERY_RELIABLE = 'very_reliable', _('Very Reliable') # RELIABLE = 'reliable', _('Reliable') @@ -313,63 +262,7 @@ class Transmitters(models.Model): return f"{settings.DOVODI_DOMAIN}/arguments/narrators/{self.slug}" return None - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from full_name field with robust error handling - try: - if self.full_name and isinstance(self.full_name, list) and len(self.full_name) > 0: - first_item = self.full_name[0] - if isinstance(first_item, dict): - name_text = first_item.get('text', '').strip() - if name_text: - base_slug = slugify(name_text, allow_unicode=True) - slug = base_slug - counter = 1 - while Transmitters.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback if text is empty - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"transmitter-{suffix}" - counter = 1 - while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"transmitter-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if structure is invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"transmitter-{suffix}" - counter = 1 - while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"transmitter-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - # Fallback if full_name is empty or invalid - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"transmitter-{suffix}" - counter = 1 - while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"transmitter-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - # Fallback on any error - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"transmitter-{suffix}" - counter = 1 - while Transmitters.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"transmitter-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) + def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): """ @@ -492,66 +385,13 @@ class HadisTransmitter(models.Model): return f'{self.hadis.number} - Chain {self.chain_index} - {self.transmitter} (Pos: {self.order})' -class OpinionStatus(ColorPaletteMixin, models.Model): +class OpinionStatus(ColorPaletteMixin, LowercaseSlugMixin, models.Model): + slug_source_field = 'title' title = models.JSONField(default = list , verbose_name=_('Title')) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,null=True) - def save(self, *args, **kwargs): - if not self.slug or (isinstance(self.slug, str) and self.slug.strip() == ''): - # Try to get text from title field with robust error handling - try: - if self.title and isinstance(self.title, list) and len(self.title) > 0: - first_item = self.title[0] - if isinstance(first_item, dict): - text = first_item.get('text', '').strip() - if text: - slug = slugify(text) - # Ensure uniqueness - counter = 1 - base_slug = slug - while OpinionStatus.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - else: - # Fallback to a timestamp-based slug - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"opinion-{suffix}" - counter = 1 - while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"opinion-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"opinion-{suffix}" - counter = 1 - while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"opinion-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - else: - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"opinion-{suffix}" - counter = 1 - while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"opinion-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - except (IndexError, KeyError, AttributeError, TypeError): - import time - suffix = int(time.time() * 1000) % 1000000 - base_slug = f"opinion-{suffix}" - counter = 1 - while OpinionStatus.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"opinion-{suffix}-{counter}" - counter += 1 - self.slug = base_slug - super().save(*args, **kwargs) + def __str__(self): return self.title[0]['text'] if self.title else str(self.id) @@ -695,37 +535,8 @@ class TransmitterOriginalText(models.Model): """ Override save to automatically generate smart slugs. """ - - # Generate slug if not already set - if not self.slug and self.title: - # Extract title text - title_text = None - if isinstance(self.title, list) and self.title: - first_item = self.title[0] - if isinstance(first_item, dict): - title_text = first_item.get("text") - - # Generate smart slug - if title_text: - self.slug = generate_smart_slug( - text=title_text, - model_class=TransmitterOriginalText, - max_length=100, # ← Adjust max length here - keep_words=8, # ← Limit to 8 words (your requirement) - instance=self, - ) - else: - # Fallback if title is empty - use timestamp for uniqueness - import time - suffix = int(time.time() * 1000) % 1000000 - transmitter_slug = self.transmitter.slug if self.transmitter and self.transmitter.slug else 'unknown' - base_slug = f"original-text-{transmitter_slug}-{suffix}" - # Ensure uniqueness - counter = 1 - while TransmitterOriginalText.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): - base_slug = f"original-text-{transmitter_slug}-{suffix}-{counter}" - counter += 1 - self.slug = base_slug + from utils.slug import build_slug_for_instance + build_slug_for_instance(self, source_field='title') # Generate/update share_link before saving if self.slug and self.transmitter and self.transmitter.slug: diff --git a/apps/library/models.py b/apps/library/models.py index 36b7085..f50b8cf 100644 --- a/apps/library/models.py +++ b/apps/library/models.py @@ -11,7 +11,7 @@ from apps.account.models import User from utils.mixins import LowercaseSlugMixin -class BookCollection(models.Model): +class BookCollection(LowercaseSlugMixin, models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') @@ -38,11 +38,6 @@ class BookCollection(models.Model): verbose_name = _('Book Collection') verbose_name_plural = _('Book Collections') - def save(self, *args, **kwargs): - if not self.slug or not self.slug.strip(): - self.slug = generate_smart_slug(self.title, BookCollection, instance=self) - super().save(*args, **kwargs) - class PinnedBookCollection(BookCollection): @@ -67,7 +62,7 @@ class MiddleBookCollection(BookCollection): -class Category(models.Model): +class Category(LowercaseSlugMixin, models.Model): title = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) status = models.BooleanField(default=True, verbose_name=_('status')) @@ -79,11 +74,6 @@ class Category(models.Model): def __str__(self): return self.title - def save(self, *args, **kwargs): - if not self.slug or not self.slug.strip(): - self.slug = generate_smart_slug(self.title, Category, instance=self) - super().save(*args, **kwargs) - @property def books_count(self): """Return the number of books in this category""" @@ -94,7 +84,8 @@ class Category(models.Model): verbose_name_plural = _('Categories') -class Author(models.Model): +class Author(LowercaseSlugMixin, models.Model): + slug_source_field = 'full_name' full_name = models.CharField(max_length=255, verbose_name=_('Full Name')) slug = models.SlugField(max_length=255, unique=True, blank=True, null=True) thumbnail = models.ImageField( @@ -117,11 +108,6 @@ class Author(models.Model): def __str__(self): return self.full_name - def save(self, *args, **kwargs): - if not self.slug or not self.slug.strip(): - self.slug = generate_smart_slug(self.full_name, Author, instance=self) - super().save(*args, **kwargs) - @property def books_count(self): return self.books.filter(status=True).count() diff --git a/apps/podcast/models.py b/apps/podcast/models.py index 1d8cff9..a13fac0 100644 --- a/apps/podcast/models.py +++ b/apps/podcast/models.py @@ -6,7 +6,7 @@ from utils import generate_slug_for_model from utils.mixins import LowercaseSlugMixin -class PodcastCategory(models.Model): +class PodcastCategory(LowercaseSlugMixin, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) @@ -18,18 +18,13 @@ class PodcastCategory(models.Model): def __str__(self): return self.title - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(PodcastCategory, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Podcast Category') verbose_name_plural = _('Podcast Categories') ordering = ['order'] -class PodcastCollection(models.Model): +class PodcastCollection(LowercaseSlugMixin, models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') @@ -54,11 +49,6 @@ class PodcastCollection(models.Model): def __str__(self): return f'Collection #{self.id}/{self.title}' - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(PodcastCollection, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Podcast Collection') verbose_name_plural = _('Podcasts Collections') diff --git a/apps/video/models.py b/apps/video/models.py index c276a2d..683a7f4 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -8,7 +8,7 @@ from django.core.validators import FileExtensionValidator from django.core.exceptions import ValidationError -class VideoCategory(models.Model): +class VideoCategory(LowercaseSlugMixin, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) @@ -20,18 +20,13 @@ class VideoCategory(models.Model): def __str__(self): return self.title - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(VideoCategory, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Video Category') verbose_name_plural = _('Video Categories') ordering = ['order'] -class VideoCollection(models.Model): +class VideoCollection(LowercaseSlugMixin, models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') @@ -56,12 +51,6 @@ class VideoCollection(models.Model): def __str__(self): return f'Collection #{self.id}/{self.title}' - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(VideoCollection, self.title) - super().save(*args, **kwargs) - - class Meta: verbose_name = _('Video Collection') verbose_name_plural = _('Video Collections') @@ -140,7 +129,7 @@ class Video(LowercaseSlugMixin, models.Model): -class VideoPlaylist(models.Model): +class VideoPlaylist(LowercaseSlugMixin, models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) slug = models.SlugField(allow_unicode=True, unique=True, null=True, blank=True, verbose_name=_('slug')) slogan = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('slogan')) @@ -189,11 +178,6 @@ class VideoPlaylist(models.Model): return timedelta(seconds=total_seconds) - def save(self, *args, **kwargs): - if not self.slug: - self.slug = generate_slug_for_model(VideoPlaylist, self.title) - super().save(*args, **kwargs) - class Meta: verbose_name = _('Video Playlist') verbose_name_plural = _('Video Playlists') diff --git a/utils/admin.py b/utils/admin.py index 9b7890e..ea8ed10 100644 --- a/utils/admin.py +++ b/utils/admin.py @@ -32,41 +32,6 @@ def slugify(value): return _unicode_slugify(value) # ------------------------------------------------------- -# Unfold Imports -import json -import random -from functools import lru_cache - -from django import forms -from django.conf import settings -from django.contrib.humanize.templatetags.humanize import intcomma -from django.urls import reverse, path -from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ -from django.views.generic import RedirectView -from django.utils.translation import get_language -from django.http import JsonResponse -from django.utils.html import format_html -from django.views.decorators.http import require_POST - -# --- AGGRESSIVE UNICODE PATCH FOR UNFOLD 0.64.1 TABS --- -import django.utils.text -from django.template.defaultfilters import register - -_original_slugify = django.utils.text.slugify - -def _unicode_slugify(value, allow_unicode=True): - # We forcefully pass allow_unicode=True to preserve Russian characters for tab IDs - return _original_slugify(value, allow_unicode=True) - -django.utils.text.slugify = _unicode_slugify - -# We must also override the template filter explicitly because Unfold calls it directly in 0.64.1 -@register.filter(is_safe=True) -def slugify(value): - return _unicode_slugify(value) -# ------------------------------------------------------- - # Unfold Imports from unfold.sites import UnfoldAdminSite diff --git a/utils/mixins.py b/utils/mixins.py index 565163c..39fa30b 100644 --- a/utils/mixins.py +++ b/utils/mixins.py @@ -58,36 +58,8 @@ class LowercaseSlugMixin(models.Model): abstract = True def save(self, *args, **kwargs): - # 1. Generate slug if empty - if not self.slug: - source_value = getattr(self, self.slug_source_field, '') or '' - base_slug = slugify(source_value, allow_unicode=True) or 'slug' - base_slug = base_slug.lower() - - # Ensure uniqueness - slug = base_slug - counter = 1 - model_class = self.__class__ - - # Exclude current instance from conflict check if it exists in database - queryset = model_class.objects.all() - if self.pk: - queryset = queryset.exclude(pk=self.pk) - - while queryset.filter(slug=slug).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - - self.slug = slug - else: - # 2. Force existing slug to lowercase (handling both string and JSONField lists) - if isinstance(self.slug, str): - self.slug = self.slug.lower() - elif isinstance(self.slug, list): - for item in self.slug: - if isinstance(item, dict) and "title" in item and item["title"]: - item["title"] = str(item["title"]).lower() - + from utils.slug import build_slug_for_instance + build_slug_for_instance(self, source_field=self.slug_source_field) super().save(*args, **kwargs) diff --git a/utils/slug.py b/utils/slug.py index 78b299d..cb47e1e 100644 --- a/utils/slug.py +++ b/utils/slug.py @@ -137,3 +137,64 @@ def generate_unique_slug( instance=instance, keep_words=999, # No word limit ) + + +def build_slug_for_instance(instance, source_field='title', slug_field='slug', max_length=100): + """ + Helper to automatically build, update, and ensure uniqueness/lowercase for a model's slug. + If the record is new: generates slug from source_field if slug is empty. + If the record is being updated: if source_field has changed, regenerates slug. + """ + model_class = instance.__class__ + source_val = getattr(instance, source_field, None) + + # Helper to extract raw text if source value is a list (e.g. JSONField translation list) + def extract_text(val): + if not val: + return "" + if isinstance(val, str): + return val.strip() + if isinstance(val, list) and len(val) > 0: + first_item = val[0] + if isinstance(first_item, dict): + return (first_item.get('text') or first_item.get('value') or first_item.get('title') or '').strip() + return str(first_item).strip() + return str(val).strip() + + text = extract_text(source_val) + current_slug = getattr(instance, slug_field, None) + + is_new = instance.pk is None + should_generate = False + + if is_new: + if not current_slug or not str(current_slug).strip(): + should_generate = True + else: + # Fetch the original source and slug values from DB + orig = model_class.objects.filter(pk=instance.pk).values(source_field, slug_field).first() + if orig: + orig_source_val = orig.get(source_field) + orig_slug = orig.get(slug_field) + + orig_text = extract_text(orig_source_val) + # 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 + + if should_generate: + # Generate new slug using generate_smart_slug to handle uniqueness, unicode, and limits safely + new_slug = generate_smart_slug( + text=text, + model_class=model_class, + max_length=max_length, + field_name=slug_field, + instance=instance, + keep_words=999, # Keep all words up to max_length + ) + setattr(instance, slug_field, new_slug.lower()) + else: + # Enforce lowercase on existing slug + if current_slug and isinstance(current_slug, str): + setattr(instance, slug_field, current_slug.lower()) +