From e9cefd3d663ad2cccb7ce8c3f076958d23d68aee Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Mon, 22 Dec 2025 09:41:27 +0330 Subject: [PATCH] smart short slug field updated to Hadis --- apps/hadis/management/commands/slug_hadis.py | 258 ++++++++++++++++++ apps/hadis/migrations/0058_hadis_slug.py | 19 ++ .../hadis/migrations/0059_alter_hadis_slug.py | 19 ++ apps/hadis/models/hadis.py | 65 ++++- utils/slug.py | 139 ++++++++++ 5 files changed, 485 insertions(+), 15 deletions(-) create mode 100644 apps/hadis/management/commands/slug_hadis.py create mode 100644 apps/hadis/migrations/0058_hadis_slug.py create mode 100644 apps/hadis/migrations/0059_alter_hadis_slug.py create mode 100644 utils/slug.py diff --git a/apps/hadis/management/commands/slug_hadis.py b/apps/hadis/management/commands/slug_hadis.py new file mode 100644 index 0000000..898a9b0 --- /dev/null +++ b/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 diff --git a/apps/hadis/migrations/0058_hadis_slug.py b/apps/hadis/migrations/0058_hadis_slug.py new file mode 100644 index 0000000..f0d11b4 --- /dev/null +++ b/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" + ), + ), + ] diff --git a/apps/hadis/migrations/0059_alter_hadis_slug.py b/apps/hadis/migrations/0059_alter_hadis_slug.py new file mode 100644 index 0000000..cb291df --- /dev/null +++ b/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" + ), + ), + ] diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index 11fc50b..e5582c8 100644 --- a/apps/hadis/models/hadis.py +++ b/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. @@ -235,21 +285,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 قبل از ذخیره diff --git a/utils/slug.py b/utils/slug.py new file mode 100644 index 0000000..78b299d --- /dev/null +++ b/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 + )