Browse Source

smart short slug field updated to Hadis

master
Mohsen Taba 5 months ago
parent
commit
e9cefd3d66
  1. 258
      apps/hadis/management/commands/slug_hadis.py
  2. 19
      apps/hadis/migrations/0058_hadis_slug.py
  3. 19
      apps/hadis/migrations/0059_alter_hadis_slug.py
  4. 65
      apps/hadis/models/hadis.py
  5. 139
      utils/slug.py

258
apps/hadis/management/commands/slug_hadis.py

@ -0,0 +1,258 @@
"""
Django Management Command: Regenerate Hadis Slugs
This command:
1. Takes all existing Hadis objects
2. Extracts their title (first 8 words max)
3. Generates smart, short, meaningful slugs
4. Handles uniqueness with counters (-1, -2, -3, etc.)
5. REPLACES all old slug values with new ones
6. Detects and numbers duplicates automatically
Key feature:
- If multiple hadis have the same shortened title, they get numbered:
- hadis-1830: "достоинство-молитвы-и-ее-место" "достоинство-молитвы-и-ее-место"
- hadis-1831: same title "достоинство-молитвы-и-ее-место-1"
- hadis-1832: same title "достоинство-молитвы-и-ее-место-2"
Usage:
python manage.py regenerate_hadis_slugs
python manage.py regenerate_hadis_slugs --max-length 75
python manage.py regenerate_hadis_slugs --keep-words 6
python manage.py regenerate_hadis_slugs --dry-run
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from collections import defaultdict
from apps.hadis.models import Hadis
from utils.slug import generate_smart_slug
class Command(BaseCommand):
help = (
"Regenerate smart slugs for all Hadis objects. "
"Replaces existing slug values with optimized, short, meaningful ones. "
"Automatically adds counters for duplicates."
)
def add_arguments(self, parser):
"""Add optional command-line arguments"""
parser.add_argument(
"--max-length",
type=int,
default=100,
help="Maximum slug length (default: 100)",
)
parser.add_argument(
"--keep-words",
type=int,
default=8,
help="Maximum number of words to keep in slug (default: 8)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would change without saving",
)
@transaction.atomic
def handle(self, *args, **options):
max_length = options["max_length"]
keep_words = options["keep_words"]
dry_run = options["dry_run"]
self.stdout.write(self.style.HTTP_INFO("=" * 80))
self.stdout.write("🔄 HADIS SLUG REGENERATION WITH DUPLICATE HANDLING\n")
self.stdout.write(f"Configuration:")
self.stdout.write(f" • Max length: {max_length} chars")
self.stdout.write(f" • Keep words: {keep_words} words")
self.stdout.write(f" • Dry run: {'Yes (no changes)' if dry_run else 'No (will save)'}")
self.stdout.write(self.style.HTTP_INFO("=" * 80) + "\n")
# Get all hadis
qs = Hadis.objects.all().order_by("id")
total = qs.count()
self.stdout.write(f"Step 1: Analyzing {total} hadis objects...\n")
# Dictionary to track duplicate slugs: {base_slug: [hadis_ids]}
slug_map = defaultdict(list)
hadis_slug_map = {} # {hadis_id: generated_slug}
# First pass: Generate slugs and identify duplicates
for hadis in qs:
try:
# Extract title text
title_text = None
if hadis.title and isinstance(hadis.title, list) and hadis.title:
first_item = hadis.title[0]
if isinstance(first_item, dict):
title_text = first_item.get("text")
# Fallback if no title
if not title_text:
title_text = f"hadis-{hadis.number or hadis.id}"
# Generate base slug (without counter)
base_slug = self._generate_base_slug(
title_text,
max_length - 5, # Reserve space for counter
keep_words,
)
hadis_slug_map[hadis.id] = {
"base_slug": base_slug,
"title_text": title_text,
"old_slug": hadis.slug or "(empty)",
}
slug_map[base_slug].append(hadis.id)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error analyzing Hadis {hadis.id}: {str(e)}")
)
self.stdout.write(f"✅ Analysis complete!\n")
self.stdout.write(f"Step 2: Detecting duplicates...\n")
# Identify groups with duplicates
duplicate_groups = {
slug: ids for slug, ids in slug_map.items() if len(ids) > 1
}
if duplicate_groups:
self.stdout.write(
self.style.WARNING(
f"⚠️ Found {len(duplicate_groups)} groups with duplicate slugs:\n"
)
)
for base_slug, ids in sorted(duplicate_groups.items()):
self.stdout.write(f"'{base_slug}' → {len(ids)} hadis")
for idx, hadis_id in enumerate(ids):
counter = "" if idx == 0 else f"-{idx}"
self.stdout.write(
f" - Hadis {hadis_id}: {base_slug}{counter}"
)
else:
self.stdout.write("✅ No duplicates found! All slugs are unique.\n")
self.stdout.write("\nStep 3: Applying slugs with counters...\n")
# Second pass: Apply slugs with counters for duplicates
updated = 0
unchanged = 0
errors = []
for hadis in qs:
try:
slug_info = hadis_slug_map.get(hadis.id)
if not slug_info:
continue
base_slug = slug_info["base_slug"]
old_slug = slug_info["old_slug"]
# If this slug has duplicates, add counter
if len(slug_map[base_slug]) > 1:
# Find position of this hadis in the duplicate group
duplicate_ids = slug_map[base_slug]
position = duplicate_ids.index(hadis.id)
# First one gets no counter, rest get -1, -2, etc.
if position == 0:
new_slug = base_slug
else:
counter_suffix = f"-{position}"
available_length = max_length - len(counter_suffix)
new_slug = (
base_slug[:available_length].rstrip("-")
+ counter_suffix
)
else:
new_slug = base_slug
changed = hadis.slug != new_slug
if changed:
self.stdout.write(
f" [{hadis.id:5d}] {old_slug:45s} → {new_slug}"
)
if not dry_run:
hadis.slug = new_slug
hadis.save(update_fields=["slug"])
updated += 1
else:
unchanged += 1
except Exception as e:
error_msg = f"Hadis {hadis.id}: {str(e)}"
self.stdout.write(self.style.ERROR(f" ❌ {error_msg}"))
errors.append(error_msg)
# Summary
self.stdout.write("\n" + self.style.HTTP_INFO("=" * 80))
self.stdout.write("📊 RESULTS\n")
self.stdout.write(f" ✅ Updated: {updated}")
self.stdout.write(f" ➡️ Unchanged: {unchanged}")
self.stdout.write(f" ❌ Errors: {len(errors)}")
self.stdout.write(
f" 🔢 Duplicate groups handled: {len(duplicate_groups)}"
)
if dry_run:
self.stdout.write(
self.style.WARNING(
"\n ⚠️ DRY RUN MODE: No changes were saved."
)
)
self.stdout.write(
self.style.WARNING(
" Run without --dry-run to apply changes."
)
)
if errors:
self.stdout.write(self.style.ERROR("\n❌ ERRORS ENCOUNTERED:"))
for error in errors:
self.stdout.write(f" • {error}")
self.stdout.write(self.style.HTTP_INFO("=" * 80) + "\n")
if not dry_run and updated > 0:
self.stdout.write(
self.style.SUCCESS(
f"✅ Successfully regenerated {updated} slug(s)!"
)
)
elif dry_run:
self.stdout.write(
self.style.HTTP_INFO(
f"ℹ️ Would update {updated} slug(s) in production run."
)
)
def _generate_base_slug(self, text: str, max_length: int, keep_words: int) -> str:
"""
Generate a base slug without counter.
Returns the base slug that might be used for multiple hadis.
"""
from django.utils.text import slugify
if not text or not isinstance(text, str):
return "hadis-unknown"
# Extract first N words
words = text.strip().split()[:keep_words]
text_shortened = " ".join(words)
# Slugify
base_slug = slugify(text_shortened, allow_unicode=True)
# Truncate
slug = base_slug[:max_length].rstrip("-")
return slug

19
apps/hadis/migrations/0058_hadis_slug.py

@ -0,0 +1,19 @@
# Generated by Django 4.2.27 on 2025-12-22 08:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("hadis", "0057_hadiscorrection_description"),
]
operations = [
migrations.AddField(
model_name="hadis",
name="slug",
field=models.SlugField(
blank=True, max_length=255, null=True, verbose_name="slug"
),
),
]

19
apps/hadis/migrations/0059_alter_hadis_slug.py

@ -0,0 +1,19 @@
# Generated by Django 4.2.27 on 2025-12-22 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("hadis", "0058_hadis_slug"),
]
operations = [
migrations.AlterField(
model_name="hadis",
name="slug",
field=models.SlugField(
blank=True, max_length=255, unique=True, verbose_name="slug"
),
),
]

65
apps/hadis/models/hadis.py

@ -6,6 +6,7 @@ from django.conf import settings
from django.utils.text import slugify from django.utils.text import slugify
from filer.fields.image import FilerImageField from filer.fields.image import FilerImageField
from .reference import BookReference from .reference import BookReference
from utils.slug import generate_smart_slug
class HadisCollection(models.Model): class HadisCollection(models.Model):
title = models.JSONField(default = list , verbose_name=_('Title')) title = models.JSONField(default = list , verbose_name=_('Title'))
@ -167,6 +168,7 @@ class HadisStatus(models.Model):
class Hadis(models.Model): class Hadis(models.Model):
category = models.ForeignKey("hadis.HadisCategory", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('category')) category = models.ForeignKey("hadis.HadisCategory", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('category'))
number = models.PositiveIntegerField(verbose_name=_('number'), default=1) number = models.PositiveIntegerField(verbose_name=_('number'), default=1)
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True)
title_narrator = models.JSONField(default = list , verbose_name=_('Title Narrator')) title_narrator = models.JSONField(default = list , verbose_name=_('Title Narrator'))
title = models.JSONField(default = list , verbose_name=_('Title')) title = models.JSONField(default = list , verbose_name=_('Title'))
description = models.JSONField(default = list , verbose_name=_('Description')) description = models.JSONField(default = list , verbose_name=_('Description'))
@ -190,6 +192,54 @@ class Hadis(models.Model):
def __str__(self): def __str__(self):
return f"{self.number} - {self.title[0]['text']}" if self.title else f"Hadis {self.number}" return f"{self.number} - {self.title[0]['text']}" if self.title else f"Hadis {self.number}"
def save(self, *args, **kwargs):
"""
Override save to automatically generate smart slugs.
- If slug is empty, generates a new one
- Uses the first 8 words from the title (configurable)
- Ensures uniqueness with counters (-1, -2, etc.)
- Max length: 100 characters (configurable)
Examples:
>>> hadis = Hadis.objects.create(
... number=1877,
... title=[
... {
... "text": "Fatwa on Combining Prayers While Traveling and Missing Prayer",
... "language_code": "en"
... }
... ]
... )
>>> print(hadis.slug)
'fatwa-on-combining-prayers-while'
"""
# 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=Hadis,
max_length=100, # ← Adjust max length here
keep_words=8, # ← Limit to 8 words (your requirement)
instance=self,
)
else:
# Fallback if title is empty
self.slug = f"hadis-{self.number or 'unknown'}"
# Call parent save
super().save(*args, **kwargs)
def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"): def _get_json_field(self, field_name: str, lang: Optional[str]=None , fallback: str = "en"):
""" """
Generic getter for JSONField in our [{text, language_code}] format. Generic getter for JSONField in our [{text, language_code}] format.
@ -235,21 +285,6 @@ class Hadis(models.Model):
def get_explanation(self, lang): def get_explanation(self, lang):
return self._get_json_field("explanation" , lang) return self._get_json_field("explanation" , lang)
# """
# Get translation for a specific language
# """
# if not self.translation or not isinstance(self.translation, list):
# return None
# for tr in self.translation:
# if isinstance(tr, dict) and tr.get('language_code') == lang:
# return tr.get('title', '')
# for tr in self.translation:
# if isinstance(tr, dict) and tr.get('language_code') == 'en':
# return tr.get('title', '')
# return None
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# ساخت share_link قبل از ذخیره # ساخت share_link قبل از ذخیره

139
utils/slug.py

@ -0,0 +1,139 @@
"""
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
)
Loading…
Cancel
Save