diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index e565768..255a3c3 100644 --- a/apps/account/serializers/user.py +++ b/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() - - class Meta: - model = User - fields = ["email", "code"] - extra_kwargs = { - 'email': {'required': True,}, - 'code': {'required': True,}, - } + device_id = serializers.CharField(max_length=255, required=False) + -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): diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 134b2bc..5553186 100644 --- a/apps/account/views/user.py +++ b/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" diff --git a/apps/hadis/__init__.py b/apps/hadis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/admin/__init__.py b/apps/hadis/admin/__init__.py new file mode 100644 index 0000000..ba8ce70 --- /dev/null +++ b/apps/hadis/admin/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/admin/category.py b/apps/hadis/admin/category.py new file mode 100644 index 0000000..4ffcefa --- /dev/null +++ b/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) + + + diff --git a/apps/hadis/admin/hadis.py b/apps/hadis/admin/hadis.py new file mode 100644 index 0000000..444918c --- /dev/null +++ b/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 + diff --git a/apps/hadis/admin/transmitter.py b/apps/hadis/admin/transmitter.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/apps.py b/apps/hadis/apps.py new file mode 100644 index 0000000..47fcf3d --- /dev/null +++ b/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' diff --git a/apps/hadis/doc.py b/apps/hadis/doc.py new file mode 100644 index 0000000..6976da7 --- /dev/null +++ b/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." + } +) \ No newline at end of file diff --git a/apps/hadis/migrations/0001_initial.py b/apps/hadis/migrations/0001_initial.py new file mode 100644 index 0000000..0cd0e90 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/hadis/migrations/0002_auto_20250317_0055.py b/apps/hadis/migrations/0002_auto_20250317_0055.py new file mode 100644 index 0000000..819cede --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/hadis/migrations/0003_auto_20250317_0102.py b/apps/hadis/migrations/0003_auto_20250317_0102.py new file mode 100644 index 0000000..12cabcb --- /dev/null +++ b/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, + ), + ] diff --git a/apps/hadis/migrations/0004_auto_20250321_0119.py b/apps/hadis/migrations/0004_auto_20250321_0119.py new file mode 100644 index 0000000..1f4decf --- /dev/null +++ b/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')}, + ), + ] diff --git a/apps/hadis/migrations/0005_auto_20250321_1550.py b/apps/hadis/migrations/0005_auto_20250321_1550.py new file mode 100644 index 0000000..1a8af1e --- /dev/null +++ b/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', + ), + ] diff --git a/apps/hadis/migrations/0006_auto_20250321_1600.py b/apps/hadis/migrations/0006_auto_20250321_1600.py new file mode 100644 index 0000000..d314b59 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/hadis/migrations/__init__.py b/apps/hadis/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/models/__init__.py b/apps/hadis/models/__init__.py new file mode 100644 index 0000000..ba8ce70 --- /dev/null +++ b/apps/hadis/models/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py new file mode 100644 index 0000000..7fde53e --- /dev/null +++ b/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 + + + + diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py new file mode 100644 index 0000000..91c8523 --- /dev/null +++ b/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) + diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py new file mode 100644 index 0000000..08d7e48 --- /dev/null +++ b/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})' diff --git a/apps/hadis/serializers.py b/apps/hadis/serializers.py new file mode 100644 index 0000000..3ee2628 --- /dev/null +++ b/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') \ No newline at end of file diff --git a/apps/hadis/templates/admin/category_index.html b/apps/hadis/templates/admin/category_index.html new file mode 100644 index 0000000..a3653c0 --- /dev/null +++ b/apps/hadis/templates/admin/category_index.html @@ -0,0 +1,2343 @@ +{% extends 'admin/change_form.html' %} +{% load i18n static admin_modify mptt_tags %} + +{% block content %} +
+
+
+ +
+
+
{% trans "Category Tree Editor" %}
+
+
+

+ {% trans "Make your category and sort it by drag and drop . and try to edit items by double click." %} +

