37 changed files with 1813 additions and 332 deletions
-
0apps/bookmark/__init__.py
-
11apps/bookmark/admin.py
-
6apps/bookmark/apps.py
-
34apps/bookmark/migrations/0001_initial.py
-
0apps/bookmark/migrations/__init__.py
-
76apps/bookmark/models.py
-
76apps/bookmark/serializers.py
-
3apps/bookmark/tests.py
-
10apps/bookmark/urls.py
-
128apps/bookmark/views.py
-
196apps/chat/admin.py
-
0apps/dobodbi_calendar/__init__.py
-
3apps/dobodbi_calendar/admin.py
-
6apps/dobodbi_calendar/apps.py
-
0apps/dobodbi_calendar/migrations/__init__.py
-
39apps/dobodbi_calendar/models.py
-
3apps/dobodbi_calendar/tests.py
-
3apps/dobodbi_calendar/views.py
-
320apps/hadis/admin/hadis.py
-
230apps/library/admin.py
-
31apps/library/doc.py
-
18apps/library/migrations/0003_bookcollection_pin_top.py
-
19apps/library/migrations/0004_bookcollection_slug.py
-
40apps/library/migrations/0005_bookdownload_delete_bottombookcollection_and_more.py
-
50apps/library/models.py
-
23apps/library/pagination.py
-
80apps/library/serializers.py
-
19apps/library/templates/admin/library/pinnedbookcollection/change_list_before.html
-
4apps/library/urls.py
-
126apps/library/views.py
-
2apps/quiz/models/participant.py
-
95config/settings/base.py
-
24config/test_auth_middleware.py
-
2config/urls.py
-
12templates/admin/chat/chatmessage/change_list.html
-
283templates/course/course_analytics.html
-
161templates/course/course_stats.html
@ -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' |
||||
@ -0,0 +1,6 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class BookmarkConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.bookmark' |
||||
@ -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,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 |
||||
@ -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 |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -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'), |
||||
|
] |
||||
@ -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) |
||||
@ -1,59 +1,201 @@ |
|||||
from django.contrib import admin |
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 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 = ( |
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 = ( |
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') |
search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username') |
||||
ordering = ('-created_at',) |
ordering = ('-created_at',) |
||||
readonly_fields = ('created_at', 'updated_at') |
|
||||
|
readonly_fields = ('created_at', 'updated_at', 'messages_count') |
||||
|
inlines = [ChatMessageInline] |
||||
|
|
||||
fieldsets = ( |
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 = ( |
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') |
search_fields = ('room__name', 'sender__username', 'content') |
||||
ordering = ('-sent_at',) |
ordering = ('-sent_at',) |
||||
readonly_fields = ('sent_at', 'updated_at') |
|
||||
|
readonly_fields = ('sent_at', 'updated_at', 'content_size') |
||||
|
inlines = [MessageReadStatusInline] |
||||
|
|
||||
fieldsets = ( |
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,0 +1,3 @@ |
|||||
|
from django.contrib import admin |
||||
|
|
||||
|
# Register your models here. |
||||
@ -0,0 +1,6 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class DobodbiCalendarConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.dobodbi_calendar' |
||||
@ -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')) |
||||
|
|
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.shortcuts import render |
||||
|
|
||||
|
# Create your views here. |
||||
@ -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 |
||||
|
|
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -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, |
||||
|
), |
||||
|
] |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -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, |
||||
|
}) |
||||
@ -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> |
||||
@ -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 %} |
||||
@ -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 %} |
||||
@ -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 %} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue