Browse Source

develop dovoodi

master
alireza 1 year ago
parent
commit
0317864c49
  1. 73
      apps/account/serializers/user.py
  2. 46
      apps/account/views/user.py
  3. 0
      apps/hadis/__init__.py
  4. 3
      apps/hadis/admin/__init__.py
  5. 217
      apps/hadis/admin/category.py
  6. 161
      apps/hadis/admin/hadis.py
  7. 0
      apps/hadis/admin/transmitter.py
  8. 6
      apps/hadis/apps.py
  9. 159
      apps/hadis/doc.py
  10. 87
      apps/hadis/migrations/0001_initial.py
  11. 22
      apps/hadis/migrations/0002_auto_20250317_0055.py
  12. 23
      apps/hadis/migrations/0003_auto_20250317_0102.py
  13. 121
      apps/hadis/migrations/0004_auto_20250321_0119.py
  14. 21
      apps/hadis/migrations/0005_auto_20250321_1550.py
  15. 23
      apps/hadis/migrations/0006_auto_20250321_1600.py
  16. 0
      apps/hadis/migrations/__init__.py
  17. 3
      apps/hadis/models/__init__.py
  18. 105
      apps/hadis/models/category.py
  19. 102
      apps/hadis/models/hadis.py
  20. 52
      apps/hadis/models/transmitter.py
  21. 50
      apps/hadis/serializers.py
  22. 2343
      apps/hadis/templates/admin/category_index.html
  23. 42
      apps/hadis/templates/admin/hadiscategory/change_form.html
  24. 153
      apps/hadis/templates/admin/hadisowerview_change_form.html
  25. 7
      apps/hadis/templates/admin/widgets/color_radio.html
  26. 9
      apps/hadis/templates/admin/widgets/color_radio_option.html
  27. 3
      apps/hadis/tests.py
  28. 9
      apps/hadis/urls.py
  29. 3
      apps/hadis/views/__init__.py
  30. 301
      apps/hadis/views/category.py
  31. 29
      apps/hadis/views/hadis.py
  32. 0
      apps/library/__init__.py
  33. 192
      apps/library/admin.py
  34. 9
      apps/library/apps.py
  35. 140
      apps/library/migrations/0001_initial.py
  36. 18
      apps/library/migrations/0002_alter_bookcollection_title.py
  37. 26
      apps/library/migrations/0003_auto_20250321_0119.py
  38. 0
      apps/library/migrations/__init__.py
  39. 132
      apps/library/models.py
  40. 33
      apps/library/serializers.py
  41. 3
      apps/library/tests.py
  42. 22
      apps/library/views.py
  43. 0
      apps/podcast/__init__.py
  44. 23
      apps/podcast/admin.py
  45. 8
      apps/podcast/apps.py
  46. 0
      apps/podcast/migrations/__init__.py
  47. 71
      apps/podcast/models.py
  48. 3
      apps/podcast/tests.py
  49. 3
      apps/podcast/views.py
  50. 0
      apps/video/__init__.py
  51. 23
      apps/video/admin.py
  52. 6
      apps/video/apps.py
  53. 0
      apps/video/migrations/__init__.py
  54. 71
      apps/video/models.py
  55. 3
      apps/video/tests.py
  56. 3
      apps/video/views.py
  57. 3
      config/settings/base.py
  58. 1
      config/urls.py
  59. 20
      test.py
  60. 2
      utils/redis.py

73
apps/account/serializers/user.py

@ -43,19 +43,16 @@ class UserProfileSerializer(serializers.ModelSerializer):
class UserRegisterSerializer(serializers.ModelSerializer):
password_confirmation = serializers.CharField(write_only=True)
fcm = serializers.CharField(required=False)
device_id = serializers.CharField(required=True)
email = serializers.EmailField()
class Meta:
model = User
fields = ['id','fullname', 'email', 'password', 'password_confirmation', 'fcm', 'device_id']
fields = ['id','fullname', 'email', 'fcm', 'device_id']
extra_kwargs = {
'fullname': {'required': True,},
'email': {'required': True,},
'password': {'required': True,},
'password_confirmation': {'required': True,},
'device_id': {'required': True,},
}
@ -65,35 +62,15 @@ class UserRegisterSerializer(serializers.ModelSerializer):
return value
def validate(self, data):
password = data.get('password')
password_confirmation = data.get('password_confirmation')
errors = {}
if password and password_confirmation and password != password_confirmation:
raise serializers.ValidationError({"password_confirmation": "Passwords do not match."})
if len(password) < 8:
raise serializers.ValidationError({"password": "Password must be at least 8 characters long."})
# If there are any errors, raise ValidationError
data.pop('password_confirmation', None)
return data
class UserVerifySerializer(serializers.ModelSerializer):
class UserVerifySerializer(serializers.Serializer):
code = serializers.CharField(max_length=5, validators=[validate_type_code])
email = serializers.EmailField()
device_id = serializers.CharField(max_length=255, required=False)
class Meta:
model = User
fields = ["email", "code"]
extra_kwargs = {
'email': {'required': True,},
'code': {'required': True,},
}
class UserLoginSerializer(serializers.ModelSerializer):
class UserLoginSerializer(serializers.Serializer):
password = serializers.CharField(write_only=True)
token = serializers.CharField(allow_null=True, read_only=True, required=False)
fullname = serializers.CharField(allow_null=True, read_only=True, required=False)
@ -104,20 +81,23 @@ class UserLoginSerializer(serializers.ModelSerializer):
device_id = serializers.CharField(required=False)
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class Meta:
model = User
fields = ['id', 'phone_number', 'password', 'fullname', 'avatar', 'email', 'token', 'fcm', 'device_id', 'timezone']
def get_token(self, obj):
token, created = Token.objects.get_or_create(user=obj)
return token.key
def validate(self, data):
data.pop('fcm', None)
data.pop('device_id', None)
# Custom validation logic can be added here if needed
# data.pop('fcm', None)
# data.pop('device_id', None)
return data
# class UserLoginSerializer(serializers.Serializer):
# password = serializers.CharField(write_only=True)
# token = serializers.CharField(allow_null=True, read_only=True, required=False)
# fullname = serializers.CharField(allow_null=True, read_only=True, required=False)
# avatar = serializers.CharField(allow_null=True, read_only=True, required=False)
# email = serializers.EmailField(write_only=True)
# password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False)
# fcm = serializers.CharField(required=False)
# device_id = serializers.CharField(required=False)
# timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
@ -134,30 +114,15 @@ class UserRecoverPasswordSerializer(serializers.ModelSerializer):
class UserResetPasswordSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
password_confirmation = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['password', 'password_confirmation']
fields = ['password', ]
extra_kwargs = {
'password': {'required': True,},
'password_confirmation': {'required': True,},
}
def validate(self, data):
password = data.get('password')
password_confirmation = data.get('password_confirmation')
errors = {}
if password and password_confirmation and password != password_confirmation:
raise serializers.ValidationError({"password_confirmation": "Passwords do not match."})
if len(password) < 8:
raise serializers.ValidationError({"password": "Password must be at least 8 characters long."})
# If there are any errors, raise ValidationError
data.pop('password_confirmation', None)
return data
class UserGuestSerializer(serializers.ModelSerializer):

46
apps/account/views/user.py

