diff --git a/apps/hadis/admin/hadis.py b/apps/hadis/admin/hadis.py index 39d9ca6..3d7f581 100644 --- a/apps/hadis/admin/hadis.py +++ b/apps/hadis/admin/hadis.py @@ -9,7 +9,8 @@ import json from utils.admin import project_admin_site from ..models import ( - Hadis, HadisReference, HadisTag, HadisStatus, ReferenceImage + Hadis, HadisReference, HadisTag, HadisStatus, ReferenceImage, + HadisCollection, HadisInCollection ) @@ -99,7 +100,7 @@ class HadisReferenceInline(TabularInline): """Inline for HadisReference in Hadis admin""" model = HadisReference extra = 0 - fields = ('book', 'description') + fields = ('book_reference',) readonly_fields = ('created_at',) @@ -170,15 +171,15 @@ class HadisAdmin(ModelAdmin): class HadisReferenceAdmin(ModelAdmin): """Admin for HadisReference model""" - list_display = ('hadis', 'book', 'created_at') - list_filter = ('created_at', 'book') - search_fields = ('hadis__title', 'book__title', 'description') + list_display = ('hadis', 'book_reference', 'created_at') + list_filter = ('created_at', 'book_reference') + search_fields = ('hadis__title', 'book_reference__title') readonly_fields = ('created_at',) inlines = [ReferenceImageInline] fieldsets = ( (None, { - 'fields': ('hadis', 'book', 'description') + 'fields': ('hadis', 'book_reference') }), (_('Timestamps'), { 'fields': ('created_at',), @@ -201,9 +202,38 @@ class ReferenceImageAdmin(ModelAdmin): ) +class HadisInCollectionInline(TabularInline): + """Inline for HadisInCollection in HadisCollection admin""" + model = HadisInCollection + extra = 0 + fields = ('hadis', 'order') + ordering = ('order',) + + +class HadisCollectionAdmin(ModelAdmin): + """Admin for HadisCollection model""" + list_display = ('title', 'slug', 'status', 'order', 'created_at') + list_filter = ('status', 'created_at') + search_fields = ('title', 'slug', 'summary') + readonly_fields = ('slug', 'created_at', 'updated_at') + ordering = ('order',) + inlines = [HadisInCollectionInline] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'summary', 'status', 'order', 'thumbnail') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + # Register models with the custom admin site project_admin_site.register(HadisTag, HadisTagAdmin) project_admin_site.register(HadisStatus, HadisStatusAdmin) project_admin_site.register(Hadis, HadisAdmin) project_admin_site.register(HadisReference, HadisReferenceAdmin) -project_admin_site.register(ReferenceImage, ReferenceImageAdmin) \ No newline at end of file +project_admin_site.register(ReferenceImage, ReferenceImageAdmin) +project_admin_site.register(HadisCollection, HadisCollectionAdmin) \ No newline at end of file diff --git a/apps/hadis/admin/transmitter.py b/apps/hadis/admin/transmitter.py index 4f29233..e7ccc21 100644 --- a/apps/hadis/admin/transmitter.py +++ b/apps/hadis/admin/transmitter.py @@ -11,7 +11,7 @@ class HadisTransmitterInline(TabularInline): """Inline for HadisTransmitter in Transmitters admin""" model = HadisTransmitter extra = 0 - fields = ('hadis', 'order', 'status', 'is_gap') + fields = ('hadis', 'order', 'status') readonly_fields = ('created_at',) @@ -40,8 +40,8 @@ class TransmittersAdmin(ModelAdmin): class HadisTransmitterAdmin(ModelAdmin): """Admin for HadisTransmitter model""" - list_display = ('hadis', 'transmitter', 'order', 'status', 'is_gap', 'created_at') - list_filter = ('status', 'is_gap', 'created_at') + list_display = ('hadis', 'transmitter', 'order', 'status', 'created_at') + list_filter = ('status', 'created_at') search_fields = ('hadis__title', 'transmitter__full_name') readonly_fields = ('created_at',) ordering = ('hadis', 'order') @@ -50,9 +50,8 @@ class HadisTransmitterAdmin(ModelAdmin): (None, { 'fields': ('hadis', 'transmitter', 'order') }), - (_('Status & Gap Information'), { - 'fields': ('status', 'is_gap'), - 'description': _('Use "Is Gap" to mark missing links in the transmission chain') + (_('Status Information'), { + 'fields': ('status',) }), (_('Timestamps'), { 'fields': ('created_at',), diff --git a/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py b/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py new file mode 100644 index 0000000..2650a1b --- /dev/null +++ b/apps/hadis/migrations/0003_bookreference_narratorlayer_and_more.py @@ -0,0 +1,226 @@ +# Generated by Django 5.1.8 on 2025-12-03 23:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0002_hadissect_hadisstatus_alter_hadis_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BookReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=500, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('language', models.CharField(blank=True, max_length=100, null=True, verbose_name='language')), + ('isbn', models.CharField(blank=True, max_length=100, null=True, verbose_name='ISBN')), + ('volume', models.CharField(blank=True, max_length=100, null=True, verbose_name='volume')), + ('year_of_publication', models.CharField(blank=True, max_length=50, null=True, verbose_name='year of publication')), + ('number_page', models.PositiveIntegerField(blank=True, null=True, verbose_name='number of pages')), + ('rate', models.DecimalField(blank=True, decimal_places=2, help_text='Rating from 0 to 5', max_digits=3, null=True, verbose_name='rate')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Book Reference', + 'verbose_name_plural': 'Book References', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='NarratorLayer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('number', models.PositiveIntegerField(unique=True, verbose_name='layer number')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Narrator Layer', + 'verbose_name_plural': 'Narrator Layers', + 'ordering': ['number'], + }, + ), + migrations.AlterUniqueTogether( + name='hadisreference', + unique_together=set(), + ), + migrations.RemoveField( + model_name='hadistransmitter', + name='is_gap', + ), + migrations.AddField( + model_name='hadis', + name='title_narrator', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title narrator'), + ), + migrations.AddField( + model_name='hadiscategory', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='hadissect', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='transmitters', + name='age_at_death', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Age at Death'), + ), + migrations.AddField( + model_name='transmitters', + name='died_in', + field=models.CharField(blank=True, help_text='Place of death', max_length=255, null=True, verbose_name='Died In'), + ), + migrations.AddField( + model_name='transmitters', + name='in_sahih_bukhari', + field=models.BooleanField(default=False, help_text='Is this narrator present in Sahih Bukhari?', verbose_name='In Sahih Bukhari'), + ), + migrations.AddField( + model_name='transmitters', + name='in_sahih_muslim', + field=models.BooleanField(default=False, help_text='Is this narrator present in Sahih Muslim?', verbose_name='In Sahih Muslim'), + ), + migrations.AddField( + model_name='transmitters', + name='known_as', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Known As'), + ), + migrations.AddField( + model_name='transmitters', + name='kunya', + field=models.CharField(blank=True, help_text='e.g., Abu Abdullah', max_length=255, null=True, verbose_name='Kunya'), + ), + migrations.AddField( + model_name='transmitters', + name='lived_in', + field=models.CharField(blank=True, help_text='Places where they lived', max_length=255, null=True, verbose_name='Lived In'), + ), + migrations.AddField( + model_name='transmitters', + name='madhhab', + field=models.CharField(choices=[('shia', 'Shia'), ('sunni', 'Sunni'), ('hanafi', 'Hanafi'), ('maliki', 'Maliki'), ('shafii', "Shafi'i"), ('hanbali', 'Hanbali'), ('other', 'Other'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Madhhab/School of Thought'), + ), + migrations.AddField( + model_name='transmitters', + name='nickname', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Nickname/Laqab'), + ), + migrations.AddField( + model_name='transmitters', + name='origin', + field=models.CharField(blank=True, help_text='Place of origin', max_length=255, null=True, verbose_name='Origin'), + ), + migrations.AddField( + model_name='transmitters', + name='reliability', + field=models.CharField(choices=[('very_reliable', 'Very Reliable'), ('reliable', 'Reliable'), ('acceptable', 'Acceptable'), ('weak', 'Weak'), ('very_weak', 'Very Weak'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Reliability Level'), + ), + migrations.AlterField( + model_name='hadiscategory', + name='source_type', + field=models.CharField(choices=[('quran', 'Quran'), ('hadith', 'Hadith'), ('history', 'History'), ('fatwa', 'Fatwa'), ('quote', 'Quote')], max_length=10, verbose_name='Source Type'), + ), + migrations.AlterField( + model_name='hadistransmitter', + name='status', + field=models.CharField(choices=[('reliable', 'Reliable'), ('weak', 'Weak'), ('unknown', 'Unknown')], default='unknown', help_text='Reliability status of the narrator', max_length=20, verbose_name='reliability status'), + ), + migrations.AlterField( + model_name='hadistransmitter', + name='transmitter', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter'), + ), + migrations.CreateModel( + name='BookAuthor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('book_references', models.ManyToManyField(blank=True, related_name='authors', to='hadis.bookreference', verbose_name='book references')), + ], + options={ + 'verbose_name': 'Book Author', + 'verbose_name_plural': 'Book Authors', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='BookAttribute', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('value', models.CharField(max_length=500, verbose_name='value')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('book_references', models.ManyToManyField(blank=True, related_name='attributes', to='hadis.bookreference', verbose_name='book references')), + ], + options={ + 'verbose_name': 'Book Attribute', + 'verbose_name_plural': 'Book Attributes', + 'ordering': ['title'], + }, + ), + migrations.AddField( + model_name='hadisreference', + name='book_reference', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='hadis.bookreference', verbose_name='book reference'), + ), + migrations.CreateModel( + name='BookReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='hadis/book_reference_images/', verbose_name='image')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('description', models.CharField(blank=True, max_length=255, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('book_reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='hadis.bookreference', verbose_name='book reference')), + ], + options={ + 'verbose_name': 'Book Reference Image', + 'verbose_name_plural': 'Book Reference Images', + 'ordering': ['order', '-created_at'], + }, + ), + migrations.AddField( + model_name='hadistransmitter', + name='narrator_layer', + field=models.ForeignKey(blank=True, help_text='The layer/class (Tabaqah) this narrator belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transmitters', to='hadis.narratorlayer', verbose_name='narrator layer'), + ), + migrations.CreateModel( + name='TransmitterOpinion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scholar_name', models.CharField(help_text='Name of the scholar who gave this opinion', max_length=255, verbose_name='Scholar Name')), + ('opinion_text', models.TextField(help_text="The scholar's opinion about this transmitter", verbose_name='Opinion Text')), + ('status', models.CharField(choices=[('confirmed', 'Confirmed'), ('mixed', 'Mixed'), ('rejected', 'Rejected')], default='confirmed', help_text='Status of the opinion', max_length=20, verbose_name='Opinion Status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opinions', to='hadis.transmitters', verbose_name='transmitter')), + ], + options={ + 'verbose_name': 'Transmitter Opinion', + 'verbose_name_plural': 'Transmitter Opinions', + 'ordering': ('-created_at',), + }, + ), + migrations.RemoveField( + model_name='hadisreference', + name='book', + ), + migrations.RemoveField( + model_name='hadisreference', + name='description', + ), + ] diff --git a/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py b/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py new file mode 100644 index 0000000..733ea2e --- /dev/null +++ b/apps/hadis/migrations/0004_hadiscollection_hadisincollection.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2.4 on 2025-12-05 17:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ('hadis', '0003_bookreference_narratorlayer_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HadisCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('slug', models.SlugField(blank=True, max_length=255, unique=True, verbose_name='slug')), + ('summary', models.TextField(blank=True, null=True, verbose_name='summary')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='thumbnail image', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ], + options={ + 'verbose_name': 'hadis collection', + 'verbose_name_plural': 'hadis collections', + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='HadisInCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadis_items', to='hadis.hadiscollection', verbose_name='collection')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_items', to='hadis.hadis', verbose_name='hadis')), + ], + options={ + 'verbose_name': 'hadis in collection', + 'verbose_name_plural': 'hadis in collections', + 'ordering': ('order',), + 'unique_together': {('hadis', 'collection')}, + }, + ), + ] diff --git a/apps/hadis/models/__init__.py b/apps/hadis/models/__init__.py index ba8ce70..3e6f70c 100644 --- a/apps/hadis/models/__init__.py +++ b/apps/hadis/models/__init__.py @@ -1,3 +1,4 @@ from .category import * from .hadis import * -from .transmitter import * \ No newline at end of file +from .transmitter import * +from .reference import * \ No newline at end of file diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py index 5b107ce..a74f23e 100644 --- a/apps/hadis/models/category.py +++ b/apps/hadis/models/category.py @@ -29,11 +29,15 @@ class HadisCategory(MPTTModel): class SourceType(models.TextChoices): QURAN = 'quran', _('Quran') HADITH = 'hadith', _('Hadith') + HISTORY = 'history', _('History') + FATWA = 'fatwa', _('Fatwa') + QUOTE = 'quote', _('Quote') parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') sect = models.ForeignKey(HadisSect, on_delete=models.PROTECT, verbose_name=_('Sect'), null=False, blank=False) source_type = models.CharField(max_length=10, choices=SourceType.choices, verbose_name=_('Source Type')) title = models.CharField(max_length=256, verbose_name=_('Title')) + description = models.TextField(verbose_name=_('Description'), null=True, blank=True) order = models.IntegerField(default=0, verbose_name=_('order')) xmind_file = models.FileField(upload_to='hadis/xmind_files/', verbose_name=_('xmind file'), null=True, blank=True) slug = None diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index 4cb3f82..83babff 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -2,6 +2,61 @@ from django.db import models from django.db.models import F from django.utils.translation import gettext_lazy as _ from django.conf import settings +from django.utils.text import slugify +from filer.fields.image import FilerImageField + + +class HadisCollection(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(max_length=255, unique=True, verbose_name=_('slug'), blank=True) + summary = models.TextField(verbose_name=_('summary'), null=True, blank=True) + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.IntegerField(default=0, verbose_name=_('order')) + thumbnail = FilerImageField( + related_name="+", + on_delete=models.CASCADE, + help_text=_('thumbnail image'), + null=True, + blank=True, + verbose_name=_('thumbnail') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title, allow_unicode=True) + slug = base_slug + counter = 1 + while HadisCollection.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def __str__(self): + return self.title + + class Meta: + verbose_name = _('hadis collection') + verbose_name_plural = _('hadis collections') + ordering = ('order',) + + +class HadisInCollection(models.Model): + hadis = models.ForeignKey('Hadis', on_delete=models.CASCADE, verbose_name=_('hadis'), related_name='collection_items') + collection = models.ForeignKey(HadisCollection, on_delete=models.CASCADE, verbose_name=_('collection'), related_name='hadis_items') + order = models.IntegerField(default=0, verbose_name=_('order')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('hadis in collection') + verbose_name_plural = _('hadis in collections') + ordering = ('order',) + unique_together = ('hadis', 'collection') + + def __str__(self): + return f"{self.collection.title} - {self.hadis.number}" class HadisTag(models.Model): @@ -43,7 +98,9 @@ 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) + title_narrator = models.CharField(max_length=255, verbose_name=_('title narrator'), null=True, blank=True) title = models.CharField(max_length=255, verbose_name=_('title'), null=True, blank=True) + text = models.TextField(verbose_name=_('text')) translation = models.JSONField(verbose_name=_('translation'), default=list) status = models.BooleanField(default=True, verbose_name=_('visibility')) @@ -99,17 +156,23 @@ class HadisReference(models.Model): verbose_name=_('hadis'), related_name='references' ) - book = models.ForeignKey("library.Book", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('book'), related_name='hadis_references') - description = models.TextField(verbose_name=_('description'), blank=True, null=True) + book_reference = models.ForeignKey( + "hadis.BookReference", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('book reference'), + related_name='hadis_references' + ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) class Meta: verbose_name = _('Hadis Reference') verbose_name_plural = _('Hadis References') - unique_together = ('hadis', 'book') + # unique_together = ('hadis', 'book_reference') def __str__(self): - return f'{self.hadis.number}-{self.book.title if self.book else "No Book"}' + return f'{self.hadis.number}-{self.book_reference.title if self.book_reference else "No Book Reference"}' class ReferenceImage(models.Model): reference = models.ForeignKey(HadisReference, verbose_name="Hadis Reference", on_delete=models.CASCADE) diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py new file mode 100644 index 0000000..22077d0 --- /dev/null +++ b/apps/hadis/models/reference.py @@ -0,0 +1,109 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class BookReference(models.Model): + """ + Model for hadis book references with detailed information + This is different from library books - these are reference books for hadis + """ + title = models.CharField(max_length=500, verbose_name=_('title')) + description = models.TextField(verbose_name=_('description'), blank=True, null=True) + language = models.CharField(max_length=100, verbose_name=_('language'), blank=True, null=True) + 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) + 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) + rate = models.DecimalField( + max_digits=3, + decimal_places=2, + verbose_name=_('rate'), + blank=True, + null=True, + help_text=_('Rating from 0 to 5') + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Reference') + verbose_name_plural = _('Book References') + ordering = ('-created_at',) + + def __str__(self): + return self.title + + +class BookReferenceImage(models.Model): + """ + Model for book reference images - multiple images per book reference + """ + book_reference = models.ForeignKey( + BookReference, + on_delete=models.CASCADE, + related_name='images', + verbose_name=_('book reference') + ) + image = models.ImageField(upload_to='hadis/book_reference_images/', verbose_name=_('image')) + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + description = models.CharField(max_length=255, verbose_name=_('description'), blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('Book Reference Image') + verbose_name_plural = _('Book Reference Images') + ordering = ['order', '-created_at'] + + def __str__(self): + return f"{self.book_reference.title} - Image {self.order}" + + +class BookAuthor(models.Model): + """ + Model for book reference authors + """ + name = models.CharField(max_length=255, verbose_name=_('name')) + book_references = models.ManyToManyField( + BookReference, + related_name='authors', + verbose_name=_('book references'), + blank=True + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Author') + verbose_name_plural = _('Book Authors') + ordering = ['name'] + + def __str__(self): + return self.name + + +class BookAttribute(models.Model): + """ + Model for book reference attributes - custom key-value pairs + """ + title = models.CharField(max_length=255, verbose_name=_('title')) + value = models.CharField(max_length=500, verbose_name=_('value')) + book_references = models.ManyToManyField( + BookReference, + related_name='attributes', + verbose_name=_('book references'), + blank=True + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Book Attribute') + verbose_name_plural = _('Book Attributes') + ordering = ['title'] + + def __str__(self): + return f"{self.title}: {self.value}" diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 6233ec9..693e1dd 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -5,11 +5,89 @@ from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField +class NarratorLayer(models.Model): + """ + Model for narrator layers/classes (Tabaqat) + Represents the classification level of narrators in hadis chains + """ + name = models.CharField(max_length=255, verbose_name=_('name')) + number = models.PositiveIntegerField(verbose_name=_('layer number'), unique=True) + description = models.TextField(verbose_name=_('description'), blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Narrator Layer') + verbose_name_plural = _('Narrator Layers') + ordering = ['number'] + + def __str__(self): + return f"{_('Layer')} {self.number} - {self.name}" + class Transmitters(models.Model): + class ReliabilityLevel(models.TextChoices): + VERY_RELIABLE = 'very_reliable', _('Very Reliable') + RELIABLE = 'reliable', _('Reliable') + ACCEPTABLE = 'acceptable', _('Acceptable') + WEAK = 'weak', _('Weak') + VERY_WEAK = 'very_weak', _('Very Weak') + UNKNOWN = 'unknown', _('Unknown') + + class MadhhabChoices(models.TextChoices): + SHIA = 'shia', _('Shia') + SUNNI = 'sunni', _('Sunni') + HANAFI = 'hanafi', _('Hanafi') + MALIKI = 'maliki', _('Maliki') + SHAFII = 'shafii', _('Shafi\'i') + HANBALI = 'hanbali', _('Hanbali') + OTHER = 'other', _('Other') + UNKNOWN = 'unknown', _('Unknown') + + # Basic Information full_name = models.CharField(max_length=255, verbose_name=_('full name')) + kunya = models.CharField(max_length=255, verbose_name=_('Kunya'), blank=True, null=True, help_text=_('e.g., Abu Abdullah')) + known_as = models.CharField(max_length=255, verbose_name=_('Known As'), blank=True, null=True) + nickname = models.CharField(max_length=255, verbose_name=_('Nickname/Laqab'), blank=True, null=True) + + # Geographic Information + origin = models.CharField(max_length=255, verbose_name=_('Origin'), blank=True, null=True, help_text=_('Place of origin')) + lived_in = models.CharField(max_length=255, verbose_name=_('Lived In'), blank=True, null=True, help_text=_('Places where they lived')) + died_in = models.CharField(max_length=255, verbose_name=_('Died In'), blank=True, null=True, help_text=_('Place of death')) + + # Date Information birth_year_hijri = models.IntegerField(verbose_name=_("Birth Year (Hijri)"), null=True, blank=True) death_year_hijri = models.IntegerField(verbose_name=_("Death Year (Hijri)"), null=True, blank=True) + age_at_death = models.PositiveIntegerField(verbose_name=_('Age at Death'), blank=True, null=True) + + # Religious & Academic Information + reliability = models.CharField( + max_length=20, + choices=ReliabilityLevel.choices, + default=ReliabilityLevel.UNKNOWN, + verbose_name=_('Reliability Level') + ) + madhhab = models.CharField( + max_length=20, + choices=MadhhabChoices.choices, + default=MadhhabChoices.UNKNOWN, + verbose_name=_('Madhhab/School of Thought') + ) + + # Presence in Famous Collections + in_sahih_muslim = models.BooleanField( + default=False, + verbose_name=_('In Sahih Muslim'), + help_text=_('Is this narrator present in Sahih Muslim?') + ) + in_sahih_bukhari = models.BooleanField( + default=False, + verbose_name=_('In Sahih Bukhari'), + help_text=_('Is this narrator present in Sahih Bukhari?') + ) + + # Additional Information description = models.TextField(blank=True, null=True, verbose_name=_("Description")) thumbnail = FilerImageField( related_name="+", @@ -18,6 +96,7 @@ class Transmitters(models.Model): null=True, blank=True ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) @@ -32,6 +111,10 @@ class Transmitters(models.Model): class HadisTransmitter(models.Model): + class ReliabilityStatus(models.TextChoices): + RELIABLE = 'reliable', _('Reliable') + WEAK = 'weak', _('Weak') + UNKNOWN = 'unknown', _('Unknown') hadis = models.ForeignKey( "hadis.Hadis", @@ -43,29 +126,29 @@ class HadisTransmitter(models.Model): Transmitters, on_delete=models.CASCADE, verbose_name=_('transmitter'), - related_name='hadises', - null=True, - blank=True, - help_text=_('Leave empty if this represents a gap in the chain') + related_name='hadises' ) - status = models.ForeignKey( - "hadis.HadisStatus", + narrator_layer = models.ForeignKey( + NarratorLayer, on_delete=models.SET_NULL, - verbose_name=_('status'), + verbose_name=_('narrator layer'), related_name='transmitters', null=True, - blank=True + blank=True, + help_text=_('The layer/class (Tabaqah) this narrator belongs to') + ) + status = models.CharField( + max_length=20, + choices=ReliabilityStatus.choices, + default=ReliabilityStatus.UNKNOWN, + verbose_name=_('reliability status'), + help_text=_('Reliability status of the narrator') ) order = models.PositiveIntegerField( default=0, verbose_name=_('Order'), help_text=_('Order in the chain of transmission') ) - is_gap = models.BooleanField( - default=False, - verbose_name=_('Is Gap'), - help_text=_('Check this if this represents a gap in the transmission chain') - ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) @@ -76,4 +159,49 @@ class HadisTransmitter(models.Model): unique_together = ('hadis', 'transmitter', 'order') def __str__(self): - return f'{self.hadis.number} - {self.transmitter.full_name if self.transmitter else "Gap"} ({self.order})' + layer_info = f" - {self.narrator_layer}" if self.narrator_layer else "" + return f'{self.hadis.number} - {self.transmitter.full_name} ({self.order}){layer_info}' + + +class TransmitterOpinion(models.Model): + """ + Model for scholarly opinions about transmitters + """ + class OpinionStatus(models.TextChoices): + CONFIRMED = 'confirmed', _('Confirmed') + MIXED = 'mixed', _('Mixed') + REJECTED = 'rejected', _('Rejected') + + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='opinions' + ) + scholar_name = models.CharField( + max_length=255, + verbose_name=_('Scholar Name'), + help_text=_('Name of the scholar who gave this opinion') + ) + opinion_text = models.TextField( + verbose_name=_('Opinion Text'), + help_text=_('The scholar\'s opinion about this transmitter') + ) + status = models.CharField( + max_length=20, + choices=OpinionStatus.choices, + default=OpinionStatus.CONFIRMED, + verbose_name=_('Opinion Status'), + help_text=_('Status of the opinion') + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + verbose_name = _('Transmitter Opinion') + verbose_name_plural = _('Transmitter Opinions') + ordering = ('-created_at',) + + def __str__(self): + return f"{self.scholar_name}'s opinion on {self.transmitter.full_name} ({self.status})" diff --git a/apps/hadis/serializers/category.py b/apps/hadis/serializers/category.py index e186dc6..bb8c70b 100644 --- a/apps/hadis/serializers/category.py +++ b/apps/hadis/serializers/category.py @@ -6,22 +6,30 @@ from ..models import HadisSect, HadisCategory, Hadis class HadisCategorySectListSerializer(serializers.ModelSerializer): """Serializer for HadisSect list with grouped response""" - + source_types = serializers.SerializerMethodField() + class Meta: model = HadisSect - fields = ['id', 'title'] + fields = ['id', 'title', 'description', 'source_types'] + + def get_source_types(self, obj): + """Get unique source types for this sect's categories""" + source_types = HadisCategory.objects.filter( + sect=obj + ).values_list('source_type', flat=True).distinct() + return list(source_types) - def to_representation(self, instance): - # This method will be overridden in the view to create the grouped response - return super().to_representation(instance) class HadisCategoryTreeSerializer(serializers.ModelSerializer): """Serializer for HadisCategory tree structure""" + sect_id = serializers.IntegerField(source='sect.id', read_only=True) + sect_type = serializers.CharField(source='sect.sect_type', read_only=True) + children = serializers.SerializerMethodField() class Meta: model = HadisCategory - fields = ['id', 'title', 'source_type'] + fields = ['id', 'title', 'source_type', 'sect_id', 'sect_type', 'children'] def get_name(self, obj): """Get category name based on request language""" @@ -79,10 +87,27 @@ class HadisCategoryTreeSerializer(serializers.ModelSerializer): """Check if category has xmind file""" return bool(obj.xmind_file) + def get_thumbnail(self, obj): + """Get absolute URL for thumbnail""" + if hasattr(obj, 'thumbnail') and obj.thumbnail: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.thumbnail.url) + return obj.thumbnail.url + return None + + def get_hadis_index(self, obj): + """Get list of hadis numbers in this category (not including children)""" + return list( + Hadis.objects.filter( + category=obj, + status=True + ).order_by('number').values_list('number', flat=True) + ) + def to_dict(self, c): """Convert category to dictionary""" children = c.get_children().filter(sect=c.sect).order_by('order') - # Filter children that have either children or hadis filtered_children = [] for child in children: has_children = child.get_children().filter(sect=c.sect).exists() @@ -93,9 +118,12 @@ class HadisCategoryTreeSerializer(serializers.ModelSerializer): return { 'id': c.id, 'name': self.get_name(c), + 'description': c.description, 'hadis_count': self.get_hadis_count(c), 'has_hadis': self.get_has_hadis(c), + 'hadis_index': self.get_hadis_index(c) if self.get_has_hadis(c) else [], 'order': c.order, + 'thumbnail': self.get_thumbnail(c), 'xmind_file': self.get_xmind_file(c), 'has_xmind_file': self.get_has_xmind_file(c), 'children': [] if not filtered_children else [self.to_dict(i) for i in filtered_children], diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index db8a1ad..a34c6f6 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -3,11 +3,68 @@ from django.utils.translation import gettext_lazy as _ from ..models import ( Hadis, HadisStatus, HadisTag, HadisTransmitter, - HadisReference, ReferenceImage, Transmitters + HadisReference, ReferenceImage, Transmitters, HadisCollection ) from apps.library.serializers import BookSerializer +class HadisCollectionListSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = HadisCollection + fields = ['id', 'title', 'slug', 'thumbnail'] + + def get_thumbnail(self, obj): + if obj.thumbnail: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.thumbnail.url) + return obj.thumbnail.url + return None + + +class HadisSyncSerializer(serializers.ModelSerializer): + """Serializer for syncing all hadis data""" + translations = serializers.SerializerMethodField() + hadis_status = serializers.SerializerMethodField() + tags = serializers.SerializerMethodField() + + class Meta: + model = Hadis + fields = [ + 'id', 'number', 'category_id', 'title', 'title_narrator', + 'text', 'translations', 'explanation', 'address', + 'hadis_status', 'hadis_status_text', 'share_link', 'tags', 'links' + ] + + def get_translations(self, obj): + """Get all translations""" + translations_dict = {} + if obj.translation and isinstance(obj.translation, list): + for tr in obj.translation: + if isinstance(tr, dict): + lang_code = tr.get('language_code') + title = tr.get('title') + if lang_code: + translations_dict[lang_code] = title + return translations_dict + + def get_hadis_status(self, obj): + """Get hadis status info""" + if obj.hadis_status: + return { + 'id': obj.hadis_status.id, + 'title': obj.hadis_status.title, + 'color': obj.hadis_status.color + } + return None + + def get_tags(self, obj): + """Get tags""" + return [{'id': tag.id, 'title': tag.title} for tag in obj.tags.all()] + + class HadisListSerializer(serializers.ModelSerializer): """Serializer for Hadis list""" category = serializers.SerializerMethodField() diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 715d5c9..fc77a38 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -1,16 +1,15 @@ from django.urls import path -from .views.category import HadisSectListView, HadisCategoryTreeView -from .views.hadis import HadisListView, HadisDetailView +from .views.category import HadisCategorySectListView, HadisCategoryTreeView +from .views.hadis import HadisCollectionListView, HadisListView, HadisDetailView, HadisSyncView +from .views.info import HadisInfoView urlpatterns = [ - # Hadis Sect endpoints - path('categories/', HadisSectListView.as_view(), name='hadis-sect-list'), - - # Hadis Category endpoints - path('categories//', HadisCategoryTreeView.as_view(), name='hadis-category-tree'), - - # Hadis endpoints - path('/hadis/', HadisListView.as_view(), name='hadis-list'), - path('hadis//', HadisDetailView.as_view(), name='hadis-detail'), + path('collections/', HadisCollectionListView.as_view(), name='hadis-collection-list'), + path('sync/sects/', HadisCategorySectListView.as_view(), name='hadis-sect-list'), + path('sync/categories/tree/', HadisCategoryTreeView.as_view(), name='hadis-category-tree'), + path('sync/hadis/', HadisSyncView.as_view(), name='hadis-sync'), + path('info/', HadisInfoView.as_view(), name='hadis-info'), + path('category//', HadisListView.as_view(), name='hadis-list'), + path('/', HadisDetailView.as_view(), name='hadis-detail'), ] \ No newline at end of file diff --git a/apps/hadis/views/__init__.py b/apps/hadis/views/__init__.py index b239bfe..ea3b563 100644 --- a/apps/hadis/views/__init__.py +++ b/apps/hadis/views/__init__.py @@ -1,3 +1,3 @@ from .category import * from .hadis import * -# from .transmitter import * \ No newline at end of file +from .info import * \ No newline at end of file diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py index 7caf4dd..64b918d 100644 --- a/apps/hadis/views/category.py +++ b/apps/hadis/views/category.py @@ -8,7 +8,7 @@ from ..serializers import HadisCategorySectListSerializer, HadisCategoryTreeSeri from ..docs import hadis_sect_list_swagger, hadis_category_tree_swagger -class HadisSectListView(ListAPIView): +class HadisCategorySectListView(ListAPIView): """ API view to list all HadisSects grouped by sect_type (shia/sunni) """ @@ -35,7 +35,6 @@ class HadisSectListView(ListAPIView): sect_data = { 'id': sect.id, 'title': sect.title, - 'seo_field': None } if sect.sect_type == HadisSect.SectType.SHIA: @@ -54,8 +53,8 @@ class HadisSectListView(ListAPIView): class HadisCategoryTreeView(ListAPIView): """ - API view to get HadisCategory tree structure by sect_id - Returns categories grouped by source_type (quran/hadith) + API view to get all HadisCategory tree structure grouped by sect and source_type + Returns all categories in a tree structure """ serializer_class = HadisCategoryTreeSerializer pagination_class = NoPagination @@ -65,52 +64,62 @@ class HadisCategoryTreeView(ListAPIView): return self.list(request, *args, **kwargs) def get_queryset(self): - sect_id = self.kwargs.get('sect_id') - sect = get_object_or_404(HadisSect, id=sect_id, is_active=True) - - # Get root categories (no parent) for this sect return HadisCategory.objects.filter( - sect=sect, - parent__isnull=True - ).order_by('order') + parent__isnull=True, + sect__is_active=True + ).order_by('sect__order', 'order') def list(self, request, *args, **kwargs): queryset = self.get_queryset() - # Group categories by source_type - grouped_data = { - 'quran': [], - 'hadith': [] - } + grouped_data = {} - # Create serializer instance for to_dict method serializer_instance = HadisCategoryTreeSerializer(context={'request': request}) + # گروه‌بندی بر اساس sect for category in queryset: + sect_id = str(category.sect.id) + + if sect_id not in grouped_data: + # اضافه کردن اطلاعات sect + grouped_data[sect_id] = { + 'sect_info': { + 'id': category.sect.id, + 'sect_type': category.sect.sect_type, + 'title': category.sect.title, + 'description': category.sect.description + }, + 'source_types': [], + 'categories': {} + } + + # اضافه کردن source_type به لیست + if category.source_type not in grouped_data[sect_id]['source_types']: + grouped_data[sect_id]['source_types'].append(category.source_type) + + # گروه‌بندی categories بر اساس source_type + if category.source_type not in grouped_data[sect_id]['categories']: + grouped_data[sect_id]['categories'][category.source_type] = [] + category_data = serializer_instance.to_dict(category) + grouped_data[sect_id]['categories'][category.source_type].append(category_data) - if category.source_type == HadisCategory.SourceType.QURAN: - grouped_data['quran'].append(category_data) - elif category.source_type == HadisCategory.SourceType.HADITH: - grouped_data['hadith'].append(category_data) - - # Calculate total count including all descendants recursively - def count_objects_recursive(data_list): - """Count all objects including children recursively""" + def count_children(children_list): count = 0 - for item in data_list: - count += 1 # Count current item + for item in children_list: + count += 1 if 'children' in item and item['children']: - count += count_objects_recursive(item['children']) # Count children recursively + count += count_children(item['children']) return count - # Calculate total count from grouped data - total_count = ( - count_objects_recursive(grouped_data['quran']) + - count_objects_recursive(grouped_data['hadith']) - ) + total_count = 0 + for sect_data in grouped_data.values(): + for source_categories in sect_data['categories'].values(): + for item in source_categories: + total_count += 1 + if 'children' in item and item['children']: + total_count += count_children(item['children']) - # Create response with count and results response_data = { 'count': total_count, 'results': grouped_data diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 9d8bffe..ecca852 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -1,11 +1,54 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView from django.shortcuts import get_object_or_404 +from utils.pagination import NoPagination +from rest_framework.response import Response -from ..models import HadisCategory, Hadis -from ..serializers import HadisListSerializer, HadisDetailSerializer +from ..models import HadisCategory, Hadis, HadisCollection +from ..serializers import HadisListSerializer, HadisDetailSerializer, HadisCollectionListSerializer, HadisSyncSerializer from ..docs import hadis_list_swagger, hadis_detail_swagger +class HadisCollectionListView(ListAPIView): + """ + API view to list all hadis collections + """ + queryset = HadisCollection.objects.filter(status=True).order_by('order') + serializer_class = HadisCollectionListSerializer + pagination_class = NoPagination + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class HadisSyncView(ListAPIView): + """ + API view to sync all hadis data for offline mode + """ + serializer_class = HadisSyncSerializer + pagination_class = NoPagination + + def get_queryset(self): + return Hadis.objects.filter(status=True).select_related( + 'category', 'hadis_status' + ).prefetch_related('tags').order_by('id') + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + + grouped_data = {} + for hadis_data in serializer.data: + hadis_id = str(hadis_data['id']) + grouped_data[hadis_id] = hadis_data + + response_data = { + 'count': queryset.count(), + 'results': grouped_data + } + + return Response(response_data) + + class HadisListView(ListAPIView): """ API view to list Hadis by category_id diff --git a/apps/hadis/views/info.py b/apps/hadis/views/info.py new file mode 100644 index 0000000..64616db --- /dev/null +++ b/apps/hadis/views/info.py @@ -0,0 +1,30 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from ..models import HadisSect, BookReference, Transmitters +from apps.bookmark.models import Bookmark + + +class HadisInfoView(APIView): + """ + API view to get hadis statistics + """ + + def get(self, request, *args, **kwargs): + category_count = HadisSect.objects.filter(is_active=True).count() + reference_count = BookReference.objects.count() + bookmark_count = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.HADITH, + status=True + ).count() + narrator_count = Transmitters.objects.count() + + data = { + 'category_count': category_count, + 'reference_count': reference_count, + 'bookmark_count': bookmark_count, + 'narrator_count': narrator_count + } + + return Response(data, status=status.HTTP_200_OK)