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.
 
 
 
 

200 lines
7.1 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
)
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())