+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% csrf_token %} + {% block form_top %}{% endblock %} +
+ {% block field_sets %} + + + +
+
+ + + +
+
+
+ + {% trans "Level 1 categories represent source types: Shia or Sunni" %} +
+ +
+
+
+ + {% trans "Level 2 categories are children of Shia/Sunni with content type: Quran or Hadith" %} +
+ +
+
+
+ + {% trans "Level 3 categories are children of Quran or Hadith categories" %} +
+ +
+
+
+
+ + +
+
+
+ + + + + {% trans "Select a parent category or leave empty for top-level category" %} + +
+
+
+ + {% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" with fullwidth="" %} + {% endfor %} + + {% endblock %} +
+ {% include "admin/submit_line.html" with show_save_and_continue=True show_delete_link=True %} +
+
+
+
+
+
+ + + +
+{% endblock %} + +{% block scripts %} + {{ block.super }} + + + + + + + + + + + + + +{% endblock %} diff --git a/apps/hadis/templates/admin/hadiscategory/change_form.html b/apps/hadis/templates/admin/hadiscategory/change_form.html new file mode 100644 index 0000000..40424b1 --- /dev/null +++ b/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 }} + +{% endblock %} + +{% block scripts %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/apps/hadis/templates/admin/hadisowerview_change_form.html b/apps/hadis/templates/admin/hadisowerview_change_form.html new file mode 100644 index 0000000..0ccd13b --- /dev/null +++ b/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 }} + + + +{% endblock %} + +{% block scripts %} + {{ block.super }} + + + +{% endblock %} diff --git a/apps/hadis/templates/admin/widgets/color_radio.html b/apps/hadis/templates/admin/widgets/color_radio.html new file mode 100644 index 0000000..aa7786a --- /dev/null +++ b/apps/hadis/templates/admin/widgets/color_radio.html @@ -0,0 +1,7 @@ +{% for group, options, index in widget.optgroups %} + {% for option in options %} +
+ {% include option.template_name with widget=option %} +
+ {% endfor %} +{% endfor %} \ No newline at end of file diff --git a/apps/hadis/templates/admin/widgets/color_radio_option.html b/apps/hadis/templates/admin/widgets/color_radio_option.html new file mode 100644 index 0000000..7897608 --- /dev/null +++ b/apps/hadis/templates/admin/widgets/color_radio_option.html @@ -0,0 +1,9 @@ +{% if widget.wrap_label %} + +{% endif %} + + + {{ widget.label }} +{% if widget.wrap_label %} + +{% endif %} \ No newline at end of file diff --git a/apps/hadis/tests.py b/apps/hadis/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/hadis/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py new file mode 100644 index 0000000..306b2eb --- /dev/null +++ b/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//hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'), +] \ No newline at end of file diff --git a/apps/hadis/views/__init__.py b/apps/hadis/views/__init__.py new file mode 100644 index 0000000..b239bfe --- /dev/null +++ b/apps/hadis/views/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +# from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py new file mode 100644 index 0000000..df35e82 --- /dev/null +++ b/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 diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py new file mode 100644 index 0000000..9722e35 --- /dev/null +++ b/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', + ) + diff --git a/apps/library/__init__.py b/apps/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/library/admin.py b/apps/library/admin.py new file mode 100644 index 0000000..bcf9a89 --- /dev/null +++ b/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('{}', 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('{}', url, count) + return count + diff --git a/apps/library/apps.py b/apps/library/apps.py new file mode 100644 index 0000000..166aebd --- /dev/null +++ b/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' diff --git a/apps/library/migrations/0001_initial.py b/apps/library/migrations/0001_initial.py new file mode 100644 index 0000000..6d16c23 --- /dev/null +++ b/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',), + ), + ] diff --git a/apps/library/migrations/0002_alter_bookcollection_title.py b/apps/library/migrations/0002_alter_bookcollection_title.py new file mode 100644 index 0000000..0efec27 --- /dev/null +++ b/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), + ), + ] diff --git a/apps/library/migrations/0003_auto_20250321_0119.py b/apps/library/migrations/0003_auto_20250321_0119.py new file mode 100644 index 0000000..a64ffc2 --- /dev/null +++ b/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', + ), + ] diff --git a/apps/library/migrations/__init__.py b/apps/library/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/library/models.py b/apps/library/models.py new file mode 100644 index 0000000..d6fa0f8 --- /dev/null +++ b/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') + + diff --git a/apps/library/serializers.py b/apps/library/serializers.py new file mode 100644 index 0000000..8676cbf --- /dev/null +++ b/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') + diff --git a/apps/library/tests.py b/apps/library/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/library/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/library/views.py b/apps/library/views.py new file mode 100644 index 0000000..c000528 --- /dev/null +++ b/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', ) + + diff --git a/apps/podcast/__init__.py b/apps/podcast/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/podcast/admin.py b/apps/podcast/admin.py new file mode 100644 index 0000000..fb046ae --- /dev/null +++ b/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',) diff --git a/apps/podcast/apps.py b/apps/podcast/apps.py new file mode 100644 index 0000000..076e705 --- /dev/null +++ b/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' + + diff --git a/apps/podcast/migrations/__init__.py b/apps/podcast/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/podcast/models.py b/apps/podcast/models.py new file mode 100644 index 0000000..8cc1594 --- /dev/null +++ b/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') + diff --git a/apps/podcast/tests.py b/apps/podcast/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/podcast/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/podcast/views.py b/apps/podcast/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/podcast/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/video/__init__.py b/apps/video/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/video/admin.py b/apps/video/admin.py new file mode 100644 index 0000000..12a7364 --- /dev/null +++ b/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',) diff --git a/apps/video/apps.py b/apps/video/apps.py new file mode 100644 index 0000000..c091c61 --- /dev/null +++ b/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' diff --git a/apps/video/migrations/__init__.py b/apps/video/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/video/models.py b/apps/video/models.py new file mode 100644 index 0000000..d168ffa --- /dev/null +++ b/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') + diff --git a/apps/video/tests.py b/apps/video/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/video/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/video/views.py b/apps/video/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/video/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/config/settings/base.py b/config/settings/base.py index c47f595..149610d 100644 --- a/config/settings/base.py +++ b/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', diff --git a/config/urls.py b/config/urls.py index 759e195..41def22 100644 --- a/config/urls.py +++ b/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')), diff --git a/test.py b/test.py new file mode 100644 index 0000000..eb0983d --- /dev/null +++ b/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}') \ No newline at end of file diff --git a/utils/redis.py b/utils/redis.py index d46a6c3..9d66dd3 100644 --- a/utils/redis.py +++ b/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 )