@ -41,7 +41,18 @@ class UserGuestView(CreateAPIView):
@swagger_auto_schema(
operation_description="Create a guest user account with device information",
request_body=UserGuestSerializer,
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"),
"fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""),
"device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"),
"lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"),
"lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"),
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"),
},
required=["device_id"],
),
)
def post(self, request, *args, **kwargs):
logger.info(f'GuestAuthView--> {request.data}')
@ -75,7 +86,8 @@ class UserGuestView(CreateAPIView):
device_id = serializer.validated_data.get('device_id')
device_os = serializer.validated_data.get('device_os')
fcm = serializer.validated_data.get('fcm')
lat, lon = serializer.validated_data.get('lat'), serializer.validated_data.get('lon')
lat = serializer.validated_data.pop('lat', None)
lon = serializer.validated_data.pop('lon', None)
user_timezone = serializer.validated_data.pop('timezone', None)
serializer_data = dict(serializer.validated_data)
@ -122,7 +134,6 @@ class UserRegisterView(CreateAPIView):
phone_number = RedisManager().add_to_redis(code, **data)
send_email([data['email']], code)
password = data.pop('password')
return Response(
data= {
"user": data,
@ -142,12 +153,14 @@ class UserVerifyView(CreateAPIView):
request_body=UserVerifySerializer,
)
def post(self, request, *args, **kwargs):
print(f'-UserVerifyView-> {request.data}')
return super().post(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.data
print(f'--UserVerifyView---1--')
try:
verify_data = RedisManager().get_by_redis(data['email'])
if not verify_data:
@ -159,18 +172,16 @@ class UserVerifyView(CreateAPIView):
# raise ExpiredCodeException("The verification code has expired.")
raise ValidationError({"code": "The verification code has expired."})
code = self.valied_code(data['code'], verify_data['code'])
del verify_data['code']
user = self.perform_create(
email=serializer.data['email'],**verify_data
email=serializer.data['email'], device_id=serializer.data['device_id'], **verify_data
)
# Token.objects.filter(user=user).delete()
token = Token.objects.get_or_create(user=user)
token, _ = Token.objects.get_or_create(user=user)
return Response(data={
'token': str(token),
'token': str(token.key),
'user_id': user.id,
'phone_number': str(user.phone_number),
'phone_number': str(user.phone_number) if user.phone_number else None,
'email': str(user.email),
'fullname': str(user.fullname),
'avatar': str(user.avatar) if user.avatar else None
@ -184,20 +195,23 @@ class UserVerifyView(CreateAPIView):
def perform_create(self, *args, **kwargs):
email = kwargs.get('email')
device_id = kwargs.get('device_id')
user = User.objects.filter(email=email).first()
if user:
if kwargs['password']:
user.is_active = True
user.deletion_date = None
user.device_id = device_id
user.last_login = timezone.now()
user.set_password(kwargs['password'])
user.save()
else:
user = User.objects.filter(device_id=kwargs['device_id']).first()
user = User.objects.filter(device_id=device_id, email__isnull=True).first()
if not user:
user = User.objects.create(**kwargs)
user.set_password(kwargs['password'])
else:
user.email = email
user.fullname = kwargs['fullname']
user.device_id = device_id
user.last_login = timezone.now()
user.is_active = True
user.save()
@ -215,8 +229,6 @@ class UserLoginView(CreateAPIView):
)
def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
@staticmethod
def get_client_ip(self):
request = self.request
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
@ -241,7 +253,7 @@ class UserLoginView(CreateAPIView):
serializer_data = serializer.data
serializer_data['token'] = token.key
login_history_obj = obj.login_history.create(
login_history_obj = user.login_history.create(
ip=self.get_client_ip(),
timezone=user_timezone,
)
@ -295,7 +307,7 @@ class UserRecoverPassword(CreateAPIView):
data= {
"id": user.id,
"fullname": user.fullname,
"phone_number": str(user.phone_number),
"phone_number": str(user.phone_number) if user.phone_number else None,
"email": user.email if user.email else None,
"avatar": user.avatar if user.avatar else None,
"message": "Forgot password code sent"

0
apps/hadis/__init__.py

3
apps/hadis/admin/__init__.py

@ -0,0 +1,3 @@
from .category import *
from .hadis import *
from .transmitter import *

217
apps/hadis/admin/category.py

@ -0,0 +1,217 @@
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 apps.hadis.models import *
from django import forms
from django.db.models import Case, When, Value
@admin.register(HadisCategory)
class HadisCategoryAdmin(BaseCategoryAdmin):
change_form_template = 'admin/hadiscategory/change_form.html'
change_list_template = 'admin/category_index.html'
fields = (
'name', 'source_type', 'category_type' , 'parent', 'is_active', 'order'
)
search_fields = ['name']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
return form
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('categories-ajax/hadiscategory/', self.admin_site.admin_view(self.ajax_categories), name='hadiscategory_ajax_categories'),
]
return custom_urls + urls
def get_categories_groupby_language(self, request=None, selected_values=(), is_multiple=False):
print(f'--get_categories_groupby_language-> {selected_values}')
return super().get_categories(request, selected_values, is_multiple)
def ajax_update(self, request):
data = request.POST
src_node = self.model.objects.get(pk=int(data['srcNode']))
other_node = self.model.objects.get(pk=int(data['otherNode']))
print(f'--ajax_update-> {data}')
if src_node.slug in self.base_categories or other_node.slug in self.base_categories:
return JsonResponse({'data': _('This item can not be modifed')}, status=401)
mode = data['hitMode']
if mode == 'over':
src_node.move_to(other_node, 'first-child')
elif mode == 'after':
src_node.move_to(other_node, 'right')
elif mode == 'before':
src_node.move_to(other_node, 'left')
return JsonResponse({'data': 'ok'}, safe=False)
def get_categories(self, request=None, selected_values=(), is_multiple=False):
"""
Override the get_categories method to filter by source_type if provided in the request
"""
categories = super().get_categories(request, selected_values, is_multiple)
# If request has source_type parameter, filter the categories
if request and request.GET.get('source_type'):
source_type = request.GET.get('source_type')
# Filter the categories by source_type
filtered_categories = []
for category in categories:
# If it's a dictionary (serialized category)
if isinstance(category, dict) and category.get('source_type') == source_type:
filtered_categories.append(category)
# If it's a model instance
elif hasattr(category, 'source_type') and getattr(category, 'source_type') == source_type:
filtered_categories.append(category)
return filtered_categories
return categories
def ajax_categories(self, request):
"""
Handle AJAX request for categories with source_type filtering and search
"""
# Get source_type from request
source_type = request.GET.get('source_type')
# Get node_id if provided (for single node data)
node_id = request.GET.get('node_id')
# Get search term if provided
search = request.GET.get('search')
# Get parent level filter if provided
parent_level = request.GET.get('parent_level')
if node_id:
# Return data for a specific node
try:
node = self.model.objects.get(pk=int(node_id))
return JsonResponse({
'id': node.id,
'source_type': node.source_type,
'category_type': node.category_type,
'parent': node.parent_id,
'level': node.level_p # Add the level_p property
})
except self.model.DoesNotExist:
return JsonResponse({'error': 'Node not found'}, status=404)
# Get all categories
queryset = self.model.objects.all()
# Annotate queryset with level_p
queryset = queryset.annotate(
level_pp=Case(
When(parent=None, then=Value(1)),
When(parent__isnull=False, parent__parent=None, then=Value(2)),
default=Value(3),
output_field=models.IntegerField()
)
)
# Filter by source_type if provided
if source_type:
queryset = queryset.filter(source_type=source_type)
# Filter by search term if provided
if search:
queryset = queryset.filter(name__icontains=search)
# Filter by parent_level if provided
if parent_level and parent_level.isdigit():
# Convert to integer
level = int(parent_level)
# Filter categories by level_p
queryset = queryset.filter(level_pp=level)
# Convert queryset to list of dictionaries for JSON response
categories = []
for category in queryset:
categories.append({
'key': category.id,
'title': category.name,
'parent': category.parent_id,
'source_type': category.source_type,
'category_type': category.category_type,
'level': category.level_p,
# Add data property to store additional information
'data': {
'parent': category.parent_id,
'level': category.level_p
}
})
print(f'-categories-->{categories}')
return JsonResponse(categories, safe=False)
def save_model(self, request, obj, form, change):
print(f'SAVE_MODEL CALLED: {request}/ {obj} / {form} / {change}')
print(f'POST DATA: {request.POST}')
# Get the level choice from the form data
level_choice = request.POST.get('level_choice_hidden')
print(f'LEVEL CHOICE: {level_choice}')
# Get the parent from AJAX selection if provided
ajax_parent = request.POST.get('ajax_parent')
if ajax_parent and ajax_parent.isdigit():
# Set the parent for the object
try:
parent_category = self.model.objects.get(pk=int(ajax_parent))
obj.parent = parent_category
# If parent is level 1, inherit its source_type
# if parent_category.level_p == 1 and level_choice == '2':
# obj.source_type = parent_category.source_type
print(f'AJAX PARENT SET: {parent_category.id} - {parent_category.name}')
except self.model.DoesNotExist:
print(f'PARENT CATEGORY NOT FOUND: {ajax_parent}')
# Debug form validation
if form.is_valid():
print("FORM IS VALID")
else:
print(f"FORM ERRORS: {form.errors}")
print(f'---> {obj}')
# Let the parent class handle the save
super().save_model(request, obj, form, change)
# Add a message to trigger tree reload via JavaScript
from django.contrib import messages
messages.success(request, "Category saved successfully. Tree will be reloaded.")
# Set a flag in the request to redirect back to the category index page
request._category_saved = True
def response_add(self, request, obj, post_url_continue=None):
"""
Override to redirect back to the category index page after adding a new category
"""
if hasattr(request, '_category_saved') and request._category_saved:
from django.http import HttpResponseRedirect
from django.urls import reverse
return HttpResponseRedirect(reverse('admin:hadis_hadiscategory_changelist'))
return super().response_add(request, obj, post_url_continue)
def response_change(self, request, obj):
"""
Override to redirect back to the category index page after editing a category
"""
if hasattr(request, '_category_saved') and request._category_saved:
from django.http import HttpResponseRedirect
from django.urls import reverse
return HttpResponseRedirect(reverse('admin:hadis_hadiscategory_changelist'))
return super().response_change(request, obj)

161
apps/hadis/admin/hadis.py

@ -0,0 +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

0
apps/hadis/admin/transmitter.py

6
apps/hadis/apps.py

@ -0,0 +1,6 @@
from django.apps import AppConfig
class HadisConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.hadis'

159
apps/hadis/doc.py

@ -0,0 +1,159 @@
"""
Swagger documentation for the Hadis API endpoints.
This module provides Swagger documentation for the Hadis API endpoints using drf-yasg.
It defines the request parameters, response schemas, and decorators for the views.
"""
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.hadis.models import HadisCategory
# Parameter definitions
source_type_param = openapi.Parameter(
'source_type',
openapi.IN_QUERY,
description="Filter categories by source type (shia or sunni)",
type=openapi.TYPE_STRING,
enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI],
required=False
)
# Response schemas
tag_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the tag"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the tag"
)
},
required=['id', 'title']
)
category_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the category"
),
'name': openapi.Schema(
type=openapi.TYPE_STRING,
description="Name of the category"
),
'hadis_count': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Number of hadis items in this category"
),
'source_type': openapi.Schema(
type=openapi.TYPE_STRING,
enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI],
description="Source type of the category (shia or sunni)"
),
'category_type': openapi.Schema(
type=openapi.TYPE_STRING,
enum=[HadisCategory.ContentType.QURAN, HadisCategory.ContentType.HADITH],
description="Content type of the category (quran or hadith)",
nullable=True
),
'children': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_OBJECT), # Recursive reference
description="List of child categories"
)
},
required=['id', 'name', 'hadis_count', 'source_type', 'children']
)
categories_response = openapi.Response(
description="Tree structure of hadis categories",
schema=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=category_schema
)
)
hadis_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'number': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique number identifier for the hadis"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the hadis"
),
'text': openapi.Schema(
type=openapi.TYPE_STRING,
description="Original text of the hadis"
),
'translation': openapi.Schema(
type=openapi.TYPE_STRING,
description="Translation of the hadis text"
),
'tags': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=tag_schema,
description="List of tags associated with this hadis"
)
},
required=['number', 'title', 'text', 'translation', 'tags']
)
hadis_list_response = openapi.Response(
description="List of hadis items in the specified category",
schema=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=hadis_schema
)
)
# Swagger decorators for views
category_list_swagger = swagger_auto_schema(
operation_id="list_hadis_categories",
operation_description="""
Retrieve a hierarchical tree structure of hadis categories.
This endpoint returns all hadis categories in a tree structure, with parent categories
containing their child categories. Each category includes its ID, name, source type,
category type, and the count of hadis items it contains.
The response can be filtered by source type (shia or sunni) using the query parameter.
If no source type is specified, all categories are returned.
""",
operation_summary="List Hadis Categories",
tags=["Hadis"],
manual_parameters=[source_type_param],
responses={
200: categories_response,
401: "Authentication credentials were not provided or are invalid.",
500: "Internal server error occurred."
}
)
category_hadis_list_swagger = swagger_auto_schema(
operation_id="list_hadis_in_category",
operation_description="""
Retrieve a list of hadis items belonging to a specific category.
This endpoint returns all hadis items that belong to the specified category.
Each hadis item includes its number, title, original text, translation, and associated tags.
The category is specified by its ID in the URL path.
""",
operation_summary="List Hadis Items in Category",
tags=["Hadis"],
responses={
200: hadis_list_response,
401: "Authentication credentials were not provided or are invalid.",
404: "The specified category does not exist.",
500: "Internal server error occurred."
}
)

