5 changed files with 485 additions and 15 deletions
-
258apps/hadis/management/commands/slug_hadis.py
-
19apps/hadis/migrations/0058_hadis_slug.py
-
19apps/hadis/migrations/0059_alter_hadis_slug.py
-
65apps/hadis/models/hadis.py
-
139utils/slug.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 |
||||
@ -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" |
||||
|
), |
||||
|
), |
||||
|
] |
||||
@ -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" |
||||
|
), |
||||
|
), |
||||
|
] |
||||
@ -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 |
||||
|
) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue