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 filer.fields.image import FilerImageField
from .reference import BookReference
from utils.slug import generate_smart_slug
class HadisCollection(models.Model):
title = models.JSONField(default = list , verbose_name=_('Title'))
@ -167,6 +168,7 @@ class HadisStatus(models.Model):
class Hadis(models.Model):
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)
slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True)
title_narrator = models.JSONField(default = list , verbose_name=_('Title Narrator'))
title = models.JSONField(default = list , verbose_name=_('Title'))
description = models.JSONField(default = list , verbose_name=_('Description'))
@ -190,6 +192,54 @@ class Hadis(models.Model):
def __str__(self):
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"):
"""
Generic getter for JSONField in our [{text, language_code}] format.
@ -236,21 +286,6 @@ class Hadis(models.Model):
def get_explanation(self, 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):
# ساخت share_link قبل از ذخیره
if not self.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