87
apps/hadis/migrations/0001_initial.py

@ -0,0 +1,87 @@
# Generated by Django 3.2.7 on 2025-03-16 23:50
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Hadis',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.PositiveIntegerField(unique=True, verbose_name='number')),
('title', models.CharField(max_length=355, verbose_name='title')),
('text', models.TextField(verbose_name='text')),
('translation', models.TextField(blank=True, default='', verbose_name='translation')),
('status', models.BooleanField(default=True, verbose_name='visibility')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'hadis',
'verbose_name_plural': 'hadises',
},
),
migrations.CreateModel(
name='HadisTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=355, verbose_name='title')),
],
),
migrations.CreateModel(
name='HadisTagRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.IntegerField(default=0, verbose_name='priority')),
('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadis', verbose_name='hadis')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadistag', verbose_name='tag')),
],
options={
'verbose_name': 'hadis tag relation',
'verbose_name_plural': 'hadis tag relations',
'unique_together': {('tag', 'hadis')},
},
),
migrations.CreateModel(
name='HadisCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=512, verbose_name='name')),
('is_active', models.BooleanField(default=True, verbose_name='is active')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('source_type', models.CharField(blank=True, choices=[('shia', 'Shia Sources'), ('sunni', 'Sunni Sources')], default='shia', max_length=10, verbose_name='Source Type')),
('category_type', models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, null=True, verbose_name='Category Content Type')),
('title', models.CharField(max_length=355, verbose_name='title')),
('order', models.IntegerField(default=0, verbose_name='order')),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='hadis.hadiscategory')),
],
options={
'verbose_name': 'Hadis Category',
'verbose_name_plural': 'Hadis Categories',
'ordering': ('order',),
},
),
migrations.AddField(
model_name='hadis',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadiscategory', verbose_name='category'),
),
migrations.AddField(
model_name='hadis',
name='tags',
field=models.ManyToManyField(related_name='hadises', through='hadis.HadisTagRelation', to='hadis.HadisTag', verbose_name='tags'),
),
]

22
apps/hadis/migrations/0002_auto_20250317_0055.py

@ -0,0 +1,22 @@
# Generated by Django 3.2.7 on 2025-03-17 00:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hadis', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='hadiscategory',
name='name',
),
migrations.AlterField(
model_name='hadiscategory',
name='source_type',
field=models.CharField(blank=True, choices=[('shia', 'Shia'), ('sunni', 'Sunni')], default='shia', max_length=10, verbose_name='Source Type'),
),
]

23
apps/hadis/migrations/0003_auto_20250317_0102.py

@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2025-03-17 01:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hadis', '0002_auto_20250317_0055'),
]
operations = [
migrations.RemoveField(
model_name='hadiscategory',
name='title',
),
migrations.AddField(
model_name='hadiscategory',
name='name',
field=models.CharField(default='1', max_length=355, verbose_name='name'),
preserve_default=False,
),
]

121
apps/hadis/migrations/0004_auto_20250321_0119.py

@ -0,0 +1,121 @@
# Generated by Django 3.2.7 on 2025-03-21 01:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import filer.fields.image
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
('library', '0003_auto_20250321_0119'),
('hadis', '0003_auto_20250317_0102'),
]
operations = [
migrations.CreateModel(
name='HadisOverview',
fields=[
('hadis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='hadis.hadis')),
('status', models.CharField(max_length=50, verbose_name='status')),
('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')),
('status_text', models.TextField(verbose_name='address')),
('address', models.TextField(verbose_name='address')),
('links', models.JSONField(blank=True, default=dict, null=True, verbose_name='title')),
('share_link', models.CharField(blank=True, max_length=255, null=True, verbose_name='share link')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('book_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='library.book', verbose_name='book reference')),
],
),
migrations.CreateModel(
name='HadisReference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='description')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='library.book', verbose_name='book')),
],
options={
'verbose_name': 'Hadis Reference',
'verbose_name_plural': 'Hadis References',
},
),
migrations.CreateModel(
name='HadisTransmitter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0, help_text='Order in the chain of transmission', verbose_name='Order')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
],
options={
'verbose_name': 'Hadis Transmitter',
'verbose_name_plural': 'Hadis Transmitters',
'ordering': ('hadis', 'order'),
},
),
migrations.CreateModel(
name='ReferenceImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.IntegerField(default=0, help_text='Priority of the image, lower values mean higher priority.', verbose_name='Priority')),
('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadisreference', verbose_name='Hadis Reference')),
('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')),
],
options={
'verbose_name': 'Reference Image',
'verbose_name_plural': 'Reference Images',
'ordering': ('priority',),
},
),
migrations.CreateModel(
name='Transmitters',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=255)),
('birth_year_hijri', models.IntegerField(verbose_name='Birth Year (Hijri)')),
('death_year_hijri', models.IntegerField(verbose_name='Death Year (Hijri)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('status', models.CharField(max_length=50, verbose_name='status')),
('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')),
('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL)),
],
),
migrations.RemoveField(
model_name='hadis',
name='tags',
),
migrations.AddField(
model_name='hadistag',
name='status',
field=models.BooleanField(default=True, verbose_name='status'),
),
migrations.DeleteModel(
name='HadisTagRelation',
),
migrations.AddField(
model_name='hadistransmitter',
name='hadis',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transmitters', to='hadis.hadis', verbose_name='hadis'),
),
migrations.AddField(
model_name='hadistransmitter',
name='transmitter',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter'),
),
migrations.AddField(
model_name='hadisreference',
name='hadis',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadis', verbose_name='hadis'),
),
migrations.AddField(
model_name='hadisoverview',
name='tags',
field=models.ManyToManyField(blank=True, related_name='hadises', to='hadis.HadisTag', verbose_name='tags'),
),
migrations.AlterUniqueTogether(
name='hadistransmitter',
unique_together={('hadis', 'transmitter', 'order')},
),
]

21
apps/hadis/migrations/0005_auto_20250321_1550.py

@ -0,0 +1,21 @@
# Generated by Django 3.2.7 on 2025-03-21 15:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('hadis', '0004_auto_20250321_0119'),
]
operations = [
migrations.AlterModelOptions(
name='referenceimage',
options={'verbose_name': 'Reference Image', 'verbose_name_plural': 'Reference Images'},
),
migrations.RemoveField(
model_name='hadisoverview',
name='book_reference',
),
]

23
apps/hadis/migrations/0006_auto_20250321_1600.py

@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2025-03-21 16:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hadis', '0005_auto_20250321_1550'),
]
operations = [
migrations.AlterField(
model_name='hadisoverview',
name='address',
field=models.TextField(blank=True, null=True, verbose_name='address'),
),
migrations.AlterField(
model_name='hadisoverview',
name='status_text',
field=models.TextField(blank=True, null=True, verbose_name='Status Text'),
),
]

0
apps/hadis/migrations/__init__.py

3
apps/hadis/models/__init__.py

@ -0,0 +1,3 @@
from .category import *
from .hadis import *
from .transmitter import *

105
apps/hadis/models/category.py

@ -0,0 +1,105 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from dj_category.models import BaseCategoryAbstract
class HadisCategory(BaseCategoryAbstract):
class SourceType(models.TextChoices):
SHIA = 'shia', _('Shia')
SUNNI = 'sunni', _('Sunni')
class ContentType(models.TextChoices):
QURAN = 'quran', _('Quran')
HADITH = 'hadith', _('Hadith')
class LevelChoices(models.IntegerChoices):
LEVEL_1 = 1, _('Level 1 (Root)')
LEVEL_2 = 2, _('Level 2 (Child)')
LEVEL_3 = 3, _('Level 3 (Grandchild)')
source_type = models.CharField(max_length=10, choices=SourceType.choices, default=SourceType.SHIA, verbose_name=_('Source Type'), blank=True)
category_type = models.CharField(max_length=10, choices=ContentType.choices, verbose_name=_('Category Content Type'), blank=True, null=True)
name = models.CharField(max_length=355, verbose_name=_('name'))
order = models.IntegerField(default=0, verbose_name=_('order'))
slug = None
content_type = None
language = None
language_id = None
# This field is not stored in the database, it's only used for the form
level_choice = None
class Meta:
verbose_name = _('Hadis Category')
verbose_name_plural = _('Hadis Categories')
ordering = ('order',)
def __str__(self):
return f'<{str(self.level_p)}>{self.name}'
def __repr__(self):
return f'<{str(self.level_p)}>{self.name}'
def clean(self):
super().clean()
# Skip validation for new objects that haven't been saved yet
# This allows the admin form to set these values properly
if self.pk is None:
return
# For existing objects, apply the validation rules
if self.level_p == 1 and self.category_type:
raise ValidationError(_("Level 1 cannot have content type"))
if self.level_p == 2 and not self.category_type:
raise ValidationError(_("Level 2 must have content type"))
if self.level_p == 3 and (self.source_type or self.category_type):
raise ValidationError(_("Level 3 cannot have source/content type"))
def save(self, *args, **kwargs):
self.clean()
# Get the level from the parent structure
level = self.level_p
# Apply level-specific logic
# if level == 2 and self.parent:
# For level 2, inherit source_type from parent
# self.source_type = self.parent.source_type
# elif level == 3:
# For level 3, inherit both from parent
# if self.parent and self.parent.parent:
# self.source_type = self.parent.source_type
# self.category_type = self.parent.category_type
# Call the parent class's save method
super().save(*args, **kwargs)
@property
def level_p(self):
if not self.parent:
return 1
elif not self.parent.parent:
return 2
else:
return 3
def get_level_info(self):
info = {
'level': self.level_p,
'source_type': None,
'category_type': None,
}
if self.level_p == 1:
info['source_type'] = self.source_type
elif self.level_p == 2:
info['source_type'] = self.parent.source_type
info['category_type'] = self.category_type
return info

102
apps/hadis/models/hadis.py

@ -0,0 +1,102 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from filer.fields.image import FilerImageField
class HadisTag(models.Model):
title = models.CharField(max_length=355, verbose_name=_('title'))
status = models.BooleanField(default=True, verbose_name=_('status'))
def __str__(self):
return f"{self.title}"
class Hadis(models.Model):
number = models.PositiveIntegerField(verbose_name=_('number'), unique=True)
title = models.CharField(max_length=355, verbose_name=_('title'))
text = models.TextField(verbose_name=_('text'))
translation = models.TextField(verbose_name=_('translation'), blank=True, default='')
category = models.ForeignKey("hadis.HadisCategory", null=True, on_delete=models.SET_NULL, verbose_name=_('category'), )
status = models.BooleanField(default=True, verbose_name=_('visibility'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def __str__(self):
return f"<{self.number}> {self.title[:32]}"
@property
def get_tags(self):
return self.tags.all().order_by('hadistagrelation__priority')
class Meta:
verbose_name = _('hadis')
verbose_name_plural = _('hadises')
class HadisOverview(models.Model):
hadis = models.OneToOneField(Hadis, on_delete=models.CASCADE, primary_key=True)
status = models.CharField(max_length=50, verbose_name=_('status'))
status_color = models.CharField(max_length=25, verbose_name=_('Display Status Color'))
status_text = models.TextField(verbose_name=_('Status Text'), null=True, blank=True)
address = models.TextField(verbose_name=_('address'), null=True, blank=True)
links = models.JSONField(verbose_name=_('title'), null=True, blank=True, default=dict)
tags = models.ManyToManyField("HadisTag", related_name="hadises", verbose_name=_('tags'), blank=True)
share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
class HadisReference(models.Model):
hadis = models.ForeignKey(
Hadis,
on_delete=models.CASCADE,
verbose_name=_('hadis'),
related_name='references'
)
book = models.ForeignKey("library.Book", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('book'), related_name='hadis_references')
description = models.TextField(verbose_name=_('description'), blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
class Meta:
verbose_name = _('Hadis Reference')
verbose_name_plural = _('Hadis References')
def __str__(self):
return f'{self.hadis.number}-{self.book.title}'
class ReferenceImage(models.Model):
reference = models.ForeignKey(HadisReference, verbose_name="Hadis Reference", on_delete=models.CASCADE)
thumbnail = FilerImageField(
related_name='+', on_delete=models.PROTECT, null=True, blank=True,
verbose_name=_('thumbnail')
)
priority = models.IntegerField(
default=0,
verbose_name=_("Priority"),
help_text=_("Priority of the image, lower values mean higher priority.")
)
class Meta:
verbose_name = _('Reference Image')
verbose_name_plural = _('Reference Images')
def __str__(self):
return f'{self.reference.title}-{self.id}'
def save(self, *args, **kwargs):
if ReferenceImage.objects.filter(reference=self.reference, priority=self.priority).exists():
ReferenceImage.objects.filter(
reference=self.reference,
priority__gte=self.priority
).update(priority=F('priority') + 1)
super().save(*args, **kwargs)

52
apps/hadis/models/transmitter.py

@ -0,0 +1,52 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from filer.fields.image import FilerImageField
class Transmitters(models.Model):
full_name = models.CharField(max_length=255)
birth_year_hijri = models.IntegerField(verbose_name="Birth Year (Hijri)")
death_year_hijri = models.IntegerField(verbose_name="Death Year (Hijri)")
description = models.TextField(blank=True, null=True, verbose_name="Description")
status = models.CharField(max_length=50, verbose_name=_('status'))
status_color = models.CharField(max_length=25, verbose_name=_('Display Status Color'))
thumbnail = FilerImageField(related_name="+", on_delete=models.CASCADE, help_text=_(
'image allowed'
), null=True, blank=True)
def __str__(self):
return self.full_name
class HadisTransmitter(models.Model):
hadis = models.ForeignKey(
"hadis.Hadis",
on_delete=models.CASCADE,
verbose_name=_('hadis'),
related_name='transmitters'
)
transmitter = models.ForeignKey(
Transmitters,
on_delete=models.CASCADE,
verbose_name=_('transmitter'),
related_name='hadises'
)
order = models.PositiveIntegerField(
default=0,
verbose_name=_('Order'),
help_text=_('Order in the chain of transmission')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
class Meta:
verbose_name = _('Hadis Transmitter')
verbose_name_plural = _('Hadis Transmitters')
ordering = ('hadis', 'order')
unique_together = ('hadis', 'transmitter', 'order')
def __str__(self):
return f'{self.hadis.number} - {self.transmitter.full_name} ({self.order})'

50
apps/hadis/serializers.py

@ -0,0 +1,50 @@
from rest_framework import serializers
from apps.hadis.models import *
class HadisCategorySerializer(serializers.ModelSerializer):
children = serializers.SerializerMethodField('get_children')
name = serializers.SerializerMethodField()
hadis_count = serializers.SerializerMethodField()
source_type = serializers.CharField(read_only=True)
def get_children(self, obj):
return [self.to_dict(cat) for cat in obj.get_children()]
def to_dict(self, c):
children = c.get_children()
return {
'id': c.id,
'name': c.name,
'hadis_count': c.hadis_count,
'source_type': c.source_type,
'category_type': c.category_type,
'children': [] if not children else [self.to_dict(i) for i in children],
}
class Meta:
model = HadisCategory
fields = ['id', 'name', 'hadis_count', 'source_type','children']
class HadisTagSerializer(serializers.ModelSerializer):
class Meta:
model = HadisTag
fields = ('id', 'title')
class HadisSerializer(serializers.ModelSerializer):
translation = serializers.CharField(source='translation')
text = serializers.CharField(source='text')
tags = serializers.SerializerMethodField()
def get_tags(self, obj):
return HadisTagSerializer(obj.get_tags, many=True).data
class Meta:
model = Hadis
fields = ('number', 'title', 'text', 'translation', 'tags')

2343
apps/hadis/templates/admin/category_index.html
File diff suppressed because it is too large
View File

42
apps/hadis/templates/admin/hadiscategory/change_form.html

@ -0,0 +1,42 @@
{% extends 'admin/category_index.html' %}
{% load i18n admin_urls %}
{% block extrahead %}
{{ block.super }}
<style>
.bg-success {
background-color: #4CAF50 !important;
}
.bg-success:hover {
background-color: #45a049 !important;
}
</style>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script>
$(document).ready(function() {
// Add the button after the page is loaded
setTimeout(function() {
// Find the history button in the header
var $historyBtn = $(".historylink.float-right.btn");
// Create the Add Category button with the same styling as the History button
var addUrl = "{{ request.path }}".replace(/\/\d+\/change\/$/, "/add/");
var $addCategoryBtn = $('<a href="' + addUrl + '" class="float-right btn bg-success legitRipple mr-3 ml-3">' +
'{% trans "Add Category" %} <i class="icon-plus-circle2"></i></a>');
// Insert the Add Category button before the History button
if ($historyBtn.length) {
$historyBtn.before($addCategoryBtn);
} else {
// If history button not found, add to the btns-head div
$(".btns-head .dtr-inline").append($addCategoryBtn);
}
console.log("Add Category button added");
}, 1000); // Increased timeout to ensure DOM is fully loaded
});
</script>
{% endblock %}

153
apps/hadis/templates/admin/hadisowerview_change_form.html

@ -0,0 +1,153 @@
{% extends "admin/change_form.html" %}
{% load i18n %}
{% load static %}
{% block submit_buttons_bottom %}
{{ block.super }}
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_next">
{% translate 'Save And Edit Next Hadis' %}
<i class="mi-border-color ml-2"></i>
</button>
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_prev">
{% translate 'Save And Edit Previus Hadis' %}
<i class="mi-border-color ml-2"></i>
</button>
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_random">
{% translate 'Save And Edit Random' %}
<i class="mi-border-color ml-2"></i>
</button>
{% endblock %}
{% block scripts %}
{{ block.super }}
<style>
.color-picker-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 15px 0;
justify-content: flex-start;
max-width: 600px;
}
.color-option {
flex: 0 0 auto;
}
.color-radio-label {
cursor: pointer;
margin: 0;
padding: 0;
}
.color-option-container {
display: flex;
flex-direction: column;
align-items: center;
transition: all 0.2s ease;
padding: 8px;
border-radius: 8px;
}
.color-option-container:hover {
background-color: rgba(0, 0, 0, 0.05);
transform: translateY(-2px);
}
.color-preview {
display: block;
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.2s ease;
position: relative;
}
.color-name {
display: block;
font-size: 12px;
margin-top: 6px;
color: #555;
font-weight: 500;
transition: all 0.2s ease;
}
input[type="radio"] {
display: none;
}
input[type="radio"]:checked + .color-option-container {
background-color: rgba(0, 0, 0, 0.05);
}
input[type="radio"]:checked + .color-option-container .color-preview {
border: 2px solid #333;
box-shadow: 0 0 0 2px rgba(0,0,0,0.2);
transform: scale(1.1);
}
input[type="radio"]:checked + .color-option-container .color-preview:after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.7);
font-size: 18px;
font-weight: bold;
}
input[type="radio"]:checked + .color-option-container .color-name {
color: #000;
font-weight: 600;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get all color radio options
const colorOptions = document.querySelectorAll('.color-option input[type="radio"]');
// Check if there's a pre-selected value
const statusColorField = document.querySelector('input[name$="-status_color"]');
const preSelectedValue = statusColorField ? statusColorField.value : null;
colorOptions.forEach(function(radio) {
// Add click event to the color container
const colorContainer = radio.nextElementSibling;
colorContainer.addEventListener('click', function() {
radio.checked = true;
// Trigger change event to update any listeners
const event = new Event('change', { bubbles: true });
radio.dispatchEvent(event);
});
// If this radio's value matches the pre-selected value, check it
if (preSelectedValue && radio.value === preSelectedValue) {
radio.checked = true;
// Scroll to make the selected color visible
setTimeout(() => {
radio.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 300);
}
});
// If no color is selected but there's a value in the hidden field, try to match it
if (preSelectedValue && !document.querySelector('.color-option input[type="radio"]:checked')) {
// Try to find a color that matches (case insensitive)
const matchingRadio = Array.from(colorOptions).find(radio =>
radio.value.toLowerCase() === preSelectedValue.toLowerCase()
);
if (matchingRadio) {
matchingRadio.checked = true;
}
}
});
</script>
{% endblock %}

7
apps/hadis/templates/admin/widgets/color_radio.html

@ -0,0 +1,7 @@
{% for group, options, index in widget.optgroups %}
{% for option in options %}
<div class="color-option" style="display: inline-block; margin: 5px;">
{% include option.template_name with widget=option %}
</div>
{% endfor %}
{% endfor %}

9
apps/hadis/templates/admin/widgets/color_radio_option.html

@ -0,0 +1,9 @@
{% if widget.wrap_label %}
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
<span class="color-preview" style="display: inline-block; width: 30px; height: 30px; background-color: {{ widget.value }}; border: 1px solid #ccc; border-radius: 5px;"></span>
<span class="color-name">{{ widget.label }}</span>
{% if widget.wrap_label %}
</label>
{% endif %}

3
apps/hadis/tests.py

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

9
apps/hadis/urls.py

@ -0,0 +1,9 @@
from django.urls import path, include
from . import views
urlpatterns = [
path('categories/', views.CategoryListView.as_view(), name='category-list'),
path('categories/<int:pk>/hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'),
]

3
apps/hadis/views/__init__.py

@ -0,0 +1,3 @@
from .category import *
from .hadis import *
# from .transmitter import *

301
apps/hadis/views/category.py

@ -0,0 +1,301 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch, Case, When, Value, IntegerField
from rest_framework.pagination import PageNumberPagination
from rest_framework.generics import ListAPIView
from django.core.cache import cache
from django.conf import settings
import hashlib
import json
from apps.hadis.models import *
from apps.hadis.serializers import *
from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger
class CategoryPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class CategoryListView(ListAPIView):
serializer_class = HadisCategorySerializer
permission_classes = (IsAuthenticated,)
pagination_class = CategoryPagination
# Cache timeout in seconds (1 hour)
CACHE_TIMEOUT = 60 * 60
def get_cache_key(self, source_type=None):
"""
Generate a unique cache key based on the view name and filter parameters.
Args:
source_type: Optional source_type filter parameter
Returns:
A unique cache key string
"""
# Base key with the view name
key_parts = ['category_tree']
# Add filter parameters to make the key specific
if source_type:
key_parts.append(f'source_type:{source_type}')
# Join all parts with a separator
key = ':'.join(key_parts)
return key
@classmethod
def invalidate_cache(cls, source_type=None):
"""
Invalidate the category tree cache.
Args:
source_type: Optional source_type to invalidate specific cache.
If None, invalidates all category tree caches.
"""
if source_type:
# Invalidate specific tree cache
tree_cache_key = cls().get_cache_key(source_type)
cache.delete(tree_cache_key)
# Invalidate all paginated caches for this source_type
paginated_pattern = f'category_tree_paginated:source_type:{source_type}*'
paginated_keys = cache.keys(paginated_pattern)
if paginated_keys:
cache.delete_many(paginated_keys)
else:
# Invalidate all category tree caches (both full trees and paginated results)
# This uses cache key pattern matching if supported by the cache backend
# For Redis, we can use wildcards
all_cache_keys = cache.keys('category_tree*')
if all_cache_keys:
cache.delete_many(all_cache_keys)
else:
# Fallback: delete specific known keys
for st in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]:
# Delete tree cache
tree_cache_key = cls().get_cache_key(st)
cache.delete(tree_cache_key)
# Try to delete paginated caches
try:
paginated_pattern = f'category_tree_paginated:source_type:{st}*'
paginated_keys = cache.keys(paginated_pattern)
if paginated_keys:
cache.delete_many(paginated_keys)
except:
pass
# Also delete the default keys (no source_type)
cache.delete(cls().get_cache_key())
try:
default_paginated_keys = cache.keys('category_tree_paginated:page:*')
if default_paginated_keys:
cache.delete_many(default_paginated_keys)
except:
pass
def get_children(self, obj):
return [self.to_dict(cat) for cat in obj.get_children()]
def to_dict(self, c):
"""
Convert a category to a dictionary with proper tree structure based on level.
Args:
c: The HadisCategory instance
Returns:
Dictionary representation of the category with proper tree structure
"""
# Get the level of this category
level = c.level_p
# Determine source_type and category_type based on level
source_type = None
category_type = None
if level == 1:
# Level 1 (Root) - Has its own source_type
source_type = c.source_type
category_type = None
elif level == 2:
# Level 2 (Child) - Inherits source_type from parent, has own category_type
if c.parent:
source_type = c.parent.source_type
else:
source_type = c.source_type
category_type = c.category_type
elif level == 3:
# Level 3 (Grandchild) - Inherits source_type from grandparent, category_type from parent
if c.parent and c.parent.parent:
source_type = c.parent.parent.source_type
category_type = c.parent.category_type
else:
source_type = c.source_type
category_type = c.category_type
# Get direct children - use getattr to handle both model instances and cached trees
if hasattr(c, 'get_children'):
# For model instances
children = c.get_children()
else:
# For cached trees
children = getattr(c, 'children', [])
# Create the dictionary representation
return {
'id': c.id,
'name': c.name,
'hadis_count': getattr(c, 'hadis_count', 0),
'source_type': source_type,
'category_type': category_type,
'children': [] if not children else [self.to_dict(child) for child in children],
}
def get_pagination_cache_key(self, source_type=None, page=1, page_size=None):
"""
Generate a cache key for paginated results.
Args:
source_type: Optional source_type filter
page: Page number
page_size: Number of items per page
Returns:
A unique cache key for the paginated results
"""
# Base key with the view name
key_parts = ['category_tree_paginated']
# Add filter parameters
if source_type:
key_parts.append(f'source_type:{source_type}')
# Add pagination parameters
key_parts.append(f'page:{page}')
if page_size:
key_parts.append(f'page_size:{page_size}')
else:
key_parts.append(f'page_size:{self.pagination_class.page_size}')
# Join all parts with a separator
key = ':'.join(key_parts)
return key
@category_list_swagger
def get(self, request, *args, **kwargs):
from mptt.templatetags.mptt_tags import cache_tree_children
# Get source_type filter from query params
source_type = request.query_params.get('source_type', None)
# Get pagination parameters
page = request.query_params.get('page', 1)
page_size = request.query_params.get('page_size', self.pagination_class.page_size)
# Try to get paginated response from cache first
pagination_cache_key = self.get_pagination_cache_key(source_type, page, page_size)
cached_response = cache.get(pagination_cache_key)
if cached_response:
return Response(cached_response)
# Generate a unique cache key for the full tree
tree_cache_key = self.get_cache_key(source_type)
# Try to get the tree from cache first
tree = cache.get(tree_cache_key)
# If not in cache, build the tree
if tree is None:
# Build filter query
filter_query = Q(is_active=True)
if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]:
filter_query &= Q(source_type=source_type)
# Get ALL categories with hadis count - this is important to include all levels
queryset = HadisCategory.objects.filter(filter_query).select_related(
'parent', 'parent__parent' # Prefetch parent relationships for efficient access
).annotate(
hadis_count=Count('hadis'),
)
# Use cache_tree_children to build the full tree structure
# This will properly set up the parent-child relationships for the entire tree
all_categories = cache_tree_children(queryset)
# Filter to get only level 1 (root) categories as the starting point for our tree
root_categories = [category for category in all_categories if category.parent is None]
# Build the tree
tree = []
for c in root_categories:
# Convert to dictionary with proper tree structure based on level
tdata = self.to_dict(c)
# Calculate total hadis_count including all children recursively
def calculate_total_hadis_count(node):
total = node['hadis_count']
for child in node['children']:
total += calculate_total_hadis_count(child)
return total
# Update the hadis_count to include all children
tdata['hadis_count'] = calculate_total_hadis_count(tdata)
# Add to the result tree
tree.append(tdata)
# Store the tree in cache
cache.set(tree_cache_key, tree, self.CACHE_TIMEOUT)
# Apply pagination only to the root categories (level 1)
page_obj = self.paginate_queryset(tree)
if page_obj is not None:
# Get paginated response
response = self.get_paginated_response(page_obj)
# Cache the paginated response
cache.set(pagination_cache_key, response.data, self.CACHE_TIMEOUT)
return response
# If pagination is not applied, return the full tree
return Response(tree)
def get_queryset(self):
"""
Get the base queryset for the serializer.
This is used by DRF's default list() method if we don't override get().
Note: This method is not used directly in our implementation since we override get(),
but it's kept for completeness and API compatibility.
"""
source_type = self.request.query_params.get('source_type', None)
# Build filter query
filter_query = Q(is_active=True)
if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]:
filter_query &= Q(source_type=source_type)
# Get ALL categories with proper prefetching for efficiency
queryset = HadisCategory.objects.filter(filter_query).select_related(
'parent', 'parent__parent'
).prefetch_related(
'children', 'children__children' # Prefetch two levels of children
).annotate(
hadis_count=Count('hadis'),
)
# Filter to only return root categories (level 1)
queryset = queryset.filter(parent=None)
return queryset

