Browse Source

feat: library models

master
alireza 1 year ago
parent
commit
0e087e014d
  1. 0
      apps/bookmark/__init__.py
  2. 11
      apps/bookmark/admin.py
  3. 6
      apps/bookmark/apps.py
  4. 34
      apps/bookmark/migrations/0001_initial.py
  5. 0
      apps/bookmark/migrations/__init__.py
  6. 76
      apps/bookmark/models.py
  7. 76
      apps/bookmark/serializers.py
  8. 3
      apps/bookmark/tests.py
  9. 10
      apps/bookmark/urls.py
  10. 128
      apps/bookmark/views.py
  11. 196
      apps/chat/admin.py
  12. 0
      apps/dobodbi_calendar/__init__.py
  13. 3
      apps/dobodbi_calendar/admin.py
  14. 6
      apps/dobodbi_calendar/apps.py
  15. 0
      apps/dobodbi_calendar/migrations/__init__.py
  16. 39
      apps/dobodbi_calendar/models.py
  17. 3
      apps/dobodbi_calendar/tests.py
  18. 3
      apps/dobodbi_calendar/views.py
  19. 320
      apps/hadis/admin/hadis.py
  20. 236
      apps/library/admin.py
  21. 31
      apps/library/doc.py
  22. 18
      apps/library/migrations/0003_bookcollection_pin_top.py
  23. 19
      apps/library/migrations/0004_bookcollection_slug.py
  24. 40
      apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py
  25. 50
      apps/library/models.py
  26. 23
      apps/library/pagination.py
  27. 80
      apps/library/serializers.py
  28. 19
      apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html
  29. 4
      apps/library/urls.py
  30. 126
      apps/library/views.py
  31. 2
      apps/quiz/models/participant.py
  32. 95
      config/settings/base.py
  33. 34
      config/test_auth_middleware.py
  34. 2
      config/urls.py
  35. 12
      templates/admin/chat/chatmessage/change_list.html
  36. 283
      templates/course/course_analytics.html
  37. 161
      templates/course/course_stats.html

0
apps/bookmark/__init__.py

11
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'

6
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'

34
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')},
},
),
]

0
apps/bookmark/migrations/__init__.py

76
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

76
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
}

3
apps/bookmark/tests.py

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
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'),
]

128
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)

196
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('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Read</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Unread</span>')
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('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">{}</span>', count)
messages_count.short_description = _("Messages Count")
def room_type_badge(self, obj):
if obj.room_type == 'group':
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">Group</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">Private</span>')
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(
'<a href="{}" class="inline-flex items-center px-3 py-1.5 rounded text-xs font-medium bg-blue-500 text-white hover:bg-blue-600">'
'<span class="material-icons-outlined mr-1" style="font-size: 14px;">chat</span> {}</a>',
url, _("View Messages")
)
view_messages_button.short_description = _("Messages")
@admin.register(ChatMessage)
class ChatMessageAdmin(admin.ModelAdmin):
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")
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(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{}-100 text-{}-800">{}</span>',
color, color, label
)
content_type_badge.short_description = _("Type")
def is_deleted_status(self, obj):
if obj.is_deleted:
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Deleted</span>')
return format_html('<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>')
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)

0
apps/dobodbi_calendar/__init__.py

3
apps/dobodbi_calendar/admin.py

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
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'

0
apps/dobodbi_calendar/migrations/__init__.py

39
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'))

3
apps/dobodbi_calendar/tests.py

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
apps/dobodbi_calendar/views.py

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

320
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')
}),
)
@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
# 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

236
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,12 +96,23 @@ 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(
# '<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">{}</span>',
# _("Active")
# )
# return format_html(
# '<span class="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-medium">{}</span>',
# _("Inactive")
# )
@display(description=_('Number of Books'))
def count_books(self, obj):
count = obj.books.count()
if count > 0:
@ -70,9 +125,19 @@ class BookCollectionAdminBase(AjaxDatatable):
@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,97 +156,61 @@ 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',)
list_filter_submit = True
warn_unsaved_form = True
change_form_show_cancel_button = True
@admin.display(description=_('Number of Books'))
# 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(
'<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">{}</span>',
_("Active")
)
return format_html(
'<span class="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-medium">{}</span>',
_("Inactive")
)
@display(description=_('Number of Books'))
def count_books(self, obj):
count = obj.books_count
if count > 0:
@ -190,3 +218,27 @@ class CategoryAdmin(AjaxDatatable):
return format_html('<a href="{}">{}</a>', 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)

31
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.",
@ -218,3 +227,23 @@ pinned_collection_list_swagger = swagger_auto_schema(
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."
}
)

18
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'),
),
]

19
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,
),
]

40
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'),
),
]

50
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}"

23
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,
})

80
apps/library/serializers.py

@ -6,17 +6,24 @@ 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):
covers = serializers.SerializerMethodField()
@ -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

19
apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html

