From 1a56bc8a4032bfb65749d3b726dd40e6a38f8510 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 9 May 2026 14:25:54 +0330 Subject: [PATCH] dovodi data import update models to be sync with dovodi data built an script to run and store dovodi data in three phases --- .../management/commands/import_legacy_data.py | 369 ++++++++++++++++++ ...alter_hadistransmitter_options_and_more.py | 181 +++++++++ ...ve_hadiscorrection_description_and_more.py | 27 ++ apps/hadis/models/hadis.py | 117 +++--- apps/hadis/models/reference.py | 28 ++ apps/hadis/models/transmitter.py | 42 +- apps/hadis/serializers/hadis.py | 6 +- 7 files changed, 703 insertions(+), 67 deletions(-) create mode 100644 apps/hadis/management/commands/import_legacy_data.py create mode 100644 apps/hadis/migrations/0009_alter_hadistransmitter_options_and_more.py create mode 100644 apps/hadis/migrations/0010_remove_hadiscorrection_description_and_more.py diff --git a/apps/hadis/management/commands/import_legacy_data.py b/apps/hadis/management/commands/import_legacy_data.py new file mode 100644 index 0000000..850d2be --- /dev/null +++ b/apps/hadis/management/commands/import_legacy_data.py @@ -0,0 +1,369 @@ +import os +import json +import csv +from django.core.management.base import BaseCommand +from django.core.files import File +from django.db import transaction +from django.conf import settings + +# Import all necessary models +from apps.hadis.models import ( + HadisCategory, HadisSect, HadisStatus, HadisTag, Hadis, + HadisCorrection, HadisReference, ReferenceImage, HadisTransmitter, + BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookSubjectArea, + Transmitters, NarratorLayer, TransmitterReliability, OpinionStatus, + TransmitterOpinion, TransmitterOriginalText, OriginalTextReference, OriginalTextReferenceImage, + HadisInterpretation, InterpretationReference, InterpretationReferenceImage, CorrectionReference, CorrectionReferenceImage +) + +class Command(BaseCommand): + help = 'Import legacy Hadith data from JSON, CSV, and Media folders' + + def add_arguments(self, parser): + parser.add_argument('base_dir', type=str, help='Absolute path to the "тестовая база данных" directory') + + def wrap_lang(self, text, lang="ru"): + """Helper to format strings into the [ {'language_code': lang, 'text': text} ] schema. + Always returns a valid dictionary to bypass Django's blank=False validators.""" + if text is None: + text = "" + return [{"language_code": lang, "text": str(text).strip()}] + + @transaction.atomic + def handle(self, *args, **kwargs): + base_dir = kwargs['base_dir'] + + if not os.path.exists(base_dir): + self.stderr.write(self.style.ERROR(f'Directory not found: {base_dir}')) + return + + self.stdout.write(self.style.SUCCESS(f'Starting import from: {base_dir}')) + + # Paths + aut_ui_path = os.path.join(base_dir, 'AUT_UI.csv') + bib_path = os.path.join(base_dir, 'bib.csv') + narrators_path = os.path.join(base_dir, 'narrators.json') + tathir_path = os.path.join(base_dir, 'tathir.json') + + # --- PHASE 1: SCHOLARS & BOOKS --- + self.stdout.write(self.style.WARNING('\n--- PHASE 1: Loading Scholars & Books ---')) + + scholars_map = {} + if os.path.exists(aut_ui_path): + with open(aut_ui_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + for row in reader: + if len(row) >= 3: + scholars_map[row[0].strip()] = { + "ar": row[1].strip(), + "ru": row[2].strip() + } + self.stdout.write(f'Loaded {len(scholars_map)} scholars into memory.') + + if os.path.exists(bib_path): + with open(bib_path, 'r', encoding='utf-8') as f: + reader = csv.reader(f) + for row in reader: + if len(row) < 5: continue + + base_legacy_id = row[0].strip() + author_name = row[2].strip() + base_title = row[3].strip() + + # Extract total volumes (Column 11 / Index 10) + vol_str = row[10].strip() if len(row) > 10 else '' + try: + total_vols = int(vol_str) if vol_str.isdigit() else 1 + except ValueError: + total_vols = 1 + + # Create a BookReference for EVERY volume + for v in range(1, total_vols + 1): + # Generate unique ID and Title for multi-volume books + is_multi_vol = total_vols > 1 + legacy_id = f"{base_legacy_id}-v{v}" if is_multi_vol else base_legacy_id + title_text = f"{base_title} (Vol {v})" if is_multi_vol else base_title + + book, _ = BookReference.objects.update_or_create( + legacy_id=legacy_id, + defaults={ + 'title': self.wrap_lang(title_text), + 'number_of_volumes': total_vols, + 'volume': str(v), + 'year_of_publication': row[9].strip() if len(row) > 9 else '', + 'source_url': row[11].strip() if len(row) > 11 else '', + 'description': self.wrap_lang(row[12].strip() if len(row) > 12 else ''), + 'publisher': self.wrap_lang(row[5].strip() if len(row) > 5 else ''), + 'language': self.wrap_lang('') + } + ) + + # Author + if author_name: + author, _ = BookAuthor.objects.get_or_create(name=self.wrap_lang(author_name)) + book.authors.add(author) + + # Scan Book Folder for Specific Volume Images and PDFs + book_folder = os.path.join(base_dir, 'books', base_legacy_id) + if os.path.exists(book_folder): + vol_num_str = str(v) + vol_padded_str = str(v).zfill(2) # "1" -> "01" + + for root, _, files in os.walk(book_folder): + folder_name = os.path.basename(root) + + for file in files: + file_path = os.path.join(root, file) + file_lower = file.lower() + + # Attach PDF if it matches "1.pdf" or "01.pdf" + if file_lower.endswith('.pdf'): + if file_lower in [f"{vol_num_str}.pdf", f"{vol_padded_str}.pdf"] or not is_multi_vol: + with open(file_path, 'rb') as doc_f: + doc = BookReferenceDocument(book_reference=book, volume=vol_num_str, title=file) + doc.file.save(file, File(doc_f), save=True) + + # Attach Images if they are in folder "1" or "01" + elif file_lower.endswith(('.png', '.jpg', '.jpeg', '.gif')): + if folder_name in [vol_num_str, vol_padded_str] or not is_multi_vol: + with open(file_path, 'rb') as img_f: + img = BookReferenceImage(book_reference=book, volume=vol_num_str) + img.image.save(file, File(img_f), save=True) + + self.stdout.write(self.style.SUCCESS('Books (split by volumes) loaded successfully.')) + + + # --- PHASE 2: NARRATORS --- + self.stdout.write(self.style.WARNING('\n--- PHASE 2: Loading Narrators ---')) + if os.path.exists(narrators_path): + with open(narrators_path, 'r', encoding='utf-8') as f: + n_data_list = json.load(f).get('narrators', []) + + for n_data in n_data_list: + legacy_id = n_data.get('id') + legacy_number = int(n_data.get('narrator_number')) if str(n_data.get('narrator_number')).isdigit() else None + + info = n_data.get('info', {}) + ar_info = info.get('arabic', {}) + + reliability, _ = TransmitterReliability.objects.get_or_create( + title=self.wrap_lang(n_data.get('reliability', 'Unknown')) + ) + + generation = int(n_data.get('generation')) if str(n_data.get('generation')).isdigit() else None + if generation: + NarratorLayer.objects.get_or_create( + number=generation, + defaults={ + 'name': self.wrap_lang(f'Layer {generation}'), + 'description': self.wrap_lang('') + } + ) + + # Create Transmitter + transmitter, _ = Transmitters.objects.update_or_create( + legacy_id=legacy_id, + defaults={ + 'legacy_number': legacy_number, + 'full_name': self.wrap_lang(info.get('name', ''), 'ru') + self.wrap_lang(ar_info.get('name', ''), 'ar'), + 'known_as': self.wrap_lang(info.get('known_name', ''), 'ru') + self.wrap_lang(ar_info.get('known_name', ''), 'ar'), + 'kunya': self.wrap_lang(info.get('kunya', ''), 'ru') + self.wrap_lang(ar_info.get('kunya', ''), 'ar'), + 'nickname': self.wrap_lang(info.get('nickname', ''), 'ru') + self.wrap_lang(ar_info.get('nickname', ''), 'ar'), + 'origin': self.wrap_lang(info.get('origin', ''), 'ru') + self.wrap_lang(ar_info.get('origin', ''), 'ar'), + 'lived_in': self.wrap_lang(info.get('city_of_residence', ''), 'ru') + self.wrap_lang(ar_info.get('city_of_residence', ''), 'ar'), + 'died_in': self.wrap_lang(info.get('city_of_death', ''), 'ru') + self.wrap_lang(ar_info.get('city_of_death', ''), 'ar'), + 'description': self.wrap_lang(''), + 'generation': generation, + 'reliability': reliability, + 'in_sahih_bukhari': n_data.get('transmitted_to_bukhari', False), + 'in_sahih_muslim': n_data.get('transmitted_to_muslim', False), + 'relatives_raw': info.get('relatives', {}) + } + ) + + # Opinions + for op in n_data.get('strengthened_weakened', {}).get('review', []): + author_ui = op.get('author_ui') + scholar_data = scholars_map.get(author_ui, {"ar": author_ui, "ru": author_ui}) + TransmitterOpinion.objects.get_or_create( + transmitter=transmitter, + opinion_text=self.wrap_lang(op.get('quote_original', ''), 'ar') + self.wrap_lang(op.get('quote_translated', ''), 'ru'), + scholar_name=self.wrap_lang(scholar_data['ar'], 'ar') + self.wrap_lang(scholar_data['ru'], 'ru') + ) + + # Original Texts + for text_data in n_data.get('excerpts', []): + orig_text, _ = TransmitterOriginalText.objects.get_or_create( + transmitter=transmitter, + title=self.wrap_lang(text_data.get('title')), + text=self.wrap_lang(text_data.get('text'), 'ar'), + translation=self.wrap_lang(text_data.get('translation'), 'ru') + ) + + for ed in text_data.get('editions', []): + book_ref = self._get_book_volume(ed.get('book_id'), ed.get('volume')) + ref_obj, _ = OriginalTextReference.objects.get_or_create( + original_text=orig_text, book_reference=book_ref, + volume=ed.get('volume'), page=ed.get('page'), url=ed.get('url') + ) + + folder = ed.get('screenshots_folder') + if folder: + self._attach_images(os.path.join(base_dir, 'screens_trx', legacy_id, folder), OriginalTextReferenceImage, ref_obj) + + self.stdout.write(self.style.SUCCESS('Narrators loaded successfully.')) + + + # --- PHASE 3: HADITHS (Arguments, Corrections, Interpretations) --- + self.stdout.write(self.style.WARNING('\n--- PHASE 3: Loading Hadiths ---')) + default_sect, _ = HadisSect.objects.get_or_create( + sect_type='sunni', + defaults={ + 'title': self.wrap_lang('Sunni'), + 'description': self.wrap_lang('') + } + ) + + if os.path.exists(tathir_path): + with open(tathir_path, 'r', encoding='utf-8') as f: + materials = json.load(f).get('materials', []) + + # Map corrections to their parent hadiths + correction_to_hadith_map = {} + for item in materials: + if item.get('type') == 'arguments': + for conf_id in item.get('confirmation', []): + correction_to_hadith_map[conf_id] = item.get('id') + + for item in materials: + i_type = item.get('type') + + # A: BASE HADITHS + if i_type == 'arguments': + cat_str = item.get('category', [''])[0] + category, _ = HadisCategory.objects.get_or_create( + title=self.wrap_lang(cat_str), + defaults={ + 'sect': default_sect, + 'source_type': item.get('subtype', 'hadith') or 'hadith', + 'description': self.wrap_lang('') + } + ) + status, _ = HadisStatus.objects.get_or_create( + title=self.wrap_lang(item.get('authenticity', '')), + defaults={'description': self.wrap_lang('')} + ) + + hadis, _ = Hadis.objects.update_or_create( + legacy_id=item.get('id'), + defaults={ + 'category': category, + 'hadis_status': status, + 'title': self.wrap_lang(item.get('aliases', [''])[0] if item.get('aliases') else ''), + 'title_narrator': self.wrap_lang(item.get('aliases', [''])[0] if item.get('aliases') else ''), + 'description': self.wrap_lang(''), + 'explanation': self.wrap_lang(''), + 'address': self.wrap_lang(''), + 'hadis_status_text': self.wrap_lang(''), + 'text': item.get('original_text', ''), + 'translation': self.wrap_lang(item.get('translation', ''), 'ru') + } + ) + + raw_chain = item.get('chain', []) + chain_arrays = [] + + if raw_chain: + # Normalize: If it's a flat list of ints, wrap it in a list so it's a 2D array + if isinstance(raw_chain[0], int): + chain_arrays = [raw_chain] + else: + chain_arrays = raw_chain + + for chain_idx, narrator_ids in enumerate(chain_arrays): + for order_idx, n_id in enumerate(narrator_ids): + transmitter = Transmitters.objects.filter(legacy_number=n_id).first() + if transmitter: + layer = NarratorLayer.objects.filter(number=transmitter.generation).first() + HadisTransmitter.objects.get_or_create( + hadis=hadis, transmitter=transmitter, chain_index=chain_idx, order=order_idx, + defaults={'narrator_layer': layer, 'status': transmitter.reliability} + ) + # Editions & Images + for ed in item.get('editions', []): + book = self._get_book_volume(ed.get('book_id'), ed.get('volume')) + href, _ = HadisReference.objects.get_or_create( + hadis=hadis, book_reference=book, + defaults={'hadith_number': str(ed.get('hadith_number', '')), 'description': self.wrap_lang('')} + ) + if ed.get('screenshots_folder'): + self._attach_images(os.path.join(base_dir, 'screens', item.get('id'), ed.get('screenshots_folder')), ReferenceImage, href , field_name='thumbnail') + + # B: CORRECTIONS + elif i_type == 'authenticity_analysis': + parent_id = correction_to_hadith_map.get(item.get('id')) + parent_hadith = Hadis.objects.filter(legacy_id=parent_id).first() + + if parent_hadith: + corr, _ = HadisCorrection.objects.get_or_create( + hadis=parent_hadith, legacy_id=item.get('id'), + defaults={ + 'title': self.wrap_lang(''), + 'text': item.get('original_text', ''), # Directly mapped to TextField + 'translation': self.wrap_lang(item.get('translation', ''), 'ru') + } + ) + for ed in item.get('editions', []): + book = self._get_book_volume(ed.get('book_id'), ed.get('volume')) + cref, _ = CorrectionReference.objects.get_or_create(correction=corr, book_reference=book, defaults={'hadith_number': str(ed.get('hadith_number', ''))}) + if ed.get('screenshots_folder'): + self._attach_images(os.path.join(base_dir, 'screens', item.get('id'), ed.get('screenshots_folder')), CorrectionReferenceImage, cref) + + # C: INTERPRETATIONS + elif i_type == 'interpretation': + cat_str = item.get('category', [''])[0] if item.get('category') else '' + category = HadisCategory.objects.filter(title__contains=[{'text': cat_str}]).first() + + if category: + interp, _ = HadisInterpretation.objects.get_or_create( + category=category, legacy_id=item.get('id'), + defaults={ + 'title': self.wrap_lang(''), + 'text': item.get('original_text', ''), + 'translation': self.wrap_lang(item.get('translation', ''), 'ru') + } + ) + for ed in item.get('editions', []): + book = self._get_book_volume(ed.get('book_id'), ed.get('volume')) + iref, _ = InterpretationReference.objects.get_or_create(interpretation=interp, book_reference=book, defaults={'hadith_number': str(ed.get('hadith_number', ''))}) + if ed.get('screenshots_folder'): + self._attach_images(os.path.join(base_dir, 'screens', item.get('id'), ed.get('screenshots_folder')), InterpretationReferenceImage, iref) + + self.stdout.write(self.style.SUCCESS('\nAll Hadiths, Corrections, and Interpretations Imported Successfully!')) + + def _get_book_volume(self, book_id, volume_str): + """Finds the specific volume of a book, with fallbacks.""" + if not book_id: return None + + # 1. Try to find specific volume (e.g., uuid-v2) + if volume_str: + vol_clean = ''.join(filter(str.isdigit, str(volume_str))) # extracts "2" from "Vol 2" + if vol_clean: + book = BookReference.objects.filter(legacy_id=f"{book_id}-v{vol_clean}").first() + if book: return book + + # 2. Fallback: Find the base book (single volume) or the first volume available + return BookReference.objects.filter(legacy_id__startswith=book_id).first() + + def _attach_images(self, folder_path, ImageModelClass, reference_instance, field_name='image'): + """Helper to safely scan a folder and attach images to a specific reference instance.""" + if os.path.exists(folder_path): + for i, filename in enumerate(sorted(os.listdir(folder_path))): + if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')): + file_path = os.path.join(folder_path, filename) + with open(file_path, 'rb') as f: + img_obj = ImageModelClass(reference=reference_instance, priority=i) + + # Dynamically grab the correct field ('image' or 'thumbnail') + image_field = getattr(img_obj, field_name) + image_field.save(filename, File(f), save=True) \ No newline at end of file diff --git a/apps/hadis/migrations/0009_alter_hadistransmitter_options_and_more.py b/apps/hadis/migrations/0009_alter_hadistransmitter_options_and_more.py new file mode 100644 index 0000000..0341c88 --- /dev/null +++ b/apps/hadis/migrations/0009_alter_hadistransmitter_options_and_more.py @@ -0,0 +1,181 @@ +# Generated by Django 5.2.12 on 2026-05-09 10:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0008_hadiscollection_pin_top'), + ] + + operations = [ + migrations.AlterModelOptions( + name='hadistransmitter', + options={'ordering': ('hadis', 'chain_index', 'order'), 'verbose_name': 'Hadis Transmitter', 'verbose_name_plural': 'Hadis Transmitters'}, + ), + migrations.AlterUniqueTogether( + name='hadistransmitter', + unique_together=set(), + ), + migrations.AddField( + model_name='bookreference', + name='legacy_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='bookreference', + name='source_url', + field=models.URLField(blank=True, max_length=1000, null=True, verbose_name='Source URL'), + ), + migrations.AddField( + model_name='bookreferenceimage', + name='volume', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Volume'), + ), + migrations.AddField( + model_name='hadis', + name='legacy_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='hadiscorrection', + name='legacy_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='hadisreference', + name='hadith_number', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Hadith Number in Book'), + ), + migrations.AddField( + model_name='hadistransmitter', + name='chain_index', + field=models.PositiveIntegerField(default=0, help_text='Which chain/branch this belongs to (0, 1, 2...)', verbose_name='Chain Index'), + ), + migrations.AddField( + model_name='transmitters', + name='father', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children_as_father', to='hadis.transmitters', verbose_name='Father'), + ), + migrations.AddField( + model_name='transmitters', + name='legacy_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='transmitters', + name='legacy_number', + field=models.PositiveIntegerField(blank=True, db_index=True, null=True, unique=True), + ), + migrations.AddField( + model_name='transmitters', + name='mother', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children_as_mother', to='hadis.transmitters', verbose_name='Mother'), + ), + migrations.AddField( + model_name='transmitters', + name='relatives_raw', + field=models.JSONField(blank=True, default=dict, verbose_name='Raw Relatives Data'), + ), + migrations.AddField( + model_name='transmitters', + name='siblings', + field=models.ManyToManyField(blank=True, to='hadis.transmitters', verbose_name='Siblings'), + ), + migrations.AlterUniqueTogether( + name='hadistransmitter', + unique_together={('hadis', 'chain_index', 'order')}, + ), + migrations.CreateModel( + name='BookReferenceDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='hadis/book_reference_documents/', verbose_name='Document File')), + ('volume', models.CharField(blank=True, max_length=50, null=True, verbose_name='Volume')), + ('title', models.CharField(blank=True, help_text='e.g., Volume 1, 01.pdf', max_length=255, null=True)), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('book_reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='hadis.bookreference', verbose_name='book reference')), + ], + options={ + 'verbose_name': 'Book Reference Document', + 'verbose_name_plural': 'Book Reference Documents', + 'ordering': ['order', '-created_at'], + }, + ), + migrations.CreateModel( + name='CorrectionReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hadith_number', models.CharField(blank=True, max_length=100, null=True)), + ('book_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.bookreference')), + ('correction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadiscorrection')), + ], + ), + migrations.CreateModel( + name='CorrectionReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='hadis/correction_images/')), + ('priority', models.IntegerField(default=0)), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='hadis.correctionreference')), + ], + ), + migrations.CreateModel( + name='HadisInterpretation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('legacy_id', models.CharField(blank=True, db_index=True, max_length=255, null=True, unique=True)), + ('title', models.JSONField(default=list, verbose_name='Title')), + ('text', models.TextField(verbose_name='Original Text')), + ('translation', models.JSONField(default=list, verbose_name='Translation')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interpretations', to='hadis.hadiscategory', verbose_name='Category')), + ], + options={ + 'verbose_name': 'Hadis Interpretation', + 'verbose_name_plural': 'Hadis Interpretations', + }, + ), + migrations.CreateModel( + name='InterpretationReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hadith_number', models.CharField(blank=True, max_length=100, null=True)), + ('book_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.bookreference')), + ('interpretation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadisinterpretation')), + ], + ), + migrations.CreateModel( + name='InterpretationReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='hadis/interpretation_images/')), + ('priority', models.IntegerField(default=0)), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='hadis.interpretationreference')), + ], + ), + migrations.CreateModel( + name='OriginalTextReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('volume', models.CharField(blank=True, max_length=100, null=True)), + ('page', models.CharField(blank=True, max_length=100, null=True)), + ('url', models.URLField(blank=True, max_length=500, null=True)), + ('book_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.bookreference')), + ('original_text', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.transmitteroriginaltext')), + ], + ), + migrations.CreateModel( + name='OriginalTextReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='hadis/original_text_images/')), + ('priority', models.IntegerField(default=0)), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='hadis.originaltextreference')), + ], + ), + ] diff --git a/apps/hadis/migrations/0010_remove_hadiscorrection_description_and_more.py b/apps/hadis/migrations/0010_remove_hadiscorrection_description_and_more.py new file mode 100644 index 0000000..594341a --- /dev/null +++ b/apps/hadis/migrations/0010_remove_hadiscorrection_description_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.12 on 2026-05-09 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0009_alter_hadistransmitter_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='hadiscorrection', + name='description', + ), + migrations.AddField( + model_name='bookreference', + name='number_of_volumes', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Total Number of Volumes'), + ), + migrations.AddField( + model_name='hadiscorrection', + name='text', + field=models.TextField(blank=True, default='', verbose_name='Original Text'), + ), + ] diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index aa95b29..01f728b 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -262,6 +262,7 @@ class HadisStatus(ColorPaletteMixin,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) + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) 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')) @@ -420,6 +421,7 @@ class HadisReference(models.Model): verbose_name=_('book reference'), related_name='hadis_references' ) + hadith_number = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Hadith Number in Book')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) @@ -488,7 +490,8 @@ class HadisCorrection(models.Model): hadis = models.ForeignKey(Hadis, verbose_name=_("hadis correction"), on_delete=models.CASCADE) title = models.JSONField(default = list , verbose_name=_('Title')) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) - description =models.JSONField(default = list , verbose_name=_('Description')) + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) + text = models.TextField(verbose_name=_('Original Text'), default="", blank=True) translation = models.JSONField(verbose_name=_("translation"), default=list) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("updated at")) @@ -505,100 +508,94 @@ class HadisCorrection(models.Model): return f"{self.hadis.number} - {title}" def save(self, *args, **kwargs): - """ - Override save to automatically generate smart slugs. - """ - - # Generate slug if not already set + # Generate smart slug if not self.slug and self.title: - # Extract title text title_text = None - if isinstance(self.title, list) and self.title: + if isinstance(self.title, list) and len(self.title) > 0: 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=HadisCorrection, - max_length=100, # ← Adjust max length here - keep_words=8, # ← Limit to 8 words (your requirement) - instance=self, + text=title_text, model_class=HadisCorrection, + max_length=100, keep_words=8, instance=self, ) else: - # Fallback if title is empty - use timestamp for uniqueness import time suffix = int(time.time() * 1000) % 1000000 base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}" - # Ensure uniqueness counter = 1 while HadisCorrection.objects.filter(slug=base_slug).exclude(pk=self.pk).exists(): base_slug = f"correction-{self.hadis.slug if self.hadis and self.hadis.slug else 'unknown'}-{suffix}-{counter}" counter += 1 self.slug = base_slug - # Generate/update share_link before saving if self.slug and self.hadis and self.hadis.slug: self.share_link = f"{settings.DOVODI_DOMAIN}/arguments/hadith/{self.hadis.slug}/corrections/{self.slug}" - # Reset embedded_in if text or translation changes + # Track 'text' instead of 'description' if self.pk: old_instance = HadisCorrection.objects.get(pk=self.pk) - if (old_instance.description != self.description or + if (old_instance.text != self.text or old_instance.translation != self.translation): self.embedded_in = [] # Reset! super().save(*args, **kwargs) - def get_title(self,lang): - """ - Get title for a specific language - """ - - if not self.title or not isinstance(self.title, list): - return None - + def get_title(self, lang): + if not self.title or not isinstance(self.title, list): return None for tr in self.title: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - + if isinstance(tr, dict) and tr.get('language_code') == lang: return tr.get('text', '') for tr in self.title: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') + if isinstance(tr, dict) and tr.get('language_code') == 'en': return tr.get('text', '') return None - def get_description(self,lang): - """ - Get title for a specific language - """ - - if not self.description or not isinstance(self.description, list): - return None - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') - - for tr in self.description: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') + def get_translation(self, lang): + 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('text', '') + for tr in self.translation: + if isinstance(tr, dict) and tr.get('language_code') == 'en': return tr.get('text', '') return None - def get_translation(self,lang): - """ - Get title for a specific language - """ - if not self.translation or not isinstance(self.translation, list): - return None +# apps/hadis/models/hadis.py (or a new interpretation.py file) - for tr in self.translation: - if isinstance(tr, dict) and tr.get('language_code') == lang: - return tr.get('text', '') +class HadisInterpretation(models.Model): + category = models.ForeignKey('HadisCategory', on_delete=models.CASCADE, related_name='interpretations', verbose_name=_('Category')) + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) + + title = models.JSONField(default=list, verbose_name=_('Title')) + text = models.TextField(verbose_name=_('Original Text')) + translation = models.JSONField(verbose_name=_('Translation'), default=list) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - for tr in self.translation: - if isinstance(tr, dict) and tr.get('language_code') == 'en': - return tr.get('text', '') - return None + class Meta: + verbose_name = _("Hadis Interpretation") + verbose_name_plural = _("Hadis Interpretations") + +# --- FOR CORRECTIONS --- +class CorrectionReference(models.Model): + correction = models.ForeignKey(HadisCorrection, on_delete=models.CASCADE, related_name='references') + book_reference = models.ForeignKey(BookReference, on_delete=models.SET_NULL, null=True, blank=True) + hadith_number = models.CharField(max_length=100, null=True, blank=True) + +class CorrectionReferenceImage(models.Model): + reference = models.ForeignKey(CorrectionReference, related_name='images', on_delete=models.CASCADE) + image = models.ImageField(upload_to='hadis/correction_images/') + priority = models.IntegerField(default=0) + + +# --- FOR INTERPRETATIONS --- +class InterpretationReference(models.Model): + interpretation = models.ForeignKey(HadisInterpretation, on_delete=models.CASCADE, related_name='references') + book_reference = models.ForeignKey(BookReference, on_delete=models.SET_NULL, null=True, blank=True) + hadith_number = models.CharField(max_length=100, null=True, blank=True) + +class InterpretationReferenceImage(models.Model): + reference = models.ForeignKey(InterpretationReference, related_name='images', on_delete=models.CASCADE) + image = models.ImageField(upload_to='hadis/interpretation_images/') + priority = models.IntegerField(default=0) \ No newline at end of file diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index 46e6bff..080e140 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -60,11 +60,14 @@ class BookReference(models.Model): Model for hadis book references with detailed information This is different from library books - these are reference books for hadis """ + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) + source_url = models.URLField(max_length=1000, null=True, blank=True, verbose_name=_('Source URL')) title = models.JSONField(default = list , verbose_name=_('Title')) description = models.JSONField(default = list , verbose_name=_('Description')) language = models.JSONField(default = list , verbose_name=_('Language')) isbn = models.CharField(max_length=100, verbose_name=_('ISBN'), blank=True, null=True) volume = models.CharField(max_length=100, verbose_name=_('volume'), blank=True, null=True) + number_of_volumes = models.PositiveIntegerField(verbose_name=_('Total Number of Volumes'), null=True, blank=True) year_of_publication = models.CharField(max_length=50, verbose_name=_('year of publication'), blank=True, null=True) number_page = models.PositiveIntegerField(verbose_name=_('number of pages'), blank=True, null=True) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) @@ -211,6 +214,7 @@ class BookReferenceImage(models.Model): verbose_name=_('book reference') ) image = models.ImageField(upload_to='hadis/book_reference_images/', verbose_name=_('image')) + volume = models.CharField(max_length=50, null=True, blank=True, verbose_name=_('Volume')) order = models.PositiveIntegerField(default=0, verbose_name=_('order')) description = models.JSONField(default = list , verbose_name=_('Description')) @@ -241,6 +245,30 @@ class BookReferenceImage(models.Model): def __str__(self): return f"{self.book_reference} - Image {self.order}" +class BookReferenceDocument(models.Model): + """ + Model for handling multiple PDF files per book (e.g., different volumes). + """ + book_reference = models.ForeignKey( + BookReference, + on_delete=models.CASCADE, + related_name='documents', + verbose_name=_('book reference') + ) + file = models.FileField(upload_to='hadis/book_reference_documents/', verbose_name=_('Document File')) + volume = models.CharField(max_length=50, null=True, blank=True, verbose_name=_('Volume')) + title = models.CharField(max_length=255, blank=True, null=True, help_text=_('e.g., Volume 1, 01.pdf')) + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _('Book Reference Document') + verbose_name_plural = _('Book Reference Documents') + ordering = ['order', '-created_at'] + + def __str__(self): + return f"{self.book_reference} - {self.title or 'Doc ' + str(self.id)}" + class BookAuthor(models.Model): """ diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 2ca3ece..774bd42 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -235,6 +235,13 @@ class Transmitters(models.Model): known_as = models.JSONField(default = list , verbose_name=_('Known as')) nickname = models.JSONField(default = list , verbose_name=_('Nick Name')) slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True,unique=True) + legacy_number = models.PositiveIntegerField(unique=True, null=True, blank=True, db_index=True) + legacy_id = models.CharField(max_length=255, unique=True, null=True, blank=True, db_index=True) + relatives_raw = models.JSONField(default=dict, blank=True, verbose_name=_('Raw Relatives Data')) + father = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_father', verbose_name=_('Father')) + mother = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children_as_mother', verbose_name=_('Mother')) + # ManyToMany for siblings allows bi-directional sibling relationships + siblings = models.ManyToManyField('self', blank=True, verbose_name=_('Siblings')) # Geographic Information origin = models.JSONField(default = list , verbose_name=_('Origin')) @@ -423,6 +430,13 @@ class HadisTransmitter(models.Model): verbose_name=_('transmitter'), related_name='hadises' ) + + chain_index = models.PositiveIntegerField( + default=0, + verbose_name=_('Chain Index'), + help_text=_('Which chain/branch this belongs to (0, 1, 2...)') + ) + narrator_layer = models.ForeignKey( NarratorLayer, on_delete=models.SET_NULL, @@ -457,12 +471,14 @@ class HadisTransmitter(models.Model): ] verbose_name = _('Hadis Transmitter') verbose_name_plural = _('Hadis Transmitters') - ordering = ('hadis', 'order') - unique_together = ('hadis', 'transmitter', 'order') + ordering = ('hadis', 'chain_index', 'order') # Update ordering + + # 2. UPDATE UNIQUE TOGETHER + # A specific position in a specific chain of a specific hadith can only have one narrator + unique_together = ('hadis', 'chain_index', 'order') def __str__(self): - layer_info = f" - {self.narrator_layer}" if self.narrator_layer else "" - return f'{self.hadis.number} - {self.transmitter} ({self.order}){layer_info}' + return f'{self.hadis.number} - Chain {self.chain_index} - {self.transmitter} (Pos: {self.order})' class OpinionStatus(ColorPaletteMixin, models.Model): @@ -737,3 +753,21 @@ class TransmitterOriginalText(models.Model): if isinstance(tr, dict) and tr.get('language_code') == 'en': return tr.get('text', '') return None +from .reference import BookReference +class OriginalTextReference(models.Model): + """Links an Original Text (Excerpt) to a specific Book Edition""" + original_text = models.ForeignKey( + TransmitterOriginalText, + on_delete=models.CASCADE, + related_name='references' + ) + book_reference = models.ForeignKey(BookReference, on_delete=models.SET_NULL, null=True, blank=True) + volume = models.CharField(max_length=100, null=True, blank=True) + page = models.CharField(max_length=100, null=True, blank=True) + url = models.URLField(max_length=500, null=True, blank=True) + +class OriginalTextReferenceImage(models.Model): + """Stores the screenshots for a specific Original Text Book Reference""" + reference = models.ForeignKey(OriginalTextReference, related_name='images', on_delete=models.CASCADE) + image = models.ImageField(upload_to='hadis/original_text_images/') + priority = models.IntegerField(default=0) \ No newline at end of file diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index 36de333..55e607a 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -205,7 +205,7 @@ class HadisSyncSerializer(serializers.ModelSerializer): corrections_data.append({ 'id': correction.id, 'title': get_localized_text(correction.title, request), - 'description': get_localized_text(correction.description, request), + 'text': correction.text, 'translation': get_localized_text(correction.translation, request), 'share_link': correction.share_link, }) @@ -706,12 +706,12 @@ class HadisReferenceSerializer(serializers.ModelSerializer): class HadisCorrectionSerializer(serializers.ModelSerializer): """Serializer for HadisCorrection""" title = LocalizedField() - description = LocalizedField() + text = LocalizedField() translation = LocalizedField() bookmark = serializers.SerializerMethodField() class Meta: model = HadisCorrection - fields = ['id', 'title','slug', 'description', 'translation','share_link','bookmark'] + fields = ['id', 'title','slug', 'text', 'translation','share_link','bookmark'] def get_bookmark(self, obj): """Get bookmark information for this correction."""