29
apps/hadis/views/hadis.py

@ -0,0 +1,29 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch
from rest_framework.generics import ListAPIView
from apps.hadis.models import *
from apps.hadis.serializers import *
from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger
class CategoryHadisListView(ListAPIView):
serializer_class = HadisSerializer
permission_classes = (IsAuthenticated,)
@category_hadis_list_swagger
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
categories = HadisCategory.objects.filter(id=self.kwargs['pk']).order_by('-order')
return Hadis.objects.filter(
Q(category__in=categories),
status=True,
).prefetch_related(
'category',
'tags',
)

0
apps/library/__init__.py

192
apps/library/admin.py

@ -0,0 +1,192 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.utils.html import format_html
from ajaxdatatable.admin import AjaxDatatable
from apps.library.models import *
@admin.register(Book)
class BookAdmin(AjaxDatatable):
list_display = ('title', 'slug', 'status', 'pin', 'file_type', 'view_count', 'created_at')
list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at')
search_fields = ('title', 'slug', 'summary', 'description')
# autocomplete_fields = ('categories', 'collections', )
fieldsets = (
(None, {
'fields': ('title', 'slug', 'summary', 'description', 'thumbnail', 'pages_count')
}),
(_('Status'), {
'fields': ('status', 'pin')
}),
(_('File Information'), {
'fields': ('file_type', 'book_file')
}),
(_('Relations'), {
'fields': ('categories', 'collections')
}),
(_('Statistics'), {
'fields': ('view_count',)
}),
)
class BookCollectionAdminBase(AjaxDatatable):
"""Base admin class for all book collection types"""
list_display = ('get_title', 'status', 'order', 'count_books')
list_filter = ('status',)
search_fields = ('title',)
autocomplete_fields = ('books',)
ordering = ('order',)
fieldsets = (
(None, {
'fields': ('title', 'summary', 'status', 'order')
}),
(_('Books'), {
'fields': ('books',)
}),
)
exclude = ('display_position',)
def get_title(self, obj):
return str(obj.title)
get_title.short_description = _('Title')
@admin.display(description=_('Number of Books'))
def count_books(self, obj):
count = obj.books.count()
if count > 0:
url = reverse('admin:library_book_changelist') + f'?collections__id__exact={obj.id}'
return format_html('<a href="{}">{}</a>', url, count)
return count
@admin.register(PinnedBookCollection)
class PinnedBookCollectionAdmin(BookCollectionAdminBase):
"""Admin for pinned book collections only"""
def get_queryset(self, request):
# Only show pinned collections
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.PINNED)
def save_model(self, request, obj, form, change):
# Ensure the display_position is always set to PINNED
obj.display_position = BookCollection.DisplayPosition.PINNED
super().save_model(request, obj, form, change)
@admin.register(MiddleBookCollection)
class MiddleBookCollectionAdmin(BookCollectionAdminBase):
"""Admin for middle section book collections only"""
def get_queryset(self, request):
# Only show middle section collections
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.MIDDLE)
def has_add_permission(self, request):
# Check if a middle collection already exists
exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists()
# Only allow adding if no middle collection exists
return not exists
def has_delete_permission(self, request, obj=None):
# Prevent deletion of the middle collection
return False
def save_model(self, request, obj, form, change):
# Ensure the display_position is always set to MIDDLE
obj.display_position = BookCollection.DisplayPosition.MIDDLE
super().save_model(request, obj, form, change)
def changelist_view(self, request, extra_context=None):
# Check if a middle collection exists
try:
# Try to get the first (and should be only) middle collection
obj = self.get_queryset(request).first()
if obj:
# If it exists, redirect to the change view for this object
from django.http import HttpResponseRedirect
from django.urls import reverse
url = reverse(
'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name),
args=[obj.pk]
)
return HttpResponseRedirect(url)
except Exception:
# If any error occurs, just show the changelist view as usual
pass
# If no object exists or there was an error, show the default changelist view
return super().changelist_view(request, extra_context)
@admin.register(BottomBookCollection)
class BottomBookCollectionAdmin(BookCollectionAdminBase):
"""Admin for bottom section book collections only"""
def get_queryset(self, request):
# Only show bottom section collections
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.BOTTOM)
def has_add_permission(self, request):
# Check if a bottom collection already exists
exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.BOTTOM).exists()
# Only allow adding if no bottom collection exists
return not exists
def has_delete_permission(self, request, obj=None):
# Prevent deletion of the bottom collection
return False
def save_model(self, request, obj, form, change):
# Ensure the display_position is always set to BOTTOM
obj.display_position = BookCollection.DisplayPosition.BOTTOM
super().save_model(request, obj, form, change)
def changelist_view(self, request, extra_context=None):
# Check if a bottom collection exists
try:
# Try to get the first (and should be only) bottom collection
obj = self.get_queryset(request).first()
if obj:
# If it exists, redirect to the change view for this object
from django.http import HttpResponseRedirect
from django.urls import reverse
url = reverse(
'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name),
args=[obj.pk]
)
return HttpResponseRedirect(url)
except Exception:
# If any error occurs, just show the changelist view as usual
pass
# If no object exists or there was an error, show the default changelist view
return super().changelist_view(request, extra_context)
@admin.register(Category)
class CategoryAdmin(AjaxDatatable):
list_display = ('title', 'slug', 'status', 'count_books', 'created_at')
list_filter = ('status', 'created_at', 'updated_at')
search_fields = ('title', 'slug')
autocomplete_fields = ('books',)
@admin.display(description=_('Number of Books'))
def count_books(self, obj):
count = obj.books.count()
if count > 0:
url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}'
return format_html('<a href="{}">{}</a>', url, count)
return count

