From 0e087e014d72d7b16de0b19d88d727e048ea5d33 Mon Sep 17 00:00:00 2001 From: alireza Date: Wed, 23 Apr 2025 12:40:41 +0330 Subject: [PATCH] feat: library models --- apps/bookmark/__init__.py | 0 apps/bookmark/admin.py | 11 + apps/bookmark/apps.py | 6 + apps/bookmark/migrations/0001_initial.py | 34 ++ apps/bookmark/migrations/__init__.py | 0 apps/bookmark/models.py | 76 +++++ apps/bookmark/serializers.py | 76 +++++ apps/bookmark/tests.py | 3 + apps/bookmark/urls.py | 10 + apps/bookmark/views.py | 128 +++++++ apps/chat/admin.py | 196 +++++++++-- apps/dobodbi_calendar/__init__.py | 0 apps/dobodbi_calendar/admin.py | 3 + apps/dobodbi_calendar/apps.py | 6 + apps/dobodbi_calendar/migrations/__init__.py | 0 apps/dobodbi_calendar/models.py | 39 +++ apps/dobodbi_calendar/tests.py | 3 + apps/dobodbi_calendar/views.py | 3 + apps/hadis/admin/hadis.py | 312 +++++++++--------- apps/library/admin.py | 240 ++++++++------ apps/library/doc.py | 31 +- .../migrations/0003_bookcollection_pin_top.py | 18 + .../migrations/0004_bookcollection_slug.py | 19 ++ ...ad_delete_bottombookcollection_and_more.py | 40 +++ apps/library/models.py | 50 ++- apps/library/pagination.py | 23 ++ apps/library/serializers.py | 80 ++++- .../change_list_before.html | 19 ++ apps/library/urls.py | 4 + apps/library/views.py | 126 ++++++- apps/quiz/models/participant.py | 2 + config/settings/base.py | 95 ++++-- config/test_auth_middleware.py | 34 +- config/urls.py | 2 +- .../admin/chat/chatmessage/change_list.html | 12 + templates/course/course_analytics.html | 283 ++++++++++++++++ templates/course/course_stats.html | 161 +++++++++ 37 files changed, 1813 insertions(+), 332 deletions(-) create mode 100644 apps/bookmark/__init__.py create mode 100644 apps/bookmark/admin.py create mode 100644 apps/bookmark/apps.py create mode 100644 apps/bookmark/migrations/0001_initial.py create mode 100644 apps/bookmark/migrations/__init__.py create mode 100644 apps/bookmark/models.py create mode 100644 apps/bookmark/serializers.py create mode 100644 apps/bookmark/tests.py create mode 100644 apps/bookmark/urls.py create mode 100644 apps/bookmark/views.py create mode 100644 apps/dobodbi_calendar/__init__.py create mode 100644 apps/dobodbi_calendar/admin.py create mode 100644 apps/dobodbi_calendar/apps.py create mode 100644 apps/dobodbi_calendar/migrations/__init__.py create mode 100644 apps/dobodbi_calendar/models.py create mode 100644 apps/dobodbi_calendar/tests.py create mode 100644 apps/dobodbi_calendar/views.py create mode 100644 apps/library/migrations/0003_bookcollection_pin_top.py create mode 100644 apps/library/migrations/0004_bookcollection_slug.py create mode 100644 apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py create mode 100644 apps/library/pagination.py create mode 100644 apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html create mode 100644 templates/admin/chat/chatmessage/change_list.html create mode 100644 templates/course/course_analytics.html create mode 100644 templates/course/course_stats.html diff --git a/apps/bookmark/__init__.py b/apps/bookmark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/bookmark/admin.py b/apps/bookmark/admin.py new file mode 100644 index 0000000..1f250fa --- /dev/null +++ b/apps/bookmark/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from .models import Bookmark + +@admin.register(Bookmark) +class BookmarkAdmin(admin.ModelAdmin): + list_display = ('user', 'service', 'content_id', 'status', 'created_at', 'updated_at') + list_filter = ('service', 'status', 'created_at') + search_fields = ('user__username', 'user__email', 'content_id') + readonly_fields = ('created_at', 'updated_at') + list_per_page = 20 + date_hierarchy = 'created_at' diff --git a/apps/bookmark/apps.py b/apps/bookmark/apps.py new file mode 100644 index 0000000..419b8fc --- /dev/null +++ b/apps/bookmark/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookmarkConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.bookmark' diff --git a/apps/bookmark/migrations/0001_initial.py b/apps/bookmark/migrations/0001_initial.py new file mode 100644 index 0000000..55a1ee1 --- /dev/null +++ b/apps/bookmark/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.8 on 2025-04-23 10:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Bookmark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('service', models.CharField(choices=[('library', 'Library'), ('podcast', 'Podcast'), ('hadith', 'Hadith'), ('video', 'Video')], max_length=20, verbose_name='Service')), + ('content_id', models.PositiveIntegerField(verbose_name='Content ID')), + ('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')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Bookmark', + 'verbose_name_plural': 'Bookmarks', + 'unique_together': {('user', 'service', 'content_id')}, + }, + ), + ] diff --git a/apps/bookmark/migrations/__init__.py b/apps/bookmark/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/bookmark/models.py b/apps/bookmark/models.py new file mode 100644 index 0000000..54a75fc --- /dev/null +++ b/apps/bookmark/models.py @@ -0,0 +1,76 @@ +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + +class Bookmark(models.Model): + """ + Bookmark model for different services like library, podcast, hadith, and video. + """ + + class ServiceChoices(models.TextChoices): + LIBRARY = 'library', 'Library' + PODCAST = 'podcast', 'Podcast' + HADITH = 'hadith', 'Hadith' + VIDEO = 'video', 'Video' + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='bookmarks', verbose_name='User') + service = models.CharField(max_length=20, choices=ServiceChoices.choices, verbose_name='Service') + content_id = models.PositiveIntegerField(verbose_name='Content ID') + 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') + + class Meta: + verbose_name = 'Bookmark' + verbose_name_plural = 'Bookmarks' + unique_together = ('user', 'service', 'content_id') + + def __str__(self): + return f"{self.user.username} - {self.get_service_display()} - {self.content_id}" + + @classmethod + def is_bookmarked(cls, user, service, content_id): + """ + Check if a specific content is bookmarked by the user. + + Args: + user: User instance + service: Service name (library, podcast, hadith, video) + content_id: ID of the content + + Returns: + Boolean indicating if the content is bookmarked + """ + return cls.objects.filter( + user=user, + service=service, + content_id=content_id, + status=True + ).exists() + + @classmethod + def validate_content_exists(cls, service, content_id): + """ + Validate if content with the given ID exists in the specified service. + + Args: + service: Service name (library, podcast, hadith, video) + content_id: ID of the content to validate + + Returns: + Boolean indicating if the content exists + """ + if service == cls.ServiceChoices.LIBRARY: + from apps.library.models import Book + return Book.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.PODCAST: + from apps.podcast.models import Podcast + return Podcast.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.HADITH: + from apps.hadith.models import Hadith + return Hadith.objects.filter(id=content_id).exists() + elif service == cls.ServiceChoices.VIDEO: + from apps.video.models import Video + return Video.objects.filter(id=content_id).exists() + return False diff --git a/apps/bookmark/serializers.py b/apps/bookmark/serializers.py new file mode 100644 index 0000000..f2b9da2 --- /dev/null +++ b/apps/bookmark/serializers.py @@ -0,0 +1,76 @@ +from rest_framework import serializers +from .models import Bookmark + + +class BookmarkSerializer(serializers.ModelSerializer): + """ + Serializer for the Bookmark model. + """ + + class Meta: + model = Bookmark + fields = ['id', 'user', 'service', 'content_id', 'status', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at', 'status'] + + def validate(self, data): + """ + Validate that the content_id exists in the specified service. + """ + service = data.get('service') + content_id = data.get('content_id') + + if not Bookmark.validate_content_exists(service, content_id): + raise serializers.ValidationError( + f"Content does not exist in service." + ) + + return data + + +class BookmarkStatusSerializer(serializers.Serializer): + """ + Serializer for bookmark status information. + This can be used as a SerializerMethodField in other serializers. + """ + is_bookmarked = serializers.BooleanField(default=False) + content_id = serializers.IntegerField() + + + bookmark_info = BookmarkSerializer(read_only=True) + + @staticmethod + def get_bookmark_info(obj, user, service): + """ + Get bookmark information for a specific object. + + Args: + obj: The object being serialized + user: The user to check bookmark status for + service: The service name (library, podcast, hadith, video) + + Returns: + Dictionary with is_bookmarked and content_id + """ + if not user or user.is_anonymous: + return { + 'is_bookmarked': False, + 'content_id': getattr(obj, 'id', None) + } + + content_id = getattr(obj, 'id', None) + if content_id is None: + return { + 'is_bookmarked': False, + 'content_id': None + } + + is_bookmarked = Bookmark.is_bookmarked( + user=user, + service=service, + content_id=content_id + ) + + return { + 'is_bookmarked': is_bookmarked, + 'content_id': content_id + } \ No newline at end of file diff --git a/apps/bookmark/tests.py b/apps/bookmark/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/bookmark/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/bookmark/urls.py b/apps/bookmark/urls.py new file mode 100644 index 0000000..3e973bb --- /dev/null +++ b/apps/bookmark/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import AddBookmarkView, RemoveBookmarkView, BookmarkStatusView + +app_name = 'bookmark' + +urlpatterns = [ + path('add/', AddBookmarkView.as_view(), name='add_bookmark'), + path('remove/', RemoveBookmarkView.as_view(), name='remove_bookmark'), + path('status/', BookmarkStatusView.as_view(), name='bookmark_status'), +] \ No newline at end of file diff --git a/apps/bookmark/views.py b/apps/bookmark/views.py new file mode 100644 index 0000000..34ab88c --- /dev/null +++ b/apps/bookmark/views.py @@ -0,0 +1,128 @@ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.generics import CreateAPIView, DestroyAPIView +from rest_framework.exceptions import ValidationError + +from .models import Bookmark +from .serializers import BookmarkSerializer + + +class AddBookmarkView(CreateAPIView): + """ + Add a bookmark for a specific content in a service. + If the bookmark already exists but is inactive, it will be reactivated. + """ + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + def create(self, request, *args, **kwargs): + service = request.data.get('service') + content_id = request.data.get('content_id') + + if not service or not content_id: + return Response( + {'error': 'Both service and content_id are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if the bookmark already exists + bookmark = Bookmark.objects.filter( + user=request.user, + service=service, + content_id=content_id + ).first() + + if bookmark: + # If bookmark exists but is inactive, reactivate it + if not bookmark.status: + bookmark.status = True + bookmark.save() + serializer = self.get_serializer(bookmark) + return Response(serializer.data, status=status.HTTP_200_OK) + # If bookmark exists and is active, return it + serializer = self.get_serializer(bookmark) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Create a new bookmark + serializer = self.get_serializer(data={ + 'user': request.user.id, + 'service': service, + 'content_id': content_id, + 'status': True + }) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + +class RemoveBookmarkView(DestroyAPIView): + """ + Deactivate a bookmark by setting its status to False using content_id and user. + + The request should specify the content_id of the bookmark to be deactivated. + """ + permission_classes = [IsAuthenticated] + serializer_class = BookmarkSerializer + + def get_object(self): + service = self.request.data.get('service') + content_id = self.request.data.get('content_id') + + if not service or not content_id: + raise ValidationError('Both service and content_id are required') + + bookmark = Bookmark.objects.filter( + user=self.request.user, + service=service, + content_id=content_id, + status=True + ).first() + + if not bookmark: + raise ValidationError('Bookmark not found or already inactive') + + return bookmark + + def destroy(self, request, *args, **kwargs): + bookmark = self.get_object() + bookmark.status = False + bookmark.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class BookmarkStatusView(APIView): + """ + Return the count of bookmarks for each service for the current user. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + # Get all active bookmarks for the current user + user_bookmarks = Bookmark.objects.filter( + user=request.user, + status=True + ) + + # Get the count of bookmarks for each service + service_counts = {} + for service_choice in Bookmark.ServiceChoices.choices: + service_code = service_choice[0] # Get the service code (e.g., 'library') + service_name = service_choice[1] # Get the service display name (e.g., 'Library') + + # Count bookmarks for this service + count = user_bookmarks.filter(service=service_code).count() + + # Add to results + service_counts[service_code] = { + 'service': service_code, + 'service_display': service_name, + 'count': count + } + + # Convert to list for response + result = list(service_counts.values()) + + return Response(result, status=status.HTTP_200_OK) diff --git a/apps/chat/admin.py b/apps/chat/admin.py index 4f88fbe..52d6a50 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -1,59 +1,201 @@ from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from django.db.models import Count +from unfold.admin import ModelAdmin, TabularInline +from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus +from utils.admin import project_admin_site +class ChatMessageInline(TabularInline): + model = ChatMessage + extra = 0 + fields = ('sender', 'content', 'content_type', 'sent_at', 'is_deleted') + readonly_fields = ('sent_at',) + can_delete = False + show_change_link = True + classes = ['collapse'] + verbose_name = _("Message") + verbose_name_plural = _("Messages") -@admin.register(MessageReadStatus) -class MessageReadStatusAdmin(admin.ModelAdmin): +class MessageReadStatusAdmin(ModelAdmin): list_display = ( - 'user', 'message', 'is_read', 'read_at', + 'user', 'message', 'is_read_status', 'read_at', ) + list_filter = ( + ('read_at', RangeDateTimeFilter), + 'is_read', + ) + search_fields = ('user__username', 'user__email', 'message__content') + readonly_fields = ('read_at',) + + def is_read_status(self, obj): + if obj.is_read: + return format_html('Read') + return format_html('Unread') + + is_read_status.short_description = _("Read Status") -@admin.register(RoomMessage) -class RoomMessageAdmin(admin.ModelAdmin): +class RoomMessageAdmin(ModelAdmin): list_display = ( - 'name', 'room_type', 'course', 'initiator', 'recipient', 'created_at', 'unread_messages_count' + 'name', 'room_type_badge', 'course', 'initiator', + 'messages_count', 'view_messages_button' + ) + list_filter = ( + 'room_type', + ('created_at', RangeDateTimeFilter), + ('updated_at', RangeDateTimeFilter), + 'course' ) - list_filter = ('room_type', 'created_at', 'updated_at', 'course') search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username') ordering = ('-created_at',) - readonly_fields = ('created_at', 'updated_at') + readonly_fields = ('created_at', 'updated_at', 'messages_count') + inlines = [ChatMessageInline] + fieldsets = ( - (None, { - 'fields': ('name', 'description', 'room_type') + (_("Room Information"), { + 'fields': ('name', 'description', 'room_type', 'messages_count'), + 'classes': ('grid-col-2',), }), - ('Relations', { - 'fields': ('course', 'initiator', 'recipient') + (_("Relations"), { + 'fields': ('course', 'initiator', 'recipient'), + 'classes': ('grid-col-2',), }), - ('Timestamps', { - 'fields': ('created_at', 'updated_at') + (_("Timestamps"), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('grid-col-2',), }), ) + + def messages_count(self, obj): + count = obj.messages.count() + return format_html('{}', count) + + messages_count.short_description = _("Messages Count") + + def room_type_badge(self, obj): + if obj.room_type == 'group': + return format_html('Group') + return format_html('Private') + + room_type_badge.short_description = _("Room Type") + + def get_queryset(self, request): + queryset = super().get_queryset(request) + queryset = queryset.annotate( + total_messages=Count('messages') + ) + return queryset + + def view_messages_button(self, obj): + from django.urls import reverse + url = f"{reverse('admin:chat_chatmessage_changelist')}?room__id__exact={obj.id}" + + return format_html( + '' + 'chat {}', + url, _("View Messages") + ) + + view_messages_button.short_description = _("Messages") + +class MessageReadStatusInline(TabularInline): + model = MessageReadStatus + extra = 0 + fields = ('user', 'is_read', 'read_at') + readonly_fields = ('read_at',) + can_delete = False + show_change_link = True + classes = ['collapse'] + verbose_name = _("Read Status") + verbose_name_plural = _("Read Statuses") -@admin.register(ChatMessage) -class ChatMessageAdmin(admin.ModelAdmin): + +class ChatMessageAdmin(ModelAdmin): + change_list_template = 'admin/chat/chatmessage/change_list.html' list_display = ( - 'room', 'sender', 'content_type', 'content_size', 'sent_at', 'is_deleted' + 'id', 'room', 'sender', 'content_type_badge', 'content_preview', + 'content_size_display', 'sent_at', 'is_deleted_status' + ) + list_filter = ( + 'room', + 'content_type', + 'is_deleted', + ('sent_at', RangeDateTimeFilter), + ('updated_at', RangeDateTimeFilter), + ('content_size', RangeNumericFilter) ) - list_filter = ('content_type', 'is_deleted', 'sent_at', 'updated_at') search_fields = ('room__name', 'sender__username', 'content') ordering = ('-sent_at',) - readonly_fields = ('sent_at', 'updated_at') + readonly_fields = ('sent_at', 'updated_at', 'content_size') + inlines = [MessageReadStatusInline] + fieldsets = ( - (None, { - 'fields': ('room', 'sender', 'content', 'content_type') + (_("Message Information"), { + 'fields': ('room', 'sender', 'content', 'content_type'), + 'classes': ('grid-col-2',), }), - ('Additional Info', { - 'fields': ('content_size',) + (_("Additional Info"), { + 'fields': ('content_size',), + 'classes': ('grid-col-1',), }), - ('Status', { - 'fields': ('is_deleted', 'deleted_at') + (_("Status"), { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('grid-col-2',), }), - ('Timestamps', { - 'fields': ('sent_at', 'updated_at') + (_("Timestamps"), { + 'fields': ('sent_at', 'updated_at'), + 'classes': ('grid-col-2',), }), ) + + def content_preview(self, obj): + if obj.content_type == 'text': + preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content + return preview + return f"{obj.content_type} content" + + content_preview.short_description = _("Content Preview") + + def content_type_badge(self, obj): + badges = { + 'text': ('blue', 'Text'), + 'file': ('yellow', 'File'), + 'audio': ('green', 'Audio'), + 'image': ('pink', 'Image'), + } + color, label = badges.get(obj.content_type, ('gray', obj.content_type)) + return format_html( + '{}', + color, color, label + ) + + content_type_badge.short_description = _("Type") + + def is_deleted_status(self, obj): + if obj.is_deleted: + return format_html('Deleted') + return format_html('Active') + + is_deleted_status.short_description = _("Status") + + def content_size_display(self, obj): + if obj.content_size: + # Format size in KB if larger than 1024 bytes + if obj.content_size > 1024: + size_kb = obj.content_size / 1024 + return f"{size_kb:.1f} KB" + return f"{obj.content_size} bytes" + return "-" + + content_size_display.short_description = _("Size") + +# Register models with the custom admin site +project_admin_site.register(RoomMessage, RoomMessageAdmin) +project_admin_site.register(ChatMessage, ChatMessageAdmin) +project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin) diff --git a/apps/dobodbi_calendar/__init__.py b/apps/dobodbi_calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dobodbi_calendar/admin.py b/apps/dobodbi_calendar/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/dobodbi_calendar/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/dobodbi_calendar/apps.py b/apps/dobodbi_calendar/apps.py new file mode 100644 index 0000000..0d62830 --- /dev/null +++ b/apps/dobodbi_calendar/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DobodbiCalendarConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.dobodbi_calendar' diff --git a/apps/dobodbi_calendar/migrations/__init__.py b/apps/dobodbi_calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dobodbi_calendar/models.py b/apps/dobodbi_calendar/models.py new file mode 100644 index 0000000..328e555 --- /dev/null +++ b/apps/dobodbi_calendar/models.py @@ -0,0 +1,39 @@ +from django.db import models + +# Create your models here. + + +class CalendarOccasions(models.Model): + """ + calendar events model + """ + + class OccasionType(models.TextChoices): + GEORGIAN = 'georgian', _('georgian') + LUNAR = 'lunar', _('lunar') + + class EventType(models.TextChoices): + national = 'national', _('National') + international = 'international', _('International') + religious = 'religious', _('Religious') + + title = models.CharField(_("title"), max_length=255) + is_global = models.BooleanField( + verbose_name=_('is global'), default=False, + help_text=_('check this field if event is global'), + ) + + occasion_type = models.CharField( + choices=OccasionType.choices, + default=OccasionType.GEORGIAN, + max_length=12, + help_text=_('Choose between georgian or lunar. default to georgian'), + verbose_name=_('occasion type') + ) + dates = models.JSONField(verbose_name=_('dates')) + is_yearly = models.BooleanField( + verbose_name=_('is yearly'), default=True, + help_text=_('check this field if event is annually') + ) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + diff --git a/apps/dobodbi_calendar/tests.py b/apps/dobodbi_calendar/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/dobodbi_calendar/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/dobodbi_calendar/views.py b/apps/dobodbi_calendar/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/dobodbi_calendar/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/hadis/admin/hadis.py b/apps/hadis/admin/hadis.py index 444918c..50bd6f3 100644 --- a/apps/hadis/admin/hadis.py +++ b/apps/hadis/admin/hadis.py @@ -1,161 +1,161 @@ -from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from dj_category.admin import BaseCategoryAdmin -from ajaxdatatable.admin import AjaxDatatable -from django.http import JsonResponse -from django.urls import path -from django.db.models import Q -from django.utils.safestring import mark_safe -from django.forms.widgets import RadioSelect - -from apps.hadis.models import * -from django import forms -from utils.json_editor_field import JsonEditorWidget - -# Define color choices -COLOR_CHOICES = [ - ('red', _('Red')), - ('blue', _('Blue')), - ('green', _('Green')), - ('yellow', _('Yellow')), - ('orange', _('Orange')), - ('purple', _('Purple')), - ('pink', _('Pink')), - ('brown', _('Brown')), - ('gray', _('Gray')), - ('black', _('Black')), -] - -class ColorRadioSelect(RadioSelect): - template_name = 'admin/widgets/color_radio.html' - option_template_name = 'admin/widgets/color_radio_option.html' - - -def get_links_schema(): - return { - 'type': "array", - 'format': 'table', - 'title': ' ', - 'items': { - 'type': 'object', - 'title': str(_('Link')), - 'properties': { - 'text': {'type': 'string', "format": "textarea",'title': str(_('text'))}, - 'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))}, - } - } - } - -class HadisOverviewForm(forms.ModelForm): - status_color = forms.ChoiceField( - choices=COLOR_CHOICES, - widget=ColorRadioSelect(), - required=False - ) - - class Meta: - model = HadisOverview - fields = '__all__' - widgets = { - 'links': JsonEditorWidget(attrs={'schema': get_links_schema}), - } - - - - - -@admin.register(HadisTag) -class HadisTagAdmin(AjaxDatatable): - list_display = ['title', 'status'] - search_fields = ['title'] - - -class ReferenceImageInline(admin.TabularInline): - model = ReferenceImage - extra = 1 - verbose_name_plural = _('Reference Images') - fields = ('thumbnail', 'priority') - - -@admin.register(HadisReference) -class HadisReferenceAdmin(AjaxDatatable): - list_display = ['hadis', 'book', 'created_at'] - list_filter = ['book'] - search_fields = ['hadis__title', 'hadis__number', 'description'] - autocomplete_fields = ['hadis', 'book'] - readonly_fields = ['created_at'] - inlines = [ReferenceImageInline] - fieldsets = ( - (None, { - 'fields': ('hadis', 'book', 'description') - }), - ) +# from django.contrib import admin +# from django.utils.translation import gettext_lazy as _ +# from dj_category.admin import BaseCategoryAdmin +# from ajaxdatatable.admin import AjaxDatatable +# from django.http import JsonResponse +# from django.urls import path +# from django.db.models import Q +# from django.utils.safestring import mark_safe +# from django.forms.widgets import RadioSelect + +# from apps.hadis.models import * +# from django import forms +# from utils.json_editor_field import JsonEditorWidget + +# # Define color choices +# COLOR_CHOICES = [ +# ('red', _('Red')), +# ('blue', _('Blue')), +# ('green', _('Green')), +# ('yellow', _('Yellow')), +# ('orange', _('Orange')), +# ('purple', _('Purple')), +# ('pink', _('Pink')), +# ('brown', _('Brown')), +# ('gray', _('Gray')), +# ('black', _('Black')), +# ] + +# class ColorRadioSelect(RadioSelect): +# template_name = 'admin/widgets/color_radio.html' +# option_template_name = 'admin/widgets/color_radio_option.html' + + +# def get_links_schema(): +# return { +# 'type': "array", +# 'format': 'table', +# 'title': ' ', +# 'items': { +# 'type': 'object', +# 'title': str(_('Link')), +# 'properties': { +# 'text': {'type': 'string', "format": "textarea",'title': str(_('text'))}, +# 'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))}, +# } +# } +# } + +# class HadisOverviewForm(forms.ModelForm): +# status_color = forms.ChoiceField( +# choices=COLOR_CHOICES, +# widget=ColorRadioSelect(), +# required=False +# ) + +# class Meta: +# model = HadisOverview +# fields = '__all__' +# widgets = { +# 'links': JsonEditorWidget(attrs={'schema': get_links_schema}), +# } + + + + + +# @admin.register(HadisTag) +# class HadisTagAdmin(AjaxDatatable): +# list_display = ['title', 'status'] +# search_fields = ['title'] + + +# class ReferenceImageInline(admin.TabularInline): +# model = ReferenceImage +# extra = 1 +# verbose_name_plural = _('Reference Images') +# fields = ('thumbnail', 'priority') + + +# @admin.register(HadisReference) +# class HadisReferenceAdmin(AjaxDatatable): +# list_display = ['hadis', 'book', 'created_at'] +# list_filter = ['book'] +# search_fields = ['hadis__title', 'hadis__number', 'description'] +# autocomplete_fields = ['hadis', 'book'] +# readonly_fields = ['created_at'] +# inlines = [ReferenceImageInline] +# fieldsets = ( +# (None, { +# 'fields': ('hadis', 'book', 'description') +# }), +# ) -@admin.register(HadisOverview) -class HadisOverviewAdmin(AjaxDatatable): - change_form_template = 'admin/hadisowerview_change_form.html' - form = HadisOverviewForm - ordering = ['hadis__number'] - list_display = ['hadis', 'status', 'created_at'] - search_fields = ['hadis__title', 'hadis__number', 'status_text',] - autocomplete_fields = ['hadis', 'tags'] - fieldsets = ( - (None, { - 'fields': ('hadis', 'status', 'status_color', 'status_text') - }), - (_('Reference Information'), { - 'fields': ('address', 'share_link',), - }), - (_('Additional Information'), { - 'fields': ('links', 'tags', 'created_at'), - 'classes': ('collapse',), - }), - ) - - -class HadisOverviewInline(admin.StackedInline): - change_form_template = 'admin/hadisowerview_change_form.html' - form = HadisOverviewForm - model = HadisOverview - autocomplete_fields = ['tags', ] - can_delete = False - verbose_name_plural = _('Hadis Overview') - fieldsets = ( - (None, { - 'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), - }), - ) - extra = 1 - min_num = 1 - - -@admin.register(Hadis) -class HadisAdmin(AjaxDatatable): - # form = HadisForm - list_display = ['number', 'title', 'category', 'status', 'created_at'] - list_filter = ['status', 'category'] - search_fields = ['title', 'text', 'number'] - readonly_fields = ['created_at', 'updated_at'] - autocomplete_fields = ['category'] - inlines = [HadisOverviewInline] - fieldsets = ( - (None, { - 'fields': ('number', 'title', 'category', 'status') - }), - (_('Content'), { - 'fields': ('text', 'translation'), - 'classes': ('collapse',), - }), - ) - - - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - if obj is None: - form.base_fields['category'].widget.can_add_related = False - - return form +# @admin.register(HadisOverview) +# class HadisOverviewAdmin(AjaxDatatable): +# change_form_template = 'admin/hadisowerview_change_form.html' +# form = HadisOverviewForm +# ordering = ['hadis__number'] +# list_display = ['hadis', 'status', 'created_at'] +# search_fields = ['hadis__title', 'hadis__number', 'status_text',] +# autocomplete_fields = ['hadis', 'tags'] +# fieldsets = ( +# (None, { +# 'fields': ('hadis', 'status', 'status_color', 'status_text') +# }), +# (_('Reference Information'), { +# 'fields': ('address', 'share_link',), +# }), +# (_('Additional Information'), { +# 'fields': ('links', 'tags', 'created_at'), +# 'classes': ('collapse',), +# }), +# ) + + +# class HadisOverviewInline(admin.StackedInline): +# change_form_template = 'admin/hadisowerview_change_form.html' +# form = HadisOverviewForm +# model = HadisOverview +# autocomplete_fields = ['tags', ] +# can_delete = False +# verbose_name_plural = _('Hadis Overview') +# fieldsets = ( +# (None, { +# 'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), +# }), +# ) +# extra = 1 +# min_num = 1 + + +# @admin.register(Hadis) +# class HadisAdmin(AjaxDatatable): +# # form = HadisForm +# list_display = ['number', 'title', 'category', 'status', 'created_at'] +# list_filter = ['status', 'category'] +# search_fields = ['title', 'text', 'number'] +# readonly_fields = ['created_at', 'updated_at'] +# autocomplete_fields = ['category'] +# inlines = [HadisOverviewInline] +# fieldsets = ( +# (None, { +# 'fields': ('number', 'title', 'category', 'status') +# }), +# (_('Content'), { +# 'fields': ('text', 'translation'), +# 'classes': ('collapse',), +# }), +# ) + + +# def get_form(self, request, obj=None, **kwargs): +# form = super().get_form(request, obj, **kwargs) +# if obj is None: +# form.base_fields['category'].widget.can_add_related = False + +# return form diff --git a/apps/library/admin.py b/apps/library/admin.py index 52e1380..355f5a5 100644 --- a/apps/library/admin.py +++ b/apps/library/admin.py @@ -3,19 +3,61 @@ 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 unfold.admin import ModelAdmin +from django.contrib.admin import SimpleListFilter +from unfold.decorators import display, action +from django import forms +from utils.admin import project_admin_site from apps.library.models import * -@admin.register(Book) -class BookAdmin(AjaxDatatable): - list_display = ('title', 'slug', 'status', 'pin', 'file_type', 'view_count', 'created_at') +class BookCollectionAdmin(ModelAdmin): + list_display = ('title', 'display_position', 'status', 'order') + list_filter = ('status', 'display_position') + search_fields = ('title',) + +project_admin_site.register(BookCollection, BookCollectionAdmin) + +class BookAdminForm(forms.ModelForm): + class Meta: + model = Book + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Make thumbnail field required in the form + self.fields['thumbnail'].required = True + +class BookAdmin(ModelAdmin): + form = BookAdminForm + list_display = ('title', 'display_categories', 'display_collections', 'status', 'view_count', 'created_at') list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at') search_fields = ('title', 'slug', 'summary', 'description') # autocomplete_fields = ('categories', 'collections', ) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + + save_as = True + + @display(description=_('Categories')) + def display_categories(self, obj): + categories = obj.categories.all() + if categories: + return ', '.join([category.title for category in categories]) + return '-' + + @display(description=_('Collections')) + def display_collections(self, obj): + collections = obj.collections.all() + if collections: + return ', '.join([collection.title for collection in collections]) + return '-' + fieldsets = ( (None, { - 'fields': ('title', 'slug', 'summary', 'description', 'thumbnail', 'pages_count') + 'fields': ('title', 'summary', 'description', 'thumbnail', 'pages_count') }), (_('Status'), { 'fields': ('status', 'pin') @@ -27,19 +69,21 @@ class BookAdmin(AjaxDatatable): 'fields': ('categories', 'collections') }), (_('Statistics'), { - 'fields': ('view_count',) + 'fields': ('view_count', 'download_count') }), ) - - -class BookCollectionAdminBase(AjaxDatatable): +class BookCollectionAdminBase(ModelAdmin): """Base admin class for all book collection types""" list_display = ('get_title', 'status', 'order', 'count_books') - list_filter = ('status',) + list_filter = ('status', 'order') search_fields = ('title',) autocomplete_fields = ('books',) ordering = ('order',) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + fieldsets = ( (None, { @@ -52,27 +96,48 @@ class BookCollectionAdminBase(AjaxDatatable): exclude = ('display_position',) + @display(description=_('Title')) def get_title(self, obj): return str(obj.title) - get_title.short_description = _('Title') - - @admin.display(description=_('Number of Books')) + # @display(description=_("Status"), ordering="status") + # def status_badge(self, obj): + # if obj.status: + # return format_html( + # '{}', + # _("Active") + # ) + # return format_html( + # '{}', + # _("Inactive") + # ) + + @display(description=_('Number of Books')) def count_books(self, obj): count = obj.books.count() if count > 0: url = reverse('admin:library_book_changelist') + f'?collections__id__exact={obj.id}' return format_html('{}', url, count) return count + + - -@admin.register(PinnedBookCollection) class PinnedBookCollectionAdmin(BookCollectionAdminBase): """Admin for pinned book collections only""" + list_before_template = "admin/library/pinnedbookcollection/change_list_before.html" + + fieldsets = ( + (None, { + 'fields': ('title', 'summary', 'status', 'order', 'pin_top') + }), + (_('Books'), { + 'fields': ('books',) + }), + ) def get_queryset(self, request): # Only show pinned collections @@ -84,7 +149,6 @@ class PinnedBookCollectionAdmin(BookCollectionAdminBase): super().save_model(request, obj, form, change) -@admin.register(MiddleBookCollection) class MiddleBookCollectionAdmin(BookCollectionAdminBase): """Admin for middle section book collections only""" @@ -92,101 +156,89 @@ class MiddleBookCollectionAdmin(BookCollectionAdminBase): # Only show middle section collections return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.MIDDLE) - def has_add_permission(self, request): - # Check if a middle collection already exists - exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() - # Only allow adding if no middle collection exists - return not exists + # def has_add_permission(self, request): + # # Check if a middle collection already exists + # exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() + # # Only allow adding if no middle collection exists + # return not exists - def has_delete_permission(self, request, obj=None): - # Prevent deletion of the middle collection - return False + # def has_delete_permission(self, request, obj=None): + # # Prevent deletion of the middle collection + # return False def save_model(self, request, obj, form, change): # Ensure the display_position is always set to MIDDLE obj.display_position = BookCollection.DisplayPosition.MIDDLE super().save_model(request, obj, form, change) - def changelist_view(self, request, extra_context=None): - # Check if a middle collection exists - try: - # Try to get the first (and should be only) middle collection - obj = self.get_queryset(request).first() - if obj: - # If it exists, redirect to the change view for this object - from django.http import HttpResponseRedirect - from django.urls import reverse - url = reverse( - 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), - args=[obj.pk] - ) - return HttpResponseRedirect(url) - except Exception: - # If any error occurs, just show the changelist view as usual - pass - - # If no object exists or there was an error, show the default changelist view - return super().changelist_view(request, extra_context) - - -@admin.register(BottomBookCollection) -class BottomBookCollectionAdmin(BookCollectionAdminBase): - """Admin for bottom section book collections only""" - - def get_queryset(self, request): - # Only show bottom section collections - return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.BOTTOM) - def has_add_permission(self, request): - # Check if a bottom collection already exists - exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.BOTTOM).exists() - # Only allow adding if no bottom collection exists - return not exists - def has_delete_permission(self, request, obj=None): - # Prevent deletion of the bottom collection - return False - def save_model(self, request, obj, form, change): - # Ensure the display_position is always set to BOTTOM - obj.display_position = BookCollection.DisplayPosition.BOTTOM - super().save_model(request, obj, form, change) - - def changelist_view(self, request, extra_context=None): - # Check if a bottom collection exists - try: - # Try to get the first (and should be only) bottom collection - obj = self.get_queryset(request).first() - if obj: - # If it exists, redirect to the change view for this object - from django.http import HttpResponseRedirect - from django.urls import reverse - url = reverse( - 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), - args=[obj.pk] - ) - return HttpResponseRedirect(url) - except Exception: - # If any error occurs, just show the changelist view as usual - pass - - # If no object exists or there was an error, show the default changelist view - return super().changelist_view(request, extra_context) - - - -@admin.register(Category) -class CategoryAdmin(AjaxDatatable): - list_display = ('title', 'slug', 'status', 'count_books', 'created_at') +class CategoryAdmin(ModelAdmin): + list_display = ('title', 'slug', 'status_badge', 'count_books', 'created_at') list_filter = ('status', 'created_at', 'updated_at') search_fields = ('title', 'slug') - # autocomplete_fields = ('books',) - - @admin.display(description=_('Number of Books')) + list_filter_submit = True + warn_unsaved_form = True + change_form_show_cancel_button = True + + # Custom actions + actions_list = ['mark_as_active', 'mark_as_inactive'] + actions_row = ['toggle_status'] + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'status') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) + + readonly_fields = ('created_at', 'updated_at') + + @display(description=_("Status"), ordering="status") + def status_badge(self, obj): + if obj.status: + return format_html( + '{}', + _("Active") + ) + return format_html( + '{}', + _("Inactive") + ) + + @display(description=_('Number of Books')) def count_books(self, obj): count = obj.books_count if count > 0: url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}' return format_html('{}', url, count) return count + + @action(description=_("Mark selected categories as active")) + def mark_as_active(self, request, queryset): + updated = queryset.update(status=True) + self.message_user(request, _("%(count)d categories were successfully marked as active.") % {"count": updated}) + + @action(description=_("Mark selected categories as inactive")) + def mark_as_inactive(self, request, queryset): + updated = queryset.update(status=False) + self.message_user(request, _("%(count)d categories were successfully marked as inactive.") % {"count": updated}) + + @action(description=_("Toggle status")) + def toggle_status(self, request, obj): + obj.status = not obj.status + obj.save(update_fields=["status"]) + status_text = _("active") if obj.status else _("inactive") + self.message_user(request, _("Category '%(title)s' is now %(status)s.") % {"title": obj.title, "status": status_text}) + + +# Register models with the custom admin site +project_admin_site.register(Book, BookAdmin) +project_admin_site.register(PinnedBookCollection, PinnedBookCollectionAdmin) +project_admin_site.register(MiddleBookCollection, MiddleBookCollectionAdmin) +project_admin_site.register(Category, CategoryAdmin) diff --git a/apps/library/doc.py b/apps/library/doc.py index 0c932a8..890a9a1 100644 --- a/apps/library/doc.py +++ b/apps/library/doc.py @@ -33,6 +33,14 @@ bottom_param = openapi.Parameter( required=False ) +is_bookmark_param = openapi.Parameter( + 'is_bookmark', + openapi.IN_QUERY, + description="Filter books that are bookmarked by the current user (set to 'true' to enable)", + type=openapi.TYPE_BOOLEAN, + required=False +) + search_param = openapi.Parameter( 'search', openapi.IN_QUERY, @@ -172,12 +180,13 @@ book_list_swagger = swagger_auto_schema( - Collection ID using the query parameter 'collection_id' - Middle section collection using the query parameter 'middle' - Bottom section collection using the query parameter 'bottom' + - Bookmarked books using the query parameter 'is_bookmark=true' 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], + manual_parameters=[collection_id_param, middle_param, bottom_param, is_bookmark_param, search_param], responses={ 200: books_response, 401: "Authentication credentials were not provided or are invalid.", @@ -217,4 +226,24 @@ pinned_collection_list_swagger = swagger_auto_schema( 401: "Authentication credentials were not provided or are invalid.", 500: "Internal server error occurred." } +) + +middle_collection_list_swagger = swagger_auto_schema( + operation_id="list_middle_collections", + operation_description=""" + Retrieve a list of middle section book collections with their books. + + This endpoint returns a list of middle section book collections. Each collection includes its + title, slug, summary, status, order, and a list of books in the collection. + + Each book in the collection includes its id, title, slug, summary, thumbnail, author, + view count, download count, and file type. + """, + operation_summary="List Middle Section Book Collections", + tags=["Library"], + responses={ + 200: "List of middle section book collections with their books", + 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/0003_bookcollection_pin_top.py b/apps/library/migrations/0003_bookcollection_pin_top.py new file mode 100644 index 0000000..1987d92 --- /dev/null +++ b/apps/library/migrations/0003_bookcollection_pin_top.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-15 01:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0002_alter_book_thumbnail'), + ] + + operations = [ + migrations.AddField( + model_name='bookcollection', + name='pin_top', + field=models.BooleanField(default=True, verbose_name='pin top'), + ), + ] diff --git a/apps/library/migrations/0004_bookcollection_slug.py b/apps/library/migrations/0004_bookcollection_slug.py new file mode 100644 index 0000000..c1a17cc --- /dev/null +++ b/apps/library/migrations/0004_bookcollection_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.8 on 2025-04-15 01:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0003_bookcollection_pin_top'), + ] + + operations = [ + migrations.AddField( + model_name='bookcollection', + name='slug', + field=models.SlugField(default='1', max_length=255, unique=True), + preserve_default=False, + ), + ] diff --git a/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py b/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py new file mode 100644 index 0000000..3263687 --- /dev/null +++ b/apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.8 on 2025-04-23 10:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0004_bookcollection_slug'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BookDownload', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='library.book', verbose_name='book')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='book_downloads', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'Book Download', + 'verbose_name_plural': 'Book Downloads', + 'ordering': ('-created_at',), + }, + ), + migrations.DeleteModel( + name='BottomBookCollection', + ), + migrations.AlterField( + model_name='bookcollection', + name='display_position', + field=models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section')], default='pinned', max_length=20, verbose_name='Display Position'), + ), + ] diff --git a/apps/library/models.py b/apps/library/models.py index 42e3113..5deba22 100644 --- a/apps/library/models.py +++ b/apps/library/models.py @@ -3,16 +3,20 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField +from utils import generate_slug_for_model +from apps.account.models import User class BookCollection(models.Model): class DisplayPosition(models.TextChoices): PINNED = 'pinned', _('Pinned') MIDDLE = 'middle', _('Middle Section') - BOTTOM = 'bottom', _('Bottom Section') + # BOTTOM = 'bottom', _('Bottom Section') title = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null')) + pin_top = models.BooleanField(_('pin top'), default=True) display_position = models.CharField( max_length=20, choices=DisplayPosition.choices, @@ -30,6 +34,12 @@ class BookCollection(models.Model): verbose_name = _('Book Collection') verbose_name_plural = _('Book Collections') + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(BookCollection, self.title) + super().save(*args, **kwargs) + + class PinnedBookCollection(BookCollection): """ @@ -51,14 +61,6 @@ class MiddleBookCollection(BookCollection): verbose_name_plural = _('Middle Section Book Collections') -class BottomBookCollection(BookCollection): - """ - Proxy model for bottom section book collections - """ - class Meta: - proxy = True - verbose_name = _('Bottom Section Book Collection') - verbose_name_plural = _('Bottom Section Book Collections') class Category(models.Model): @@ -73,6 +75,11 @@ class Category(models.Model): def __str__(self): return self.title + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(Category, self.title) + super().save(*args, **kwargs) + @property def books_count(self): """Return the number of books in this category""" @@ -117,6 +124,12 @@ class Book(models.Model): def __str__(self): return f'<{self.id}>-{self.title}' + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(Book, self.title) + super().save(*args, **kwargs) + + def increment_view_count(self): """Increment the view count by 1 and save the model""" self.view_count += 1 @@ -128,3 +141,22 @@ class Book(models.Model): verbose_name_plural = _('Books') +class BookDownload(models.Model): + """ + Model to track book downloads by users + """ + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='book_downloads', verbose_name=_('user')) + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='downloads', verbose_name=_('book')) + 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')) + + class Meta: + verbose_name = _('Book Download') + verbose_name_plural = _('Book Downloads') + ordering = ('-created_at',) + + def __str__(self): + return f"{self.user} - {self.book}" + + diff --git a/apps/library/pagination.py b/apps/library/pagination.py new file mode 100644 index 0000000..10b254b --- /dev/null +++ b/apps/library/pagination.py @@ -0,0 +1,23 @@ + + +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + + +class NoPagination(PageNumberPagination): + def paginate_queryset(self, queryset, request, view=None): + # Override to return all items instead of paginated ones + self.count = len(queryset) + self.request = request + self.page = None + self.page_size = len(queryset) + return list(queryset) + + def get_paginated_response(self, data): + # Keep the structure but include all results + return Response({ + 'count': self.count, + 'next': None, # No next page + 'previous': None, # No previous page + 'results': data, + }) \ No newline at end of file diff --git a/apps/library/serializers.py b/apps/library/serializers.py index d7c2bd0..cfda41f 100644 --- a/apps/library/serializers.py +++ b/apps/library/serializers.py @@ -6,16 +6,23 @@ from django.db.models import Avg, Q from rest_framework import serializers from apps.library.models import * +from apps.bookmark.serializers import * class CategorySerializer(serializers.ModelSerializer): - books_count = serializers.IntegerField(read_only=True) + books_count = serializers.SerializerMethodField() class Meta: model = Category fields = ('id', 'title', 'slug', 'status', 'books_count', 'created_at', 'updated_at') + + def get_books_count(self, obj): + # Use the annotation if available, otherwise fall back to the property + if hasattr(obj, 'books_count_annotation'): + return obj.books_count_annotation + return obj.books_count class PinnedBookCollectionSerializer(serializers.ModelSerializer): @@ -35,11 +42,12 @@ class PinnedBookCollectionSerializer(serializers.ModelSerializer): class Meta: model = BookCollection - fields = ('id', 'title', 'covers') + fields = ('id', 'title', 'summary', 'covers') class BookSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() + bookmark = serializers.SerializerMethodField() def get_thumbnail(self, obj): if obj.thumbnail: @@ -51,7 +59,73 @@ class BookSerializer(serializers.ModelSerializer): fields = ( 'id', 'title', 'slug', 'summary', 'description', 'thumbnail', 'author', 'status', 'pin', 'view_count', 'download_count', - 'file_type', 'book_file', 'created_at' + 'file_type', 'book_file', 'created_at', 'bookmark' ) + def get_bookmark(self, obj): + """ + Get bookmark information for this book. + """ + # Get the current user from the request context + request = self.context.get('request') + user = request.user if request else None + book_mark = BookmarkStatusSerializer.get_bookmark_info( + obj=obj, + user=user, + service='library' + ) + return book_mark.get('is_bookmarked', False) + + + +class MiddleBookCollectionSerializer(serializers.ModelSerializer): + """Serializer for Middle Book Collections with their books""" + books = serializers.SerializerMethodField() + + class Meta: + model = BookCollection + fields = ('id', 'title', 'slug', 'summary', 'status', 'order', 'books') + + def get_books(self, obj): + """Get all books in this collection""" + books = obj.books.filter(status=True).order_by('-view_count')[:8] + return BookSerializer(books, many=True, context=self.context).data + + +class BookDownloadSerializer(serializers.ModelSerializer): + """Serializer for book downloads""" + book_id = serializers.IntegerField(write_only=True) + + class Meta: + model = BookDownload + fields = ('id', 'book_id', 'created_at', 'updated_at', 'status') + read_only_fields = ('id', 'created_at', 'updated_at', 'status') + + def validate_book_id(self, value): + """Validate that the book exists and is active""" + try: + book = Book.objects.get(id=value, status=True) + return value + except Book.DoesNotExist: + raise serializers.ValidationError("Book not found or inactive") + + def create(self, validated_data): + """Create a new book download record""" + book_id = validated_data.pop('book_id') + user = self.context['request'].user + book = Book.objects.get(id=book_id) + + # Create or update the download record + download, created = BookDownload.objects.update_or_create( + user=user, + book=book, + defaults={'status': True} + ) + + # Increment the book's download count + book.download_count += 1 + book.save(update_fields=['download_count']) + + return download + diff --git a/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html b/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html new file mode 100644 index 0000000..76c0f06 --- /dev/null +++ b/apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html @@ -0,0 +1,19 @@ +{% load static %} + +
+
+ Pinned Book Collections Banner + {% comment %}
{% endcomment %} + {% comment %}
{% endcomment %} + {% comment %}

Pinned Book Collections

{% endcomment %} + {% comment %}

{% endcomment %} + {% comment %}

{% endcomment %} + {% comment %}
{% endcomment %} + {% comment %}
{% endcomment %} +
+
\ No newline at end of file diff --git a/apps/library/urls.py b/apps/library/urls.py index 5eeb026..ca96f53 100644 --- a/apps/library/urls.py +++ b/apps/library/urls.py @@ -3,13 +3,17 @@ from django.urls import path from apps.library.views import ( CategoryListView, PinnedBookCollectionListView, + MiddleBookCollectionListView, BookListView, BookDetailView, + BookDownloadCreateAPIView, ) urlpatterns = [ path('categories/', CategoryListView.as_view(), name='category-list'), path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), + path('collections/', MiddleBookCollectionListView.as_view(), name='collection-list'), path('books/', BookListView.as_view(), name='book-list'), path('books//', BookDetailView.as_view(), name='book-detail'), + path('books/download/', BookDownloadCreateAPIView.as_view(), name='book-download'), ] \ No newline at end of file diff --git a/apps/library/views.py b/apps/library/views.py index 688037a..111f10d 100644 --- a/apps/library/views.py +++ b/apps/library/views.py @@ -1,16 +1,21 @@ -from django.db.models import Q, Count +from django.db.models import Count, Q from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView from rest_framework.filters import SearchFilter +from rest_framework import status +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from apps.library.pagination import NoPagination 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 + pinned_collection_list_swagger, + middle_collection_list_swagger ) @@ -30,7 +35,7 @@ class CategoryListView(ListAPIView): return Category.objects.filter( status=True ).annotate( - books_count=Count('related_categories') + books_count_annotation=Count('related_categories') ).order_by('title') @@ -40,7 +45,7 @@ class PinnedBookCollectionListView(ListAPIView): """ serializer_class = PinnedBookCollectionSerializer permission_classes = (IsAuthenticated,) - pagination_class = None + pagination_class = NoPagination @pinned_collection_list_swagger def get(self, request, *args, **kwargs): @@ -53,6 +58,30 @@ class PinnedBookCollectionListView(ListAPIView): ).order_by('-order', '-id') + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + categories_count = Category.objects.filter(status=True).count() + from apps.bookmark.models import Bookmark + bookmarks_count = Bookmark.objects.filter( + service=Bookmark.ServiceChoices.LIBRARY, + ).count() + downloads_count = BookDownload.objects.all().count() + info = { + "categories_count": categories_count, + "bookmarks_count": bookmarks_count, + "downloads_count": downloads_count + } + data = { + "count": response.data.get("count"), + "next": response.data.get("next"), + "previous": response.data.get("previous"), + "info": info, + "results": response.data.get("results") + } + return Response(data, status=status.HTTP_200_OK) + + + class BookListView(ListAPIView): """ API view to list books with filtering and search capabilities @@ -92,6 +121,22 @@ class BookListView(ListAPIView): # if bottom_collections.exists(): # queryset = queryset.filter(collections__in=bottom_collections) + # Filter by bookmarked books if requested + is_bookmark = self.request.query_params.get('is_bookmark', '').lower() + if is_bookmark == 'true': + # Import Bookmark model here to avoid circular imports + from apps.bookmark.models import Bookmark + + # Get all bookmarked book IDs for the current user + bookmarked_ids = Bookmark.objects.filter( + user=self.request.user, + service=Bookmark.ServiceChoices.LIBRARY, + status=True + ).values_list('content_id', flat=True) + + # Filter books by these IDs + queryset = queryset.filter(id__in=bookmarked_ids) + return queryset.order_by('-pin', '-created_at') @@ -114,3 +159,74 @@ class BookDetailView(RetrieveAPIView): serializer = self.get_serializer(instance) return Response(serializer.data) + +class MiddleBookCollectionListView(ListAPIView): + """ + API view to list middle section book collections with their books + """ + serializer_class = MiddleBookCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = NoPagination + + @middle_collection_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return BookCollection.objects.filter( + status=True, + display_position=BookCollection.DisplayPosition.MIDDLE + ).order_by('order') + + +class BookDownloadCreateAPIView(CreateAPIView): + """ + API view to create a book download record and increment the book's download count + """ + serializer_class = BookDownloadSerializer + permission_classes = (IsAuthenticated,) + + @swagger_auto_schema( + operation_id="download_book", + operation_description=""" + Create a book download record and increment the book's download count. + + This endpoint creates a record of a book download by the current user and increments + the book's download count. It requires the book ID in the request body. + + If the user has already downloaded the book, the existing record will be updated + with the current timestamp. + """, + operation_summary="Download Book", + tags=["Library"], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'book_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the book to download" + ) + }, + required=['book_id'] + ), + responses={ + 201: openapi.Response( + description="Book download record created successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'created_at': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATETIME), + 'updated_at': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_DATETIME), + 'status': openapi.Schema(type=openapi.TYPE_BOOLEAN) + } + ) + ), + 400: "Invalid request data or book not found", + 401: "Authentication credentials were not provided or are invalid", + 500: "Internal server error occurred" + } + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py index 305af43..3400977 100644 --- a/apps/quiz/models/participant.py +++ b/apps/quiz/models/participant.py @@ -1,4 +1,6 @@ from django.db import models +from django.db.models import F, Window +from django.db.models.functions import Rank from apps.account.models import User diff --git a/config/settings/base.py b/config/settings/base.py index fee9da3..81931f6 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -51,6 +51,7 @@ LOCAL_APPS = [ 'apps.hadis.apps.HadisConfig', 'apps.library.apps.LibraryConfig', 'apps.video.apps.VideoConfig', + 'apps.bookmark.apps.BookmarkConfig', 'dynamic_preferences', ] @@ -140,7 +141,6 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.i18n', "utils.admin.variables", - ], }, }, @@ -153,7 +153,6 @@ WSGI_APPLICATION = 'config.wsgi.application' RECAPTCHA_PUBLIC_KEY = env('captcha_public_key') RECAPTCHA_PRIVATE_KEY = env('captcha_private_key') -# custom settings APPS_REORDER = { 'auth': { 'icon': 'icon-shield-check', @@ -162,7 +161,6 @@ APPS_REORDER = { 'account': { # 'icon': 'icon-', 'name': 'account' - } } # Database @@ -401,6 +399,25 @@ UNFOLD = { # lambda request: static("js/chart.min.js"), ], "TABS": [ + { + "page": "library", + "models": ["library.bookcollection", "library.pinnedbookcollection", 'library.middlebookcollection'], + "items": [ + { + "title": _("Collections"), + "icon": "collections_bookmark", + "link": reverse_lazy("admin:library_pinnedbookcollection_changelist"), + "active": lambda request: "library/pinnedbookcollection" in request.path and "library/middlebookcollection" not in request.path, + }, + { + "title": _("Middle Collections"), + "icon": "view_module", + "link": reverse_lazy("admin:library_middlebookcollection_changelist"), + "active": lambda request: "library/middlebookcollection" in request.path, + + }, + ], + }, { "page": "accounts", "models": ["account.user", 'auth.group'], @@ -483,6 +500,7 @@ UNFOLD = { { "title": _(""), "separator": True, + "collapsible": True, "items": [ { "title": _("Dashboard"), @@ -537,21 +555,10 @@ UNFOLD = { ] }, - { - "title": _(""), - "items": [ - { - "title": _("Certificates"), - "icon": "workspace_premium", - "link": reverse_lazy("admin:certificate_certificate_changelist"), - }, - - ] - }, { "title": _("Courses"), "collapsible": True, - "separator": True, + # "separator": True, "items": [ { "title": _("Categories"), @@ -578,12 +585,17 @@ UNFOLD = { "icon": "book", "link": reverse_lazy("admin:course_glossary_changelist"), }, + { + "title": _("Certificates"), + "icon": "workspace_premium", + "link": reverse_lazy("admin:certificate_certificate_changelist"), + }, ] }, { "title": _("Transactions"), "collapsible": True, - "separator": True, + # "separator": True, "items": [ { "title": _("Transactions"), @@ -593,11 +605,55 @@ UNFOLD = { ] }, { - "title": "Preferences", + "title": _("Library"), + "collapsible": True, + "separator": True, + "items": [ + { + "title": _("Books"), + "icon": "menu_book", + "link": reverse_lazy("admin:library_book_changelist"), + }, + { + "title": _("Categories"), + "icon": "category", + "link": reverse_lazy("admin:library_category_changelist"), + }, + { + "title": _("Collections"), + "icon": "view_module", + "link": reverse_lazy("admin:library_pinnedbookcollection_changelist"), + }, + ] + }, + { + "title": _(""), + "collapsible": True, + "separator": True, "items": [ { - "title": "Global Preferences", - "icon": "settings", # You can choose an appropriate icon + "title": _("Chat Rooms"), + "icon": "forum", + "link": reverse_lazy("admin:chat_roommessage_changelist"), + }, + # { + # "title": _("Chat Messages"), + # "icon": "chat", + # "link": reverse_lazy("admin:apps_chat_chatmessage_changelist"), + # }, + # { + # "title": _("Read Status"), + # "icon": "mark_chat_read", + # "link": reverse_lazy("admin:apps_chat_messagereadstatus_changelist"), + # }, + ] + }, + { + "title": "", + "items": [ + { + "title": _("Global Preferences"), + "icon": "settings", "link": reverse_lazy("admin:dynamic_preferences_globalpreferencemodel_changelist"), }, # You can add more preference sections here @@ -609,6 +665,7 @@ UNFOLD = { # "SCRIPTS": [ # lambda request: static("js/scripts.js"), # ], + # { # "title": _("Hadis"), # "collapsible": True, diff --git a/config/test_auth_middleware.py b/config/test_auth_middleware.py index c1bbfd4..9460fa2 100644 --- a/config/test_auth_middleware.py +++ b/config/test_auth_middleware.py @@ -10,23 +10,23 @@ def test_auth_middleware(get_response): """ def middleware(request): - if "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: - if request.user.is_authenticated and request.user.is_staff: - token, _ = Token.objects.get_or_create(user=request.user) - request.META['HTTP_AUTHORIZATION'] = "Token " + token.key - - - if "/swagger" in request.path or "/redoc" in request.path: - if not request.META.get('HTTP_AUTHORIZATION'): - user = User.objects.filter(is_staff=True, email="admin@gmail.com").first() - if user: - t, _ = Token.objects.get_or_create(user=user) - request.META['HTTP_AUTHORIZATION'] = f"Token {t}" - - # user = User.objects.filter(email="mortezaei2324@gmail.com").first() - # if user: - # t, _ = Token.objects.get_or_create(user=user) - # request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + # if "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: + # if request.user.is_authenticated and request.user.is_staff: + # token, _ = Token.objects.get_or_create(user=request.user) + # request.META['HTTP_AUTHORIZATION'] = "Token " + token.key + + + # if "/swagger" in request.path or "/redoc" in request.path: + # if not request.META.get('HTTP_AUTHORIZATION'): + # user = User.objects.filter(is_staff=True, email="admin@gmail.com").first() + # if user: + # t, _ = Token.objects.get_or_create(user=user) + # request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + user = User.objects.filter(email="muhammadamin.ghorbani@gmail.com").first() + if user: + t, _ = Token.objects.get_or_create(user=user) + request.META['HTTP_AUTHORIZATION'] = f"Token {t}" return get_response(request) diff --git a/config/urls.py b/config/urls.py index be4bbf4..286f433 100644 --- a/config/urls.py +++ b/config/urls.py @@ -70,8 +70,8 @@ api_patterns = [ 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('bookmarks/', include('apps.bookmark.urls')), path('settings/', include('dynamic_preferences.urls')), diff --git a/templates/admin/chat/chatmessage/change_list.html b/templates/admin/chat/chatmessage/change_list.html new file mode 100644 index 0000000..8c217de --- /dev/null +++ b/templates/admin/chat/chatmessage/change_list.html @@ -0,0 +1,12 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} + {{ block.super }} +
  • + + arrow_back + {% translate "Back to Chat Rooms" %} + +
  • +{% endblock %} \ No newline at end of file diff --git a/templates/course/course_analytics.html b/templates/course/course_analytics.html new file mode 100644 index 0000000..4312d75 --- /dev/null +++ b/templates/course/course_analytics.html @@ -0,0 +1,283 @@ +{% load i18n %} +{% load unfold %} + +{% component "unfold/components/container.html" with component_class="CourseAnalyticsComponent" %} + +
    +
    + {% component "unfold/components/title.html" with class="text-2xl font-bold text-gray-800" %} + {% trans "Course Analytics Dashboard" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600" %} + {% trans "Comprehensive analytics and insights for your course" %} + {% endcomponent %} +
    +
    + + +
    +
    + + +
    + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-blue-50 to-blue-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-blue-700 m-0" %} + {{ total_lessons }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-blue-800 mt-1 opacity-80" %} + {% trans "Total Lessons" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-purple-50 to-purple-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-purple-700 m-0" %} + {{ quiz_scores_data.data.datasets.0.data|length }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-purple-800 mt-1 opacity-80" %} + {% trans "Total Quizzes" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-green-50 to-green-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-green-700 m-0" %} + {{ total_participants }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-green-800 mt-1 opacity-80" %} + {% trans "Active Participants" %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-2 text-gray-800" %} + {% trans "Lesson Completion Distribution" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600 mb-6" %} + {% trans "Percentage of course completion by participants" %} + {% endcomponent %} +
    + {% component "unfold/components/chart/bar.html" with data=completion_data.data height=300 %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-2 text-gray-800" %} + {% trans "Quiz Scores Distribution" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600 mb-6" %} + {% trans "Distribution of scores across all quizzes" %} + {% endcomponent %} +
    + {% component "unfold/components/chart/bar.html" with data=quiz_scores_data.data height=300 %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-6 text-gray-800" %} + {% trans "Course Engagement Metrics" %} + {% endcomponent %} + +
    + +
    +
    + {% trans "Lesson Completion Rate" %} + {{ engagement_metrics.lesson_completion_rate }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.lesson_completion_rate class="h-2 bg-blue-200" bar_class="bg-blue-600" %} + {% endcomponent %} +

    {% trans "Average percentage of lessons completed by participants" %}

    +
    + + +
    +
    + {% trans "Quiz Participation Rate" %} + {{ engagement_metrics.quiz_participation_rate }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.quiz_participation_rate class="h-2 bg-purple-200" bar_class="bg-purple-600" %} + {% endcomponent %} +

    {% trans "Percentage of participants who attempted at least one quiz" %}

    +
    + + +
    +
    + {% trans "Average Quiz Score" %} + {{ engagement_metrics.average_quiz_score }}% +
    + {% component "unfold/components/progress.html" with value=engagement_metrics.average_quiz_score class="h-2 bg-green-200" bar_class="bg-green-600" %} + {% endcomponent %} +

    {% trans "Average score across all quizzes" %}

    +
    +
    +
    + {% endcomponent %} +
    + + +
    + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Top Performing Students" %} + {% endcomponent %} + +
    + + + + + + + + + + {% for student in top_students %} + + + + + + {% endfor %} + +
    + {% trans "Student" %} + + {% trans "Completion" %} + + {% trans "Avg. Score" %} +
    +
    +
    + {{ student.initials }} +
    +
    +
    {{ student.name }}
    +
    +
    +
    +
    {{ student.completion_percentage }}%
    +
    + + {{ student.average_score }}% + +
    +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Recent Activity" %} + {% endcomponent %} + +
    +
      + {% for activity in recent_activity %} +
    • +
      + {% if not forloop.last %} + + {% endif %} +
      +
      +
      + {% if activity.type == 'lesson_completion' %} + + + + {% elif activity.type == 'quiz_completion' %} + + + + {% elif activity.type == 'course_join' %} + + + + {% endif %} +
      +
      +
      +
      + {% if activity.type == 'lesson_completion' %} + {{ activity.student_name }} درس + {{ activity.lesson_title }} را تکمیل کرد + {{ activity.time_ago }} + {% elif activity.type == 'quiz_completion' %} + {{ activity.student_name }} در کوئیز + {{ activity.quiz_title }} نمره {{ activity.score }}% کسب کرد + {{ activity.time_ago }} + {% elif activity.type == 'course_join' %} + {{ activity.student_name }} به دوره پیوست + {{ activity.time_ago }} + {% endif %} +
      +
      +
      +
      +
    • + {% endfor %} +
    +
    +
    + {% endcomponent %} +
    +{% endcomponent %} \ No newline at end of file diff --git a/templates/course/course_stats.html b/templates/course/course_stats.html new file mode 100644 index 0000000..4785253 --- /dev/null +++ b/templates/course/course_stats.html @@ -0,0 +1,161 @@ +{% load i18n %} +{% load unfold %} +{% load course_tags %} + +{% component "unfold/components/container.html" %} + +
    +
    + {% component "unfold/components/title.html" with class="text-2xl font-bold text-gray-800" %} + {% trans "Course Overview" %} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm text-gray-600" %} + {% trans "Key metrics and statistics for this course" %} + {% endcomponent %} +
    +
    + + + + + {% trans "View Detailed Analytics" %} + + + + +
    +
    + + +
    + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-blue-50 to-blue-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-blue-700 m-0" %} + {{ original.lessons.count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-blue-800 mt-1 opacity-80" %} + {% trans "Total Lessons" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-purple-50 to-purple-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-purple-700 m-0" %} + {% get_course_quizzes_count original as quiz_count %} + {{ quiz_count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-purple-800 mt-1 opacity-80" %} + {% trans "Total Quizzes" %} + {% endcomponent %} +
    +
    + {% endcomponent %} + + + {% component "unfold/components/card.html" with class="bg-gradient-to-br from-green-50 to-green-100 border-none shadow-md hover:shadow-lg transition-all duration-300" %} +
    +
    + + + +
    +
    + {% component "unfold/components/title.html" with class="text-4xl font-bold text-green-700 m-0" %} + {{ original.participants.count }} + {% endcomponent %} + {% component "unfold/components/text.html" with class="text-sm font-medium text-green-800 mt-1 opacity-80" %} + {% trans "Active Participants" %} + {% endcomponent %} +
    +
    + {% endcomponent %} +
    + + +
    + {% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %} +
    + {% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %} + {% trans "Course Summary" %} + {% endcomponent %} + +
    +
    +
    + + + +
    +
    +

    {% trans "Course Status" %}

    +

    {{ original.get_status_display }}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Duration" %}

    +

    {{ original.duration }} {% trans "hours" %}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Level" %}

    +

    {{ original.get_level_display }}

    +
    +
    + +
    +
    + + + +
    +
    +

    {% trans "Price" %}

    +

    + {% if original.is_free %} + {% trans "Free" %} + {% else %} + {{ original.final_price }} + {% endif %} +

    +
    +
    +
    +
    + {% endcomponent %} +
    + + +
    + {% include "course/course_analytics.html" %} +
    +{% endcomponent %} \ No newline at end of file