diff --git a/apps/hadis/doc.py b/apps/hadis/doc.py index 6976da7..c2dda6f 100644 --- a/apps/hadis/doc.py +++ b/apps/hadis/doc.py @@ -115,6 +115,276 @@ hadis_list_response = openapi.Response( ) ) +# Reference image schema +reference_image_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the reference image" + ), + 'thumbnail': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the thumbnail image", + nullable=True + ), + 'priority': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Priority of the image (lower values mean higher priority)" + ) + }, + required=['id', 'priority'] +) + +# Hadis reference schema +hadis_reference_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis reference" + ), + 'book': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the referenced book", + nullable=True + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the reference", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'images': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=reference_image_schema, + description="List of reference images" + ) + }, + required=['id', 'created_at', 'images'] +) + +# Hadis overview schema +hadis_overview_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema( + type=openapi.TYPE_STRING, + description="Status of the hadis" + ), + 'status_color': openapi.Schema( + type=openapi.TYPE_STRING, + description="Display color for the status" + ), + 'status_text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Descriptive text for the status", + nullable=True + ), + 'address': openapi.Schema( + type=openapi.TYPE_STRING, + description="Address information", + nullable=True + ), + 'links': openapi.Schema( + type=openapi.TYPE_OBJECT, + description="Related links" + ), + 'tags': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=tag_schema, + description="List of tags associated with this hadis" + ), + 'share_link': openapi.Schema( + type=openapi.TYPE_STRING, + description="Link for sharing the hadis", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['status', 'status_color', 'tags', 'created_at'] +) + +# Hadis detail schema +hadis_detail_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis" + ), + 'number': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique number identifier for the hadis" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the hadis" + ), + 'text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Original text of the hadis" + ), + 'translation': openapi.Schema( + type=openapi.TYPE_STRING, + description="Translation of the hadis text" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Visibility status of the hadis" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'updated_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Last update timestamp" + ), + 'overview': hadis_overview_schema, + 'first_reference': hadis_reference_schema + }, + required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview'] +) + +hadis_detail_response = openapi.Response( + description="Detailed information about a specific hadis", + schema=hadis_detail_schema +) + +# Transmitter schema +transmitter_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the transmitter" + ), + 'full_name': openapi.Schema( + type=openapi.TYPE_STRING, + description="Full name of the transmitter" + ), + 'birth_year_hijri': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Birth year in Hijri calendar" + ), + 'death_year_hijri': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Death year in Hijri calendar" + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the transmitter", + nullable=True + ), + 'status': openapi.Schema( + type=openapi.TYPE_STRING, + description="Status of the transmitter" + ), + 'status_color': openapi.Schema( + type=openapi.TYPE_STRING, + description="Display color for the status" + ), + 'thumbnail': openapi.Schema( + type=openapi.TYPE_OBJECT, + description="Thumbnail image information", + nullable=True + ) + }, + required=['id', 'full_name', 'birth_year_hijri', 'death_year_hijri', 'status', 'status_color'] +) + +# Hadis transmitter schema +hadis_transmitter_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis transmitter relation" + ), + 'transmitter': transmitter_schema, + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the transmitter's role in this hadis", + nullable=True + ), + 'order': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Order in the chain of transmission" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['id', 'transmitter', 'order', 'created_at'] +) + +# Update hadis detail schema to include transmitters +hadis_detail_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis" + ), + 'number': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique number identifier for the hadis" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the hadis" + ), + 'text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Original text of the hadis" + ), + 'translation': openapi.Schema( + type=openapi.TYPE_STRING, + description="Translation of the hadis text" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Visibility status of the hadis" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'updated_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Last update timestamp" + ), + 'overview': hadis_overview_schema, + 'first_reference': hadis_reference_schema, + 'transmitters': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=hadis_transmitter_schema, + description="List of transmitters for this hadis" + ) + }, + required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview'] +) + +hadis_detail_response = openapi.Response( + description="Detailed information about a specific hadis", + schema=hadis_detail_schema +) + # Swagger decorators for views category_list_swagger = swagger_auto_schema( operation_id="list_hadis_categories", @@ -156,4 +426,27 @@ category_hadis_list_swagger = swagger_auto_schema( 404: "The specified category does not exist.", 500: "Internal server error occurred." } +) + +hadis_detail_swagger = swagger_auto_schema( + operation_id="get_hadis_detail", + operation_description=""" + Retrieve detailed information about a specific hadis. + + This endpoint returns comprehensive information about a hadis, including: + - Basic hadis details (number, title, text, translation) + - HadisOverview information (status, tags, etc.) + - The first HadisReference with its ReferenceImages + - List of Transmitters in order of transmission chain + + The hadis is specified by its ID in the URL path. + """, + operation_summary="Get Hadis Detail", + tags=["Hadis"], + responses={ + 200: hadis_detail_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified hadis does not exist.", + 500: "Internal server error occurred." + } ) \ No newline at end of file diff --git a/apps/hadis/migrations/0007_auto_20250321_2007.py b/apps/hadis/migrations/0007_auto_20250321_2007.py new file mode 100644 index 0000000..5e4710f --- /dev/null +++ b/apps/hadis/migrations/0007_auto_20250321_2007.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.7 on 2025-03-21 20:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0004_remove_category_books'), + ('hadis', '0006_auto_20250321_1600'), + ] + + operations = [ + migrations.AddField( + model_name='hadisoverview', + name='explanation', + field=models.TextField(blank=True, null=True, verbose_name='explanation'), + ), + migrations.AddField( + model_name='hadistransmitter', + name='description', + field=models.TextField(blank=True, null=True, verbose_name='description'), + ), + migrations.AlterUniqueTogether( + name='hadisreference', + unique_together={('hadis', 'book')}, + ), + ] diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index 91c8523..cbe94d9 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -49,9 +49,10 @@ class HadisOverview(models.Model): links = models.JSONField(verbose_name=_('title'), null=True, blank=True, default=dict) tags = models.ManyToManyField("HadisTag", related_name="hadises", verbose_name=_('tags'), blank=True) share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + explanation = models.TextField(verbose_name=_('explanation'), null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) - + class HadisReference(models.Model): hadis = models.ForeignKey( @@ -67,7 +68,8 @@ class HadisReference(models.Model): class Meta: verbose_name = _('Hadis Reference') verbose_name_plural = _('Hadis References') - + unique_together = ('hadis', 'book') + def __str__(self): return f'{self.hadis.number}-{self.book.title}' diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 08d7e48..5ec2e0b 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -35,6 +35,7 @@ class HadisTransmitter(models.Model): verbose_name=_('transmitter'), related_name='hadises' ) + description = models.TextField(verbose_name=_('description'), blank=True, null=True) order = models.PositiveIntegerField( default=0, verbose_name=_('Order'), diff --git a/apps/hadis/serializers.py b/apps/hadis/serializers.py index 3ee2628..d7dd9d0 100644 --- a/apps/hadis/serializers.py +++ b/apps/hadis/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers - +from dj_filer.admin import get_thumbs from apps.hadis.models import * @@ -38,13 +38,90 @@ class HadisTagSerializer(serializers.ModelSerializer): class HadisSerializer(serializers.ModelSerializer): - translation = serializers.CharField(source='translation') - text = serializers.CharField(source='text') + class Meta: + model = Hadis + fields = ('number', 'title', 'text', 'translation',) + + +class ReferenceImageSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = ReferenceImage + fields = ('id', 'thumbnail', 'priority') + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class HadisReferenceSerializer(serializers.ModelSerializer): + images = serializers.SerializerMethodField() + + class Meta: + model = HadisReference + fields = ('id', 'book', 'description', 'created_at', 'images') + + def get_images(self, obj): + return ReferenceImageSerializer( + obj.referenceimage_set.all(), + many=True, + context=self.context + ).data + + +class TransmittersSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Transmitters + fields = ('id', 'full_name', 'birth_year_hijri', 'death_year_hijri', + 'description', 'status', 'status_color', 'thumbnail') + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class HadisTransmitterSerializer(serializers.ModelSerializer): + transmitter = serializers.SerializerMethodField() + + class Meta: + model = HadisTransmitter + fields = ('id', 'transmitter', 'description', 'order', 'created_at') + + def get_transmitter(self, obj): + return TransmittersSerializer( + obj.transmitter, + context=self.context + ).data + + +class HadisOverviewSerializer(serializers.ModelSerializer): tags = serializers.SerializerMethodField() + class Meta: + model = HadisOverview + fields = ('status', 'status_color', 'status_text', 'address', 'links', 'tags', 'share_link', 'explanation', 'created_at') + def get_tags(self, obj): - return HadisTagSerializer(obj.get_tags, many=True).data + return HadisTagSerializer( + obj.tags.all(), + many=True, + context=self.context + ).data + + +class HadisDetailSerializer(serializers.ModelSerializer): + overview = HadisOverviewSerializer(source='hadisoverview', read_only=True) + reference = serializers.SerializerMethodField() + transmitters = HadisTransmitterSerializer(many=True, read_only=True) class Meta: model = Hadis - fields = ('number', 'title', 'text', 'translation', 'tags') \ No newline at end of file + fields = ('id', 'number', 'title', 'text', 'translation', 'status', + 'created_at', 'updated_at', 'overview', 'reference', 'transmitters') + + def get_reference(self, obj): + reference = obj.references.first() + if reference: + return HadisReferenceSerializer(reference, context=self.context).data + return None \ No newline at end of file diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 306b2eb..bf75802 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -6,4 +6,7 @@ urlpatterns = [ path('categories/', views.CategoryListView.as_view(), name='category-list'), path('categories//hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'), + path('/', views.HadisDetailView.as_view(), name='hadis-detail'), + + ] \ No newline at end of file diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py index 9722e35..f0b285e 100644 --- a/apps/hadis/views/hadis.py +++ b/apps/hadis/views/hadis.py @@ -1,12 +1,13 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch -from rest_framework.generics import ListAPIView +from rest_framework.generics import ListAPIView, RetrieveAPIView +from django.shortcuts import get_object_or_404 from apps.hadis.models import * from apps.hadis.serializers import * -from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger +from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger, hadis_detail_swagger @@ -24,6 +25,51 @@ class CategoryHadisListView(ListAPIView): status=True, ).prefetch_related( 'category', - 'tags', ) + +class HadisDetailView(RetrieveAPIView): + """ + API endpoint to retrieve detailed information about a specific hadis. + + Returns: + - Hadis details (number, title, text, translation) + - HadisOverview information (status, tags, etc.) + - First HadisReference with its ReferenceImages + - List of Transmitters + """ + serializer_class = HadisDetailSerializer + permission_classes = (IsAuthenticated,) + + @hadis_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_object(self): + hadis_id = self.kwargs.get('pk') + queryset = Hadis.objects.filter(id=hadis_id) + + # Prefetch related data to optimize queries + queryset = queryset.prefetch_related( + 'hadisoverview', + 'hadisoverview__tags', + Prefetch( + 'references', + queryset=HadisReference.objects.prefetch_related( + 'referenceimage_set', + 'book' + ) + ), + Prefetch( + 'transmitters', + queryset=HadisTransmitter.objects.select_related('transmitter').order_by('order') + ) + ) + + return get_object_or_404(queryset, id=hadis_id) + + def get_serializer_context(self): + context = super().get_serializer_context() + context.update({'request': self.request}) + return context + diff --git a/apps/library/admin.py b/apps/library/admin.py index bcf9a89..52e1380 100644 --- a/apps/library/admin.py +++ b/apps/library/admin.py @@ -180,11 +180,11 @@ class CategoryAdmin(AjaxDatatable): list_display = ('title', 'slug', 'status', 'count_books', 'created_at') list_filter = ('status', 'created_at', 'updated_at') search_fields = ('title', 'slug') - autocomplete_fields = ('books',) + # autocomplete_fields = ('books',) @admin.display(description=_('Number of Books')) def count_books(self, obj): - count = obj.books.count() + count = obj.books_count if count > 0: url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}' return format_html('{}', url, count) diff --git a/apps/library/doc.py b/apps/library/doc.py new file mode 100644 index 0000000..0c932a8 --- /dev/null +++ b/apps/library/doc.py @@ -0,0 +1,220 @@ +""" +Swagger documentation for the Library API endpoints. + +This module provides Swagger documentation for the Library API endpoints using drf-yasg. +It defines the request parameters, response schemas, and decorators for the views. +""" + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema + +# Parameter definitions +collection_id_param = openapi.Parameter( + 'collection_id', + openapi.IN_QUERY, + description="Filter books by collection ID", + type=openapi.TYPE_INTEGER, + required=False +) + +middle_param = openapi.Parameter( + 'middle', + openapi.IN_QUERY, + description="Filter books by middle section collection (any value will trigger the filter)", + type=openapi.TYPE_STRING, + required=False +) + +bottom_param = openapi.Parameter( + 'bottom', + openapi.IN_QUERY, + description="Filter books by bottom section collection (any value will trigger the filter)", + type=openapi.TYPE_STRING, + required=False +) + +search_param = openapi.Parameter( + 'search', + openapi.IN_QUERY, + description="Search books by title, summary, or author", + type=openapi.TYPE_STRING, + required=False +) + +# Response schemas +book_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the book" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the book" + ), + 'slug': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL-friendly slug for the book" + ), + 'summary': openapi.Schema( + type=openapi.TYPE_STRING, + description="Brief summary of the book" + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Detailed description of the book" + ), + 'thumbnail_url': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL to the book's thumbnail image", + nullable=True + ), + 'author': openapi.Schema( + type=openapi.TYPE_STRING, + description="Author of the book" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Whether the book is active/visible" + ), + 'pin': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Whether the book is pinned to the top" + ), + 'view_count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Number of views for the book" + ), + 'download_count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Number of downloads for the book" + ), + 'file_type': openapi.Schema( + type=openapi.TYPE_STRING, + description="Type of the book file (PDF, EPUB, etc.)" + ), + 'book_file': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL to the book file", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['id', 'title', 'slug', 'status', 'created_at'] +) + +books_response = openapi.Response( + description="List of books with pagination", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'count': openapi.Schema(type=openapi.TYPE_INTEGER), + 'next': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'previous': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'results': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=book_schema + ) + } + ) +) + +# Book detail response +book_detail_response = openapi.Response( + description="Detailed information about a specific book", + schema=book_schema +) + +# Swagger decorators for views +book_detail_swagger = swagger_auto_schema( + operation_id="get_book_detail", + operation_description=""" + Retrieve detailed information about a specific book. + + This endpoint returns comprehensive information about a book, including: + - Basic book details (title, slug, summary, description) + - Thumbnail image URL + - Author information + - Status and pin information + - View and download counts + - File type and book file URL + - Creation and update timestamps + - Categories and collections the book belongs to + - Number of pages + + The book is specified by its ID in the URL path. + """, + operation_summary="Get Book Detail", + tags=["Library"], + responses={ + 200: book_detail_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified book does not exist.", + 500: "Internal server error occurred." + } +) + +book_list_swagger = swagger_auto_schema( + operation_id="list_books", + operation_description=""" + Retrieve a list of books with filtering and search capabilities. + + This endpoint returns a paginated list of books. Each book includes its title, slug, + summary, description, thumbnail, author, status, pin, view count, download count, + file type, book file URL, and creation timestamp. + + You can filter books by: + - Collection ID using the query parameter 'collection_id' + - Middle section collection using the query parameter 'middle' + - Bottom section collection using the query parameter 'bottom' + + You can also search for books by title, summary, or author using the query parameter 'search'. + """, + operation_summary="List Books", + tags=["Library"], + manual_parameters=[collection_id_param, middle_param, bottom_param, search_param], + responses={ + 200: books_response, + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +category_list_swagger = swagger_auto_schema( + operation_id="list_categories", + operation_description=""" + Retrieve a list of book categories. + + This endpoint returns a paginated list of book categories. Each category includes its + title, slug, status, books count, and timestamps. + """, + operation_summary="List Book Categories", + tags=["Library"], + responses={ + 200: "List of book categories", + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +pinned_collection_list_swagger = swagger_auto_schema( + operation_id="list_pinned_collections", + operation_description=""" + Retrieve a list of pinned book collections with their top book covers. + + This endpoint returns a list of pinned book collections. Each collection includes its + title and the covers of its top books by view count. + """, + operation_summary="List Pinned Book Collections", + tags=["Library"], + responses={ + 200: "List of pinned book collections with covers", + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) \ No newline at end of file diff --git a/apps/library/migrations/0004_remove_category_books.py b/apps/library/migrations/0004_remove_category_books.py new file mode 100644 index 0000000..54c286b --- /dev/null +++ b/apps/library/migrations/0004_remove_category_books.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.7 on 2025-03-21 20:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0003_auto_20250321_0119'), + ] + + operations = [ + migrations.RemoveField( + model_name='category', + name='books', + ), + ] diff --git a/apps/library/models.py b/apps/library/models.py index d6fa0f8..0a16b22 100644 --- a/apps/library/models.py +++ b/apps/library/models.py @@ -65,7 +65,7 @@ class Category(models.Model): title = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) status = models.BooleanField(default=True, verbose_name=_('status')) - books = models.ManyToManyField('library.Book', related_name='related_categories_books',through="library.Book_categories" ,verbose_name=_('Books'), blank=True) + # books = models.ManyToManyField('library.Book', related_name='related_categories_books',through="library.Book_categories" ,verbose_name=_('Books'), 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')) @@ -76,7 +76,7 @@ class Category(models.Model): @property def books_count(self): """Return the number of books in this category""" - return self.books.count() + return self.related_categories.count() class Meta: verbose_name = _('Category') diff --git a/apps/library/serializers.py b/apps/library/serializers.py index 8676cbf..016a941 100644 --- a/apps/library/serializers.py +++ b/apps/library/serializers.py @@ -2,32 +2,56 @@ from dj_filer.admin import get_thumbs -from django.db.models import Avg +from django.db.models import Avg, Q from rest_framework import serializers from apps.library.models import * -class BannerListSerializer(serializers.ModelSerializer): - description = serializers.CharField(source='summary') - title = serializers.SerializerMethodField() - covers = serializers.SerializerMethodField() - def get_title(self, obj): - return obj.title + +class CategorySerializer(serializers.ModelSerializer): + books_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Category + fields = ('id', 'title', 'slug', 'status', 'books_count', 'created_at', 'updated_at') + + +class PinnedBookCollectionSerializer(serializers.ModelSerializer): + covers = serializers.SerializerMethodField() def get_covers(self, obj: BookCollection): - books = obj.get_books().order_by('-view_count')[:3] + books = obj.books.all().order_by('-view_count')[:3] images = [] for book in books: - url = get_thumbs(book.thumbnail, self.context.get('request')) - if url.get('md'): - images.append(url['md']) + if book.thumbnail: + url = get_thumbs(book.thumbnail, self.context.get('request')) + if url.get('md'): + images.append(url['md']) return images class Meta: model = BookCollection - fields = ('id', 'title', 'summary', 'covers') - + fields = ('id', 'title', 'covers') + + +class BookSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + def get_thumbnail(self, obj): + if obj.thumbnail: + return get_thumbs(obj.thumbnail, self.context.get('request')) + return None + + class Meta: + model = Book + fields = ( + 'id', 'title', 'slug', 'summary', 'description', 'thumbnail', + 'author', 'status', 'pin', 'view_count', 'download_count', + 'file_type', 'book_file', 'created_at' + ) + + diff --git a/apps/library/urls.py b/apps/library/urls.py new file mode 100644 index 0000000..5eeb026 --- /dev/null +++ b/apps/library/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from apps.library.views import ( + CategoryListView, + PinnedBookCollectionListView, + BookListView, + BookDetailView, +) + +urlpatterns = [ + path('categories/', CategoryListView.as_view(), name='category-list'), + path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), + path('books/', BookListView.as_view(), name='book-list'), + path('books//', BookDetailView.as_view(), name='book-detail'), +] \ No newline at end of file diff --git a/apps/library/views.py b/apps/library/views.py index c000528..688037a 100644 --- a/apps/library/views.py +++ b/apps/library/views.py @@ -1,22 +1,116 @@ +from django.db.models import Q, Count from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.generics import ListAPIView +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.filters import SearchFilter from apps.library.models import * from apps.library.serializers import * +from apps.library.doc import ( + book_list_swagger, + book_detail_swagger, + category_list_swagger, + pinned_collection_list_swagger +) -class BannerListView(ListAPIView): - serializer_class = BannerListSerializer +class CategoryListView(ListAPIView): + """ + API view to list all book categories + """ + serializer_class = CategorySerializer + permission_classes = (IsAuthenticated,) + + @category_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return Category.objects.filter( + status=True + ).annotate( + books_count=Count('related_categories') + ).order_by('title') + + +class PinnedBookCollectionListView(ListAPIView): + """ + API view to list pinned book collections with their top 3 book covers + """ + serializer_class = PinnedBookCollectionSerializer permission_classes = (IsAuthenticated,) pagination_class = None + @pinned_collection_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): - _query = Q(status=True, display_position=BookCollection.DisplayPosition.TOP) + return BookCollection.objects.filter( + status=True, + display_position=BookCollection.DisplayPosition.PINNED + ).order_by('-order', '-id') + + +class BookListView(ListAPIView): + """ + API view to list books with filtering and search capabilities + """ + serializer_class = BookSerializer + permission_classes = (IsAuthenticated,) + filter_backends = [SearchFilter] + search_fields = ['title', 'summary', 'author'] + + @book_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = Book.objects.filter(status=True) + + # Filter by collection if provided + collection_id = self.request.query_params.get('collection_id') + if collection_id: + queryset = queryset.filter(collections__id=collection_id) + + # Filter by middle collection if requested + # if self.request.query_params.get('middle'): + # middle_collections = BookCollection.objects.filter( + # status=True, + # display_position=BookCollection.DisplayPosition.MIDDLE + # ) + # if middle_collections.exists(): + # queryset = queryset.filter(collections__in=middle_collections) + + # Filter by bottom collection if requested + # if self.request.query_params.get('bottom'): + # bottom_collections = BookCollection.objects.filter( + # status=True, + # display_position=BookCollection.DisplayPosition.BOTTOM + # ) + # if bottom_collections.exists(): + # queryset = queryset.filter(collections__in=bottom_collections) + + return queryset.order_by('-pin', '-created_at') + + +class BookDetailView(RetrieveAPIView): + """ + API view to retrieve detailed information about a specific book + """ + serializer_class = BookSerializer + permission_classes = (IsAuthenticated,) + queryset = Book.objects.filter(status=True) + + @book_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) - return Collection.objects.filter( - _query, - ).order_by('-order', '-id', ) - + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + # Increment view count when book details are viewed + instance.increment_view_count() + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/apps/podcast/models.py b/apps/podcast/models.py index 8cc1594..4e4cefd 100644 --- a/apps/podcast/models.py +++ b/apps/podcast/models.py @@ -2,10 +2,27 @@ from django.db import models +class PodcastCategory(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.PositiveIntegerField(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')) + + def __str__(self): + return self.title + + class Meta: + verbose_name = _('Video Category') + verbose_name_plural = _('Video Categories') + ordering = ['order'] class PodcastCollection(models.Model): title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) videos = models.ManyToManyField( @@ -14,6 +31,7 @@ class PodcastCollection(models.Model): related_name='collections', verbose_name=_('podcasts'), ) + def __str__(self): return f'Collection #{self.id}/{self.title}' @@ -49,7 +67,7 @@ class Podcast(models.Model): 'image allowed' )) description = models.TextField(null=True) - + categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories')) audio_file = models.FileField(upload_to='podcast/audio/', null=True, blank=True) audio_url = models.CharField(max_length=655, null=True, blank=True) audio_time = models.TimeField() diff --git a/apps/video/admin.py b/apps/video/admin.py index 12a7364..5c23d54 100644 --- a/apps/video/admin.py +++ b/apps/video/admin.py @@ -1,23 +1,85 @@ from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.html import format_html from ajaxdatatable.admin import AjaxDatatable from apps.video.models import * - - class VideoInCollectionInline(admin.TabularInline): model = VideoInCollection - extra = 1 + extra = 1 + autocomplete_fields = ('video',) + ordering = ('priority',) -@admin.register(VideoCollection) -class VideoCollectionAdmin(AjaxDatatable): - list_display = ('title',) +class VideoCollectionAdminBase(AjaxDatatable): + """Base admin class for all video collection types""" + list_display = ('title', 'status', 'order', 'count_videos', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title',) inlines = [VideoInCollectionInline] + fieldsets = ( + (None, { + 'fields': ('title', 'status', 'order') + }), + ) + + + @admin.display(description=_('Number of Videos')) + def count_videos(self, obj): + count = obj.videos.count() + if count > 0: + url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}' + return format_html('{}', url, count) + return count + + +# @admin.register(VideoCollection) +# class VideoCollectionAdmin(VideoCollectionAdminBase): +# """Admin for all video collections""" +# list_display = ('title', 'status', 'count_videos', 'created_at') +# list_filter = ('status', 'created_at', 'updated_at') + + + +@admin.register(VideoCategory) +class VideoCategoryAdmin(AjaxDatatable): + list_display = ('title', 'slug', 'status', 'order', 'count_videos', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title', 'slug') + + + @admin.display(description=_('Number of Videos')) + def count_videos(self, obj): + count = obj.videos.count() + if count > 0: + url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}' + return format_html('{}', url, count) + return count + @admin.register(Video) class VideoAdmin(AjaxDatatable): - list_display = ('title', 'video_type', 'status') - search_fields = ('title',) + list_display = ('title', 'slug', 'video_type', 'status', 'view_count', 'created_at') + list_filter = ('status', 'video_type', 'created_at', 'updated_at') + search_fields = ('title', 'slug', 'description') + autocomplete_fields = ('categories',) + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories') + }), + (_('Video Information'), { + 'fields': ('video_type', 'video_file', 'video_url', 'video_time') + }), + (_('Status'), { + 'fields': ('status',) + }), + (_('Statistics'), { + 'fields': ('view_count',) + }), + ) + diff --git a/apps/video/migrations/0001_initial.py b/apps/video/migrations/0001_initial.py new file mode 100644 index 0000000..ae6bb1f --- /dev/null +++ b/apps/video/migrations/0001_initial.py @@ -0,0 +1,99 @@ +# Generated by Django 3.2.7 on 2025-03-21 22:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Video', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, null=True)), + ('slug', models.SlugField(allow_unicode=True, unique=True)), + ('description', models.TextField(null=True)), + ('video_type', models.CharField(choices=[('file', 'File'), ('youtube', 'Youtube')], default='file', max_length=255)), + ('video_file', models.FileField(blank=True, null=True, upload_to='video/videos/')), + ('video_url', models.CharField(blank=True, max_length=655, null=True)), + ('video_time', models.TimeField()), + ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('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': 'Video', + 'verbose_name_plural': 'Videos', + }, + ), + migrations.CreateModel( + name='VideoCategory', + 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(allow_unicode=True, unique=True, verbose_name='slug')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('order', models.PositiveIntegerField(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')), + ], + options={ + 'verbose_name': 'Video Category', + 'verbose_name_plural': 'Video Categories', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='VideoCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='This title will not be displayed anywhere', max_length=255)), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('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': 'Video Collection', + 'verbose_name_plural': 'Video Collections', + }, + ), + migrations.CreateModel( + name='VideoInCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections_videos', to='video.video', verbose_name='video')), + ('video_collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='videos_in_collection', to='video.videocollection', verbose_name='video collection')), + ], + options={ + 'verbose_name': 'Video in Collection', + 'verbose_name_plural': 'Videos in Collection', + 'ordering': ['priority'], + }, + ), + migrations.AddField( + model_name='videocollection', + name='videos', + field=models.ManyToManyField(related_name='collections', through='video.VideoInCollection', to='video.Video', verbose_name='videos'), + ), + migrations.AddField( + model_name='video', + name='categories', + field=models.ManyToManyField(blank=True, related_name='videos', to='video.VideoCategory', verbose_name='categories'), + ), + migrations.AddField( + model_name='video', + name='thumbnail', + field=filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL), + ), + ] diff --git a/apps/video/models.py b/apps/video/models.py index d168ffa..467351e 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -1,13 +1,33 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ +from filer.fields.image import FilerImageField +class VideoCategory(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.PositiveIntegerField(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')) + + def __str__(self): + return self.title + + class Meta: + verbose_name = _('Video Category') + verbose_name_plural = _('Video Categories') + ordering = ['order'] + class VideoCollection(models.Model): title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") + status = models.BooleanField(default=True, verbose_name=_('status')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) videos = models.ManyToManyField( - Video, + "Video", through='VideoInCollection', related_name='collections', verbose_name=_('videos'), @@ -17,15 +37,15 @@ class VideoCollection(models.Model): class Meta: verbose_name = _('Video Collection') - verbose_name_plural = _('Video Collections') + verbose_name_plural = _('Video Collections') class VideoInCollection(models.Model): video_collection = models.ForeignKey( - VideoCollection, on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection') + "VideoCollection", on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection') ) video = models.ForeignKey( - Video, on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video') + "Video", on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video') ) priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) @@ -42,21 +62,26 @@ class Video(models.Model): class vdeo_type(models.TextChoices): FILE = 'file' YOUTUBE = 'youtube' - + title = models.CharField(max_length=255, null=True) slug = models.SlugField(allow_unicode=True, unique=True) thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_( 'image allowed' )) description = models.TextField(null=True) + categories = models.ManyToManyField( + VideoCategory, + related_name='videos', + verbose_name=_('categories'), + blank=True, + ) video_type = models.CharField(max_length=255, choices=vdeo_type.choices, default=vdeo_type.FILE) video_file = models.FileField(upload_to='video/videos/', null=True, blank=True) video_url = models.CharField(max_length=655, null=True, blank=True) video_time = models.TimeField() - + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) - download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) - + status = models.BooleanField(default=True, verbose_name=_('status')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) @@ -64,8 +89,15 @@ class Video(models.Model): def __str__(self): return self.title - + + def increment_view_count(self): + """Increment the view count for this video""" + self.view_count += 1 + self.save(update_fields=['view_count']) + return self.view_count + + class Meta: - verbose_name = _('Video') - verbose_name_plural = _('Videos') + verbose_name = _('Video') + verbose_name_plural = _('Videos') diff --git a/apps/video/serializers.py b/apps/video/serializers.py new file mode 100644 index 0000000..85d95a9 --- /dev/null +++ b/apps/video/serializers.py @@ -0,0 +1,70 @@ +from rest_framework import serializers +from .models import VideoCategory, Video, VideoCollection, VideoInCollection + + +class VideoCategoryListSerializer(serializers.ModelSerializer): + video_count = serializers.SerializerMethodField() + + class Meta: + model = VideoCategory + fields = ['id', 'title', 'slug', 'video_count'] + + def get_video_count(self, obj): + return obj.videos.filter(status=True).count() + + +class VideoListSerializer(serializers.ModelSerializer): + categories = VideoCategoryListSerializer(many=True, read_only=True) + + class Meta: + model = Video + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_time', + 'view_count', 'categories', 'created_at'] + + + + + +class VideoDetailSerializer(serializers.ModelSerializer): + related_videos = serializers.SerializerMethodField() + categories = VideoCategoryListSerializer(many=True, read_only=True) + + class Meta: + model = Video + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', + 'video_file', 'video_url', 'video_time', 'view_count', + 'categories', 'created_at', 'related_videos'] + + + def get_related_videos(self, obj): + # Get all collections that contain this video + collections = obj.collections.all() + + if collections.exists(): + # Get all videos from all collections that contain this video + related_videos = [] + video_ids = set() # To track unique videos + + for collection in collections: + # Get all videos in this collection ordered by priority + videos_in_collection = VideoInCollection.objects.filter( + video_collection=collection + ).exclude(video=obj).order_by('priority') + + # Add videos to our list if not already added + for vic in videos_in_collection: + if vic.video.id not in video_ids: + related_videos.append(vic.video) + video_ids.add(vic.video.id) + + # Return the related videos using VideoListSerializer + return VideoListSerializer(related_videos, many=True).data + + # # If not in a collection, return videos from the same category + # elif obj.category: + # related = Video.objects.filter( + # category=obj.category, + # status=True + # ).exclude(id=obj.id)[:5] + # return VideoListSerializer(related, many=True).data + return [] \ No newline at end of file diff --git a/apps/video/urls.py b/apps/video/urls.py new file mode 100644 index 0000000..e42154f --- /dev/null +++ b/apps/video/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView + +app_name = 'video' + +urlpatterns = [ + path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'), + + path('list/', VideoListAPIView.as_view(), name='video-list'), + + path('detail//', VideoDetailAPIView.as_view(), name='video-detail'), +] \ No newline at end of file diff --git a/apps/video/views.py b/apps/video/views.py index 91ea44a..3e90c7e 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -1,3 +1,49 @@ -from django.shortcuts import render +from rest_framework import generics, status +from rest_framework.response import Response +from .models import VideoCategory, Video +from .serializers import VideoCategoryListSerializer, VideoListSerializer, VideoDetailSerializer -# Create your views here. + +class VideoCategoryListAPIView(generics.ListAPIView): + """ + API view to list all video categories with their video counts + """ + serializer_class = VideoCategoryListSerializer + + def get_queryset(self): + return VideoCategory.objects.filter(status=True).order_by('order') + + +class VideoListAPIView(generics.ListAPIView): + """ + API view to list all videos, with optional category filtering + """ + serializer_class = VideoListSerializer + + def get_queryset(self): + queryset = Video.objects.filter(status=True).order_by('-created_at') + + # Filter by category if provided + category_slug = self.request.query_params.get('category', None) + if category_slug: + queryset = queryset.filter(category__slug=category_slug) + + return queryset + + +class VideoDetailAPIView(generics.RetrieveAPIView): + """ + API view to get video details, including related videos from the same collection + """ + serializer_class = VideoDetailSerializer + lookup_field = 'slug' + + def get_queryset(self): + return Video.objects.filter(status=True) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + # Increment view count + instance.increment_view_count() + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/config/settings/base.py b/config/settings/base.py index 149610d..1aa2e08 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -49,6 +49,7 @@ LOCAL_APPS = [ 'apps.certificate.apps.CertificateConfig', 'apps.hadis.apps.HadisConfig', 'apps.library.apps.LibraryConfig', + 'apps.video.apps.VideoConfig', 'dynamic_preferences', ] diff --git a/config/urls.py b/config/urls.py index 41def22..713c339 100644 --- a/config/urls.py +++ b/config/urls.py @@ -34,17 +34,20 @@ from utils import absolute_url api_patterns = [ path('test/', include('apps.api.urls')), - path('account/', include('apps.account.urls')), - path('courses/', include('apps.course.urls')), - path('quiz/', include('apps.quiz.urls')), - path('transaction/', include('apps.transaction.urls')), - path('certificates/', include('apps.certificate.urls')), - path('hadis/', include('apps.hadis.urls')), + path('account/', include('apps.account.urls')), + path('courses/', include('apps.course.urls')), + path('quiz/', include('apps.quiz.urls')), + path('transaction/', include('apps.transaction.urls')), + path('certificates/', include('apps.certificate.urls')), + path('hadis/', include('apps.hadis.urls')), + path('library/', include('apps.library.urls')), + + path('videos/', include('apps.video.urls')), path('settings/', include('dynamic_preferences.urls')), path('upload-tmp-media/', UploadTmpMedia.as_view()), - + ]