You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

139 lines
4.7 KiB

"""
Smart slug generation utility for Django models.
Handles truncation, counter-based uniqueness, and word-limit preservation.
"""
from django.utils.text import slugify
def generate_smart_slug(
text: str,
model_class,
max_length: int = 100,
field_name: str = "slug",
instance=None,
keep_words: int = 8,
reserve_for_counter: int = 5, # Reserve space for "-1", "-2", etc. (max 5 chars)
) -> str:
"""
Generate a unique, meaningful slug with a max length and word limit.
This function:
1. Extracts the first N words from the text
2. Slugifies them
3. Truncates to max_length (reserving space for counter if needed)
4. Adds a counter (-1, -2, etc.) if the slug already exists
Args:
text (str): The text to slugify (e.g., hadis title)
model_class: The Django model class (e.g., Hadis)
max_length (int): Maximum slug length (default: 100)
field_name (str): The slug field name (default: 'slug')
instance: Current instance to exclude from uniqueness check (optional)
keep_words (int): Number of words to keep (default: 8)
reserve_for_counter (int): Space reserved for counter suffix (default: 5, enough for "-9999")
Returns:
str: A unique slug within max_length and word constraints
Raises:
ValueError: If unable to generate unique slug after 1000 attempts
Examples:
>>> from utils.slugs import generate_smart_slug
>>> from hadis.models import Hadis
>>>
>>> text = "Fatwa on Combining Prayers While Traveling and Missing the Congregational Prayer"
>>> slug = generate_smart_slug(text, Hadis, keep_words=4)
>>> print(slug)
'fatwa-on-combining-prayers'
>>>
>>> # With counter for duplicate
>>> slug2 = generate_smart_slug(text, Hadis, keep_words=4, instance=hadis)
>>> print(slug2)
'fatwa-on-combining-prayers-1'
"""
# Validation: Check if text is valid
if not text or not isinstance(text, str):
fallback = (
f"{model_class.__name__.lower()}-{instance.id}"
if instance and instance.pk
else f"{model_class.__name__.lower()}-new"
)
return fallback
# ==================== STEP 1: Extract first N words ====================
words = text.strip().split()[:keep_words]
text_shortened = " ".join(words)
# ==================== STEP 2: Slugify ====================
base_slug = slugify(text_shortened, allow_unicode=True)
# ==================== STEP 3: Truncate to max_length ====================
# Reserve space for potential counter suffix
available_length = max_length - reserve_for_counter
slug = base_slug[:available_length].rstrip("-")
# ==================== STEP 4: Ensure uniqueness with counter ====================
counter = 0 # Start at 0 for first attempt (no counter)
original_slug = slug
while True:
# Build the final slug
if counter == 0:
final_slug = slug # First attempt: no counter
else:
counter_suffix = f"-{counter}"
# Ensure total doesn't exceed max_length
available_for_base = max_length - len(counter_suffix)
final_slug = original_slug[:available_for_base].rstrip("-") + counter_suffix
# Build filter query
filter_kwargs = {field_name: final_slug}
qs = model_class.objects.filter(**filter_kwargs)
# Exclude current instance if provided
if instance and instance.pk:
qs = qs.exclude(pk=instance.pk)
# If no conflict, slug is unique
if not qs.exists():
return final_slug
# Try with counter
counter += 1
# Safety: prevent infinite loop
if counter > 1000:
raise ValueError(
f"Could not generate unique slug for text: '{text}'. "
f"Attempted 1000+ variations of '{original_slug}'"
)
return slug
# ============================================================================
# Backward compatibility aliases
# ============================================================================
def generate_unique_slug(
text: str,
model_class,
max_length: int = 100,
field_name: str = "slug",
instance=None,
) -> str:
"""
Backward compatible version without word limit.
Uses default keep_words=999 (essentially unlimited).
"""
return generate_smart_slug(
text=text,
model_class=model_class,
max_length=max_length,
field_name=field_name,
instance=instance,
keep_words=999, # No word limit
)