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