9
apps/library/apps.py

@ -0,0 +1,9 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class LibraryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.library'
verbose_name = _('Library')
icon = 'mi-library-books'

140
apps/library/migrations/0001_initial.py

@ -0,0 +1,140 @@
# Generated by Django 3.2.7 on 2025-03-20 07:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import filer.fields.image
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)),
('description', models.TextField(blank=True, help_text='could be null', null=True)),
('pages_count', models.CharField(help_text='eg. 34', max_length=255, null=True, verbose_name='Number of Pages')),
('status', models.BooleanField(default=True, verbose_name='status')),
('pin', models.BooleanField(default=True, verbose_name='Pin to top')),
('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')),
('file_type', models.CharField(choices=[('pdf', 'Pdf'), ('epub', 'Epub'), ('docx', 'Docx')], default='pdf', max_length=16, verbose_name='File Type')),
('book_file', models.FileField(blank=True, max_length=550, null=True, upload_to='books', verbose_name='Book File')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'Book',
'verbose_name_plural': 'Books',
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('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')),
('books', models.ManyToManyField(blank=True, related_name='related_categories_books', to='library.Book', verbose_name='Books')),
],
options={
'verbose_name': 'Category',
'verbose_name_plural': 'Categories',
},
),
migrations.CreateModel(
name='BookDownload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created 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',
},
),
migrations.CreateModel(
name='BookCollection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.JSONField(default=dict, verbose_name='title')),
('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)),
('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section'), ('bottom', 'Bottom Section')], default='pinned', max_length=20, verbose_name='Display Position')),
('status', models.BooleanField(default=True, verbose_name='status')),
('order', models.IntegerField(default=0, verbose_name='order')),
('books', models.ManyToManyField(blank=True, related_name='related_collections_books', to='library.Book', verbose_name='Books')),
],
options={
'verbose_name': 'Book Collection',
'verbose_name_plural': 'Book Collections',
},
),
migrations.AddField(
model_name='book',
name='categories',
field=models.ManyToManyField(blank=True, related_name='related_categories', to='library.Category', verbose_name='categories'),
),
migrations.AddField(
model_name='book',
name='collections',
field=models.ManyToManyField(blank=True, related_name='related_collections', to='library.BookCollection', verbose_name='collections'),
),
migrations.AddField(
model_name='book',
name='thumbnail',
field=filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL),
),
migrations.CreateModel(
name='BottomBookCollection',
fields=[
],
options={
'verbose_name': 'Bottom Section Book Collection',
'verbose_name_plural': 'Bottom Section Book Collections',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('library.bookcollection',),
),
migrations.CreateModel(
name='MiddleBookCollection',
fields=[
],
options={
'verbose_name': 'Middle Section Book Collection',
'verbose_name_plural': 'Middle Section Book Collections',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('library.bookcollection',),
),
migrations.CreateModel(
name='PinnedBookCollection',
fields=[
],
options={
'verbose_name': 'Pinned Book Collection',
'verbose_name_plural': 'Pinned Book Collections',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('library.bookcollection',),
),
]

18
apps/library/migrations/0002_alter_bookcollection_title.py

@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2025-03-20 14:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='bookcollection',
name='title',
field=models.CharField(max_length=255),
),
]

26
apps/library/migrations/0003_auto_20250321_0119.py

@ -0,0 +1,26 @@
# Generated by Django 3.2.7 on 2025-03-21 01:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0002_alter_bookcollection_title'),
]
operations = [
migrations.AddField(
model_name='book',
name='author',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='download_count',
field=models.PositiveBigIntegerField(default=0, verbose_name='view count'),
),
migrations.DeleteModel(
name='BookDownload',
),
]

0
apps/library/migrations/__init__.py

132
apps/library/models.py

@ -0,0 +1,132 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField
class BookCollection(models.Model):
class DisplayPosition(models.TextChoices):
PINNED = 'pinned', _('Pinned')
MIDDLE = 'middle', _('Middle Section')
BOTTOM = 'bottom', _('Bottom Section')
title = models.CharField(max_length=255)
summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null'))
display_position = models.CharField(
max_length=20,
choices=DisplayPosition.choices,
default=DisplayPosition.PINNED,
verbose_name=_('Display Position')
)
status = models.BooleanField(_('status'), default=True)
order = models.IntegerField(default=0, verbose_name=_('order'))
books = models.ManyToManyField('library.Book', related_name='related_collections_books',through="library.Book_collections" ,verbose_name=_('Books'), blank=True)
def __str__(self):
return f'Collection #{self.id}/{self.title}'
class Meta:
verbose_name = _('Book Collection')
verbose_name_plural = _('Book Collections')
class PinnedBookCollection(BookCollection):
"""
Proxy model for pinned book collections
"""
class Meta:
proxy = True
verbose_name = _('Pinned Book Collection')
verbose_name_plural = _('Pinned Book Collections')
class MiddleBookCollection(BookCollection):
"""
Proxy model for middle section book collections
"""
class Meta:
proxy = True
verbose_name = _('Middle Section Book Collection')
verbose_name_plural = _('Middle Section Book Collections')
class BottomBookCollection(BookCollection):
"""
Proxy model for bottom section book collections
"""
class Meta:
proxy = True
verbose_name = _('Bottom Section Book Collection')
verbose_name_plural = _('Bottom Section Book Collections')
class Category(models.Model):
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
status = models.BooleanField(default=True, verbose_name=_('status'))
books = models.ManyToManyField('library.Book', related_name='related_categories_books',through="library.Book_categories" ,verbose_name=_('Books'), blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def __str__(self):
return self.title
@property
def books_count(self):
"""Return the number of books in this category"""
return self.books.count()
class Meta:
verbose_name = _('Category')
verbose_name_plural = _('Categories')
class Book(models.Model):
class FileType(models.TextChoices):
pdf = 'pdf', 'Pdf'
epub = 'epub', 'Epub'
docx = 'docx', 'Docx'
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null'))
description = models.TextField(null=True, blank=True, help_text=_('could be null'))
thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_(
'image allowed'
))
author = models.CharField(max_length=255, null=True, blank=True)
pages_count = models.CharField(verbose_name=_('Number of Pages'), max_length=255, help_text=_('eg. 34'), null=True)
status = models.BooleanField(default=True, verbose_name=_('status'))
pin = models.BooleanField(default=True, verbose_name=_('Pin to top'))
categories = models.ManyToManyField(Category, related_name='related_categories', verbose_name=_('categories'), blank=True)
collections = models.ManyToManyField(BookCollection, related_name='related_collections', verbose_name=_('collections'), blank=True)
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
# seo_fields = SeoGenericRelation(verbose_name=_('soe fields'))
file_type = models.CharField(verbose_name=_('File Type'), choices=FileType.choices, default=FileType.pdf, max_length=16)
book_file = models.FileField(null=True, blank=True, max_length=550, upload_to='books', verbose_name='Book File')
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def __str__(self):
return f'<{self.id}>-{self.title}'
def increment_view_count(self):
"""Increment the view count by 1 and save the model"""
self.view_count += 1
self.save(update_fields=['view_count'])
class Meta:
verbose_name = _('Book')
verbose_name_plural = _('Books')

33
apps/library/serializers.py

@ -0,0 +1,33 @@
from dj_filer.admin import get_thumbs
from django.db.models import Avg
from rest_framework import serializers
from apps.library.models import *
class BannerListSerializer(serializers.ModelSerializer):
description = serializers.CharField(source='summary')
title = serializers.SerializerMethodField()
covers = serializers.SerializerMethodField()
def get_title(self, obj):
return obj.title
def get_covers(self, obj: BookCollection):
books = obj.get_books().order_by('-view_count')[:3]
images = []
for book in books:
url = get_thumbs(book.thumbnail, self.context.get('request'))
if url.get('md'):
images.append(url['md'])
return images
class Meta:
model = BookCollection
fields = ('id', 'title', 'summary', 'covers')

3
apps/library/tests.py

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

22
apps/library/views.py

@ -0,0 +1,22 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.generics import ListAPIView
from apps.library.models import *
from apps.library.serializers import *
class BannerListView(ListAPIView):
serializer_class = BannerListSerializer
permission_classes = (IsAuthenticated,)
pagination_class = None
def get_queryset(self):
_query = Q(status=True, display_position=BookCollection.DisplayPosition.TOP)
return Collection.objects.filter(
_query,
).order_by('-order', '-id', )

0
apps/podcast/__init__.py

23
apps/podcast/admin.py

@ -0,0 +1,23 @@
from django.contrib import admin
from ajaxdatatable.admin import AjaxDatatable
from apps.podcast.models import *
class PodcastInCollectionInline(admin.TabularInline):
model = PodcastInCollection
extra = 1
@admin.register(PodcastCollection)
class PodcastCollectionAdmin(AjaxDatatable):
list_display = ('title',)
inlines = [PodcastInCollectionInline]
@admin.register(Podcast)
class PodcastAdmin(AjaxDatatable):
list_display = ('title', 'view_count', 'download_count', 'status')
search_fields = ('title',)

8
apps/podcast/apps.py

@ -0,0 +1,8 @@
from django.apps import AppConfig
class PodcastConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.podcast'

0
apps/podcast/migrations/__init__.py

71
apps/podcast/models.py

@ -0,0 +1,71 @@
from django.db import models
class PodcastCollection(models.Model):
title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere")
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
videos = models.ManyToManyField(
Video,
through='PodcastInCollection',
related_name='collections',
verbose_name=_('podcasts'),
)
def __str__(self):
return f'Collection #{self.id}/{self.title}'
class Meta:
verbose_name = _('Podcast Collection')
verbose_name_plural = _('Podcasts Collections')
class PodcastInCollection(models.Model):
video_collection = models.ForeignKey(
VideoCollection, on_delete=models.CASCADE, related_name='podcasts_in_collection', verbose_name=_('podcast collection')
)
podcast = models.ForeignKey(
Podcast, on_delete=models.CASCADE, related_name='collections_podcasts', verbose_name=_('podcasts')
)
priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
def __str__(self):
return f"{self.podcast_collection.title} - {self.podcast.title} (Priority: {self.priority})"
class Meta:
verbose_name = _('Podcast in Collection')
verbose_name_plural = _('Podcasts in Collection')
ordering = ['priority']
class Podcast(models.Model):
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_(
'image allowed'
))
description = models.TextField(null=True)
audio_file = models.FileField(upload_to='podcast/audio/', null=True, blank=True)
audio_url = models.CharField(max_length=655, null=True, blank=True)
audio_time = models.TimeField()
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
status = models.BooleanField(default=True, verbose_name=_('status'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def __str__(self):
return self.title
class Meta:
verbose_name = _('Podcast')
verbose_name_plural = _('Podcasts')

3
apps/podcast/tests.py

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

3
apps/podcast/views.py

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

0
apps/video/__init__.py

23
apps/video/admin.py

@ -0,0 +1,23 @@
from django.contrib import admin
from ajaxdatatable.admin import AjaxDatatable
from apps.video.models import *
class VideoInCollectionInline(admin.TabularInline):
model = VideoInCollection
extra = 1
@admin.register(VideoCollection)
class VideoCollectionAdmin(AjaxDatatable):
list_display = ('title',)
inlines = [VideoInCollectionInline]
@admin.register(Video)
class VideoAdmin(AjaxDatatable):
list_display = ('title', 'video_type', 'status')
search_fields = ('title',)

6
apps/video/apps.py

@ -0,0 +1,6 @@
from django.apps import AppConfig
class VideoConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.video'

0
apps/video/migrations/__init__.py

71
apps/video/models.py

@ -0,0 +1,71 @@
from django.db import models
class VideoCollection(models.Model):
title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere")
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
videos = models.ManyToManyField(
Video,
through='VideoInCollection',
related_name='collections',
verbose_name=_('videos'),
)
def __str__(self):
return f'Collection #{self.id}/{self.title}'
class Meta:
verbose_name = _('Video Collection')
verbose_name_plural = _('Video Collections')
class VideoInCollection(models.Model):
video_collection = models.ForeignKey(
VideoCollection, on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection')
)
video = models.ForeignKey(
Video, on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video')
)
priority = models.PositiveIntegerField(default=0, verbose_name=_('priority'))
def __str__(self):
return f"{self.video_collection.title} - {self.video.title} (Priority: {self.priority})"
class Meta:
verbose_name = _('Video in Collection')
verbose_name_plural = _('Videos in Collection')
ordering = ['priority']
class Video(models.Model):
class vdeo_type(models.TextChoices):
FILE = 'file'
YOUTUBE = 'youtube'
title = models.CharField(max_length=255, null=True)
slug = models.SlugField(allow_unicode=True, unique=True)
thumbnail = FilerImageField(related_name="+", on_delete=models.SET_NULL, null=True, blank=True, help_text=_(
'image allowed'
))
description = models.TextField(null=True)
video_type = models.CharField(max_length=255, choices=vdeo_type.choices, default=vdeo_type.FILE)
video_file = models.FileField(upload_to='video/videos/', null=True, blank=True)
video_url = models.CharField(max_length=655, null=True, blank=True)
video_time = models.TimeField()
view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count'))
status = models.BooleanField(default=True, verbose_name=_('status'))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'))
def __str__(self):
return self.title
class Meta:
verbose_name = _('Video')
verbose_name_plural = _('Videos')

3
apps/video/tests.py

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

3
apps/video/views.py

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

3
config/settings/base.py

@ -47,6 +47,8 @@ LOCAL_APPS = [
'apps.quiz.apps.QuizConfig',
'apps.transaction.apps.TransactionConfig',
'apps.certificate.apps.CertificateConfig',
'apps.hadis.apps.HadisConfig',
'apps.library.apps.LibraryConfig',
'dynamic_preferences',
]
@ -60,6 +62,7 @@ THIRD_PARTY_APPS = [
'dj_language',
'dj_filer',
'ajaxdatatable',
'dj_category',
'corsheaders',
'django_filters',

1
config/urls.py

@ -39,6 +39,7 @@ api_patterns = [
path('quiz/', include('apps.quiz.urls')),
path('transaction/', include('apps.transaction.urls')),
path('certificates/', include('apps.certificate.urls')),
path('hadis/', include('apps.hadis.urls')),
path('settings/', include('dynamic_preferences.urls')),

20
test.py

@ -0,0 +1,20 @@
import random
# import pyarabic.araby as araby
# from fuzzywuzzy import fuzz
# from utils.similarity import find_similarity ,rm_sign
import json
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.develop')
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
from apps.course.models import Course, CourseCategory
from apps.hadis.models.category import HadisCategory
g = HadisCategory.objects.all()[2]
print(f'---> {g.parent}')

2
utils/redis.py

@ -14,7 +14,7 @@ class RedisManager(RedisConfig):
def add_to_redis(self, code, **kwargs) -> bool:
try:
password = kwargs['password'] if kwargs['password'] else None
password = kwargs.get('password')
key = self.__serialize(
code=code, fullname=kwargs['fullname'], password=password
)

Loading…
Cancel
Save