@ -0,0 +1,19 @@
{% load static %}
<div class="mb-1 rounded-lg overflow-hidden shadow-lg transition-all duration-200 hover:shadow-xl">
<div class="relative ">
<img
src="{% static 'images/banner2.png' %}"
alt="Pinned Book Collections Banner"
class="w-full h-auto object-cover transition-transform duration-1000 hover:scale-410"
style="max-height: 250px; transform: scale(1.2); object-position: center;"
/>
{% comment %} <div class="absolute inset-0 bg-gradient-to-r from-black/50 to-transparent dark:from-black/70 flex items-center"> {% endcomment %}
{% comment %} <div class="px-6 py-4"> {% endcomment %}
{% comment %} <h2 class="text-white text-2xl font-bold mb-2 drop-shadow-lg">Pinned Book Collections</h2> {% endcomment %}
{% comment %} <p class="text-white/90 dark:text-white/80 drop-shadow-md"> {% endcomment %}
{% comment %} </p> {% endcomment %}
{% comment %} </div> {% endcomment %}
{% comment %} </div> {% endcomment %}
</div>
</div>

4
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/<int:pk>/', BookDetailView.as_view(), name='book-detail'),
path('books/download/', BookDownloadCreateAPIView.as_view(), name='book-download'),
]

126
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)

2
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

95
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": "Global Preferences",
"icon": "settings", # You can choose an appropriate icon
"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": _("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,

34
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)

2
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')),

12
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 }}
<li>
<a href="{% url 'admin:chat_roommessage_changelist' %}" class="unfold-button unfold-button-secondary">
<span class="material-icons-outlined">arrow_back</span>
{% translate "Back to Chat Rooms" %}
</a>
</li>
{% endblock %}

283
templates/course/course_analytics.html

@ -0,0 +1,283 @@
{% load i18n %}
{% load unfold %}
{% component "unfold/components/container.html" with component_class="CourseAnalyticsComponent" %}
<!-- Analytics Dashboard Header -->
<div class="flex justify-between items-center mb-8">
<div>
{% 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 %}
</div>
<div class="flex space-x-2">
<button class="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium flex items-center shadow-sm transition-all duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{% trans "Export" %}
</button>
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center shadow-sm transition-all duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{% trans "Refresh Data" %}
</button>
</div>
</div>
<!-- Basic Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Course Lessons Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
<!-- Course Quizzes Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-purple-500 to-purple-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
<!-- Course Participants Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-green-500 to-green-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<!-- Lesson Completion Chart -->
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-2">
{% 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 %}
<div class="bg-gray-50 p-4 rounded-lg">
{% component "unfold/components/chart/bar.html" with data=completion_data.data height=300 %}
{% endcomponent %}
</div>
</div>
{% endcomponent %}
<!-- Quiz Scores Chart -->
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-2">
{% 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 %}
<div class="bg-gray-50 p-4 rounded-lg">
{% component "unfold/components/chart/bar.html" with data=quiz_scores_data.data height=300 %}
{% endcomponent %}
</div>
</div>
{% endcomponent %}
</div>
<!-- Progress Bars Section -->
<div class="grid grid-cols-1 gap-6 mb-8">
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-4">
{% component "unfold/components/title.html" with class="text-xl font-bold mb-6 text-gray-800" %}
{% trans "Course Engagement Metrics" %}
{% endcomponent %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Lesson Completion Rate -->
<div class="bg-blue-50 p-4 rounded-lg border border-blue-100">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-blue-800">{% trans "Lesson Completion Rate" %}</span>
<span class="text-xl font-bold text-blue-700">{{ engagement_metrics.lesson_completion_rate }}%</span>
</div>
{% component "unfold/components/progress.html" with value=engagement_metrics.lesson_completion_rate class="h-2 bg-blue-200" bar_class="bg-blue-600" %}
{% endcomponent %}
<p class="text-xs text-blue-700 mt-2">{% trans "Average percentage of lessons completed by participants" %}</p>
</div>
<!-- Quiz Participation Rate -->
<div class="bg-purple-50 p-4 rounded-lg border border-purple-100">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-purple-800">{% trans "Quiz Participation Rate" %}</span>
<span class="text-xl font-bold text-purple-700">{{ engagement_metrics.quiz_participation_rate }}%</span>
</div>
{% component "unfold/components/progress.html" with value=engagement_metrics.quiz_participation_rate class="h-2 bg-purple-200" bar_class="bg-purple-600" %}
{% endcomponent %}
<p class="text-xs text-purple-700 mt-2">{% trans "Percentage of participants who attempted at least one quiz" %}</p>
</div>
<!-- Average Quiz Score -->
<div class="bg-green-50 p-4 rounded-lg border border-green-100">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-green-800">{% trans "Average Quiz Score" %}</span>
<span class="text-xl font-bold text-green-700">{{ engagement_metrics.average_quiz_score }}%</span>
</div>
{% component "unfold/components/progress.html" with value=engagement_metrics.average_quiz_score class="h-2 bg-green-200" bar_class="bg-green-600" %}
{% endcomponent %}
<p class="text-xs text-green-700 mt-2">{% trans "Average score across all quizzes" %}</p>
</div>
</div>
</div>
{% endcomponent %}
</div>
<!-- Additional Insights Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<!-- Top Performing Students -->
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-4">
{% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %}
{% trans "Top Performing Students" %}
{% endcomponent %}
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Student" %}
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Completion" %}
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Avg. Score" %}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for student in top_students %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-8 w-8 bg-{{ student.color }}-100 rounded-full flex items-center justify-center">
<span class="text-{{ student.color }}-600 font-medium">{{ student.initials }}</span>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">{{ student.name }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ student.completion_percentage }}%</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{{ student.average_score }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endcomponent %}
<!-- Recent Activity -->
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-4">
{% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %}
{% trans "Recent Activity" %}
{% endcomponent %}
<div class="flow-root">
<ul role="list" class="-mb-8">
{% for activity in recent_activity %}
<li>
<div class="relative pb-8">
{% if not forloop.last %}
<span class="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200" aria-hidden="true"></span>
{% endif %}
<div class="relative flex items-start space-x-3">
<div class="relative">
<div class="h-10 w-10 rounded-full bg-{{ activity.color }}-500 flex items-center justify-center ring-8 ring-white">
{% if activity.type == 'lesson_completion' %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% elif activity.type == 'quiz_completion' %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{% elif activity.type == 'course_join' %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endif %}
</div>
</div>
<div class="min-w-0 flex-1 py-1.5">
<div class="text-sm text-gray-500">
{% if activity.type == 'lesson_completion' %}
<span class="font-medium text-gray-900">{{ activity.student_name }}</span> درس
<span class="font-medium text-gray-900">{{ activity.lesson_title }}</span> را تکمیل کرد
<span class="whitespace-nowrap text-xs">{{ activity.time_ago }}</span>
{% elif activity.type == 'quiz_completion' %}
<span class="font-medium text-gray-900">{{ activity.student_name }}</span> در کوئیز
<span class="font-medium text-gray-900">{{ activity.quiz_title }}</span> نمره {{ activity.score }}% کسب کرد
<span class="whitespace-nowrap text-xs">{{ activity.time_ago }}</span>
{% elif activity.type == 'course_join' %}
<span class="font-medium text-gray-900">{{ activity.student_name }}</span> به دوره پیوست
<span class="whitespace-nowrap text-xs">{{ activity.time_ago }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endcomponent %}
</div>
{% endcomponent %}

161
templates/course/course_stats.html

@ -0,0 +1,161 @@
{% load i18n %}
{% load unfold %}
{% load course_tags %}
{% component "unfold/components/container.html" %}
<!-- Header with Analytics Button -->
<div class="flex justify-between items-center mb-6">
<div>
{% 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 %}
</div>
<div>
<a href="#course-analytics" class="group inline-flex items-center px-5 py-2.5 bg-gradient-to-r from-blue-600 to-indigo-600 text-white text-sm font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-0.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 group-hover:animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{% trans "View Detailed Analytics" %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1 transition-transform duration-300 group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
<!-- Stats Cards with Improved Design -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<!-- Course Lessons Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
<!-- Course Quizzes Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-purple-500 to-purple-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
<!-- Course Participants Count -->
{% 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" %}
<div class="flex items-center p-2">
<div class="bg-gradient-to-br from-green-500 to-green-600 text-white rounded-xl p-4 mr-5 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex flex-col">
{% 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 %}
</div>
</div>
{% endcomponent %}
</div>
<!-- Quick Summary Card -->
<div class="mb-10">
{% component "unfold/components/card.html" with class="border border-gray-200 shadow-md hover:shadow-lg transition-all duration-300" %}
<div class="p-4">
{% component "unfold/components/title.html" with class="text-xl font-bold mb-4 text-gray-800" %}
{% trans "Course Summary" %}
{% endcomponent %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-start">
<div class="flex-shrink-0 h-10 w-10 rounded-md bg-blue-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-900">{% trans "Course Status" %}</h3>
<p class="mt-1 text-sm text-gray-600">{{ original.get_status_display }}</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 h-10 w-10 rounded-md bg-purple-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-900">{% trans "Duration" %}</h3>
<p class="mt-1 text-sm text-gray-600">{{ original.duration }} {% trans "hours" %}</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 h-10 w-10 rounded-md bg-green-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-900">{% trans "Level" %}</h3>
<p class="mt-1 text-sm text-gray-600">{{ original.get_level_display }}</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 h-10 w-10 rounded-md bg-yellow-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-900">{% trans "Price" %}</h3>
<p class="mt-1 text-sm text-gray-600">
{% if original.is_free %}
<span class="text-green-600 font-medium">{% trans "Free" %}</span>
{% else %}
{{ original.final_price }}
{% endif %}
</p>
</div>
</div>
</div>
</div>
{% endcomponent %}
</div>
<!-- Detailed Analytics Section -->
<div id="course-analytics" class="mt-12 pt-8 border-t border-gray-200">
{% include "course/course_analytics.html" %}
</div>
{% endcomponent %}
Loading…
Cancel
Save