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
-
312apps/hadis/admin/hadis.py
-
240apps/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
-
34config/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.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") |
|||
|
|||
|
|||
class MessageReadStatusInline(TabularInline): |
|||
model = MessageReadStatus |
|||
extra = 0 |
|||
fields = ('user', 'is_read', 'read_at') |
|||
readonly_fields = ('read_at',) |
|||
can_delete = False |
|||
show_change_link = True |
|||
classes = ['collapse'] |
|||
verbose_name = _("Read Status") |
|||
verbose_name_plural = _("Read Statuses") |
|||
|
|||
@admin.register(ChatMessage) |
|||
class ChatMessageAdmin(admin.ModelAdmin): |
|||
|
|||
class ChatMessageAdmin(ModelAdmin): |
|||
change_list_template = 'admin/chat/chatmessage/change_list.html' |
|||
list_display = ( |
|||
'room', 'sender', 'content_type', 'content_size', 'sent_at', 'is_deleted' |
|||
'id', 'room', 'sender', 'content_type_badge', 'content_preview', |
|||
'content_size_display', 'sent_at', 'is_deleted_status' |
|||
) |
|||
list_filter = ( |
|||
'room', |
|||
'content_type', |
|||
'is_deleted', |
|||
('sent_at', RangeDateTimeFilter), |
|||
('updated_at', RangeDateTimeFilter), |
|||
('content_size', RangeNumericFilter) |
|||
) |
|||
list_filter = ('content_type', 'is_deleted', 'sent_at', 'updated_at') |
|||
search_fields = ('room__name', 'sender__username', 'content') |
|||
ordering = ('-sent_at',) |
|||
readonly_fields = ('sent_at', 'updated_at') |
|||
readonly_fields = ('sent_at', 'updated_at', 'content_size') |
|||
inlines = [MessageReadStatusInline] |
|||
|
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('room', 'sender', 'content', 'content_type') |
|||
(_("Message Information"), { |
|||
'fields': ('room', 'sender', 'content', 'content_type'), |
|||
'classes': ('grid-col-2',), |
|||
}), |
|||
('Additional Info', { |
|||
'fields': ('content_size',) |
|||
(_("Additional Info"), { |
|||
'fields': ('content_size',), |
|||
'classes': ('grid-col-1',), |
|||
}), |
|||
('Status', { |
|||
'fields': ('is_deleted', 'deleted_at') |
|||
(_("Status"), { |
|||
'fields': ('is_deleted', 'deleted_at'), |
|||
'classes': ('grid-col-2',), |
|||
}), |
|||
('Timestamps', { |
|||
'fields': ('sent_at', 'updated_at') |
|||
(_("Timestamps"), { |
|||
'fields': ('sent_at', 'updated_at'), |
|||
'classes': ('grid-col-2',), |
|||
}), |
|||
) |
|||
|
|||
def content_preview(self, obj): |
|||
if obj.content_type == 'text': |
|||
preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content |
|||
return preview |
|||
return f"{obj.content_type} content" |
|||
|
|||
content_preview.short_description = _("Content Preview") |
|||
|
|||
def content_type_badge(self, obj): |
|||
badges = { |
|||
'text': ('blue', 'Text'), |
|||
'file': ('yellow', 'File'), |
|||
'audio': ('green', 'Audio'), |
|||
'image': ('pink', 'Image'), |
|||
} |
|||
color, label = badges.get(obj.content_type, ('gray', obj.content_type)) |
|||
return format_html( |
|||
'<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') |
|||
}), |
|||
) |
|||
# from django.contrib import admin |
|||
# from django.utils.translation import gettext_lazy as _ |
|||
# from dj_category.admin import BaseCategoryAdmin |
|||
# from ajaxdatatable.admin import AjaxDatatable |
|||
# from django.http import JsonResponse |
|||
# from django.urls import path |
|||
# from django.db.models import Q |
|||
# from django.utils.safestring import mark_safe |
|||
# from django.forms.widgets import RadioSelect |
|||
|
|||
# from apps.hadis.models import * |
|||
# from django import forms |
|||
# from utils.json_editor_field import JsonEditorWidget |
|||
|
|||
# # Define color choices |
|||
# COLOR_CHOICES = [ |
|||
# ('red', _('Red')), |
|||
# ('blue', _('Blue')), |
|||
# ('green', _('Green')), |
|||
# ('yellow', _('Yellow')), |
|||
# ('orange', _('Orange')), |
|||
# ('purple', _('Purple')), |
|||
# ('pink', _('Pink')), |
|||
# ('brown', _('Brown')), |
|||
# ('gray', _('Gray')), |
|||
# ('black', _('Black')), |
|||
# ] |
|||
|
|||
# class ColorRadioSelect(RadioSelect): |
|||
# template_name = 'admin/widgets/color_radio.html' |
|||
# option_template_name = 'admin/widgets/color_radio_option.html' |
|||
|
|||
|
|||
# def get_links_schema(): |
|||
# return { |
|||
# 'type': "array", |
|||
# 'format': 'table', |
|||
# 'title': ' ', |
|||
# 'items': { |
|||
# 'type': 'object', |
|||
# 'title': str(_('Link')), |
|||
# 'properties': { |
|||
# 'text': {'type': 'string', "format": "textarea",'title': str(_('text'))}, |
|||
# 'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))}, |
|||
# } |
|||
# } |
|||
# } |
|||
|
|||
# class HadisOverviewForm(forms.ModelForm): |
|||
# status_color = forms.ChoiceField( |
|||
# choices=COLOR_CHOICES, |
|||
# widget=ColorRadioSelect(), |
|||
# required=False |
|||
# ) |
|||
|
|||
# class Meta: |
|||
# model = HadisOverview |
|||
# fields = '__all__' |
|||
# widgets = { |
|||
# 'links': JsonEditorWidget(attrs={'schema': get_links_schema}), |
|||
# } |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
# @admin.register(HadisTag) |
|||
# class HadisTagAdmin(AjaxDatatable): |
|||
# list_display = ['title', 'status'] |
|||
# search_fields = ['title'] |
|||
|
|||
|
|||
# class ReferenceImageInline(admin.TabularInline): |
|||
# model = ReferenceImage |
|||
# extra = 1 |
|||
# verbose_name_plural = _('Reference Images') |
|||
# fields = ('thumbnail', 'priority') |
|||
|
|||
|
|||
# @admin.register(HadisReference) |
|||
# class HadisReferenceAdmin(AjaxDatatable): |
|||
# list_display = ['hadis', 'book', 'created_at'] |
|||
# list_filter = ['book'] |
|||
# search_fields = ['hadis__title', 'hadis__number', 'description'] |
|||
# autocomplete_fields = ['hadis', 'book'] |
|||
# readonly_fields = ['created_at'] |
|||
# inlines = [ReferenceImageInline] |
|||
# fieldsets = ( |
|||
# (None, { |
|||
# 'fields': ('hadis', 'book', 'description') |
|||
# }), |
|||
# ) |
|||
|
|||
|
|||
|
|||
|
|||
@admin.register(HadisOverview) |
|||
class HadisOverviewAdmin(AjaxDatatable): |
|||
change_form_template = 'admin/hadisowerview_change_form.html' |
|||
form = HadisOverviewForm |
|||
ordering = ['hadis__number'] |
|||
list_display = ['hadis', 'status', 'created_at'] |
|||
search_fields = ['hadis__title', 'hadis__number', 'status_text',] |
|||
autocomplete_fields = ['hadis', 'tags'] |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('hadis', 'status', 'status_color', 'status_text') |
|||
}), |
|||
(_('Reference Information'), { |
|||
'fields': ('address', 'share_link',), |
|||
}), |
|||
(_('Additional Information'), { |
|||
'fields': ('links', 'tags', 'created_at'), |
|||
'classes': ('collapse',), |
|||
}), |
|||
) |
|||
|
|||
|
|||
class HadisOverviewInline(admin.StackedInline): |
|||
change_form_template = 'admin/hadisowerview_change_form.html' |
|||
form = HadisOverviewForm |
|||
model = HadisOverview |
|||
autocomplete_fields = ['tags', ] |
|||
can_delete = False |
|||
verbose_name_plural = _('Hadis Overview') |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), |
|||
}), |
|||
) |
|||
extra = 1 |
|||
min_num = 1 |
|||
|
|||
|
|||
@admin.register(Hadis) |
|||
class HadisAdmin(AjaxDatatable): |
|||
# form = HadisForm |
|||
list_display = ['number', 'title', 'category', 'status', 'created_at'] |
|||
list_filter = ['status', 'category'] |
|||
search_fields = ['title', 'text', 'number'] |
|||
readonly_fields = ['created_at', 'updated_at'] |
|||
autocomplete_fields = ['category'] |
|||
inlines = [HadisOverviewInline] |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('number', 'title', 'category', 'status') |
|||
}), |
|||
(_('Content'), { |
|||
'fields': ('text', 'translation'), |
|||
'classes': ('collapse',), |
|||
}), |
|||
) |
|||
|
|||
|
|||
def get_form(self, request, obj=None, **kwargs): |
|||
form = super().get_form(request, obj, **kwargs) |
|||
if obj is None: |
|||
form.base_fields['category'].widget.can_add_related = False |
|||
|
|||
return form |
|||
# @admin.register(HadisOverview) |
|||
# class HadisOverviewAdmin(AjaxDatatable): |
|||
# change_form_template = 'admin/hadisowerview_change_form.html' |
|||
# form = HadisOverviewForm |
|||
# ordering = ['hadis__number'] |
|||
# list_display = ['hadis', 'status', 'created_at'] |
|||
# search_fields = ['hadis__title', 'hadis__number', 'status_text',] |
|||
# autocomplete_fields = ['hadis', 'tags'] |
|||
# fieldsets = ( |
|||
# (None, { |
|||
# 'fields': ('hadis', 'status', 'status_color', 'status_text') |
|||
# }), |
|||
# (_('Reference Information'), { |
|||
# 'fields': ('address', 'share_link',), |
|||
# }), |
|||
# (_('Additional Information'), { |
|||
# 'fields': ('links', 'tags', 'created_at'), |
|||
# 'classes': ('collapse',), |
|||
# }), |
|||
# ) |
|||
|
|||
|
|||
# class HadisOverviewInline(admin.StackedInline): |
|||
# change_form_template = 'admin/hadisowerview_change_form.html' |
|||
# form = HadisOverviewForm |
|||
# model = HadisOverview |
|||
# autocomplete_fields = ['tags', ] |
|||
# can_delete = False |
|||
# verbose_name_plural = _('Hadis Overview') |
|||
# fieldsets = ( |
|||
# (None, { |
|||
# 'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), |
|||
# }), |
|||
# ) |
|||
# extra = 1 |
|||
# min_num = 1 |
|||
|
|||
|
|||
# @admin.register(Hadis) |
|||
# class HadisAdmin(AjaxDatatable): |
|||
# # form = HadisForm |
|||
# list_display = ['number', 'title', 'category', 'status', 'created_at'] |
|||
# list_filter = ['status', 'category'] |
|||
# search_fields = ['title', 'text', 'number'] |
|||
# readonly_fields = ['created_at', 'updated_at'] |
|||
# autocomplete_fields = ['category'] |
|||
# inlines = [HadisOverviewInline] |
|||
# fieldsets = ( |
|||
# (None, { |
|||
# 'fields': ('number', 'title', 'category', 'status') |
|||
# }), |
|||
# (_('Content'), { |
|||
# 'fields': ('text', 'translation'), |
|||
# 'classes': ('collapse',), |
|||
# }), |
|||
# ) |
|||
|
|||
|
|||
# def get_form(self, request, obj=None, **kwargs): |
|||
# form = super().get_form(request, obj, **kwargs) |
|||
# if obj is None: |
|||
# form.base_fields['category'].widget.can_add_related = False |
|||
|
|||
# return form |
|||
|
|||
@ -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