60 changed files with 4917 additions and 73 deletions
-
75apps/account/serializers/user.py
-
46apps/account/views/user.py
-
0apps/hadis/__init__.py
-
3apps/hadis/admin/__init__.py
-
217apps/hadis/admin/category.py
-
161apps/hadis/admin/hadis.py
-
0apps/hadis/admin/transmitter.py
-
6apps/hadis/apps.py
-
159apps/hadis/doc.py
-
87apps/hadis/migrations/0001_initial.py
-
22apps/hadis/migrations/0002_auto_20250317_0055.py
-
23apps/hadis/migrations/0003_auto_20250317_0102.py
-
121apps/hadis/migrations/0004_auto_20250321_0119.py
-
21apps/hadis/migrations/0005_auto_20250321_1550.py
-
23apps/hadis/migrations/0006_auto_20250321_1600.py
-
0apps/hadis/migrations/__init__.py
-
3apps/hadis/models/__init__.py
-
105apps/hadis/models/category.py
-
102apps/hadis/models/hadis.py
-
52apps/hadis/models/transmitter.py
-
50apps/hadis/serializers.py
-
2343apps/hadis/templates/admin/category_index.html
-
42apps/hadis/templates/admin/hadiscategory/change_form.html
-
153apps/hadis/templates/admin/hadisowerview_change_form.html
-
7apps/hadis/templates/admin/widgets/color_radio.html
-
9apps/hadis/templates/admin/widgets/color_radio_option.html
-
3apps/hadis/tests.py
-
9apps/hadis/urls.py
-
3apps/hadis/views/__init__.py
-
301apps/hadis/views/category.py
-
29apps/hadis/views/hadis.py
-
0apps/library/__init__.py
-
192apps/library/admin.py
-
9apps/library/apps.py
-
140apps/library/migrations/0001_initial.py
-
18apps/library/migrations/0002_alter_bookcollection_title.py
-
26apps/library/migrations/0003_auto_20250321_0119.py
-
0apps/library/migrations/__init__.py
-
132apps/library/models.py
-
33apps/library/serializers.py
-
3apps/library/tests.py
-
22apps/library/views.py
-
0apps/podcast/__init__.py
-
23apps/podcast/admin.py
-
8apps/podcast/apps.py
-
0apps/podcast/migrations/__init__.py
-
71apps/podcast/models.py
-
3apps/podcast/tests.py
-
3apps/podcast/views.py
-
0apps/video/__init__.py
-
23apps/video/admin.py
-
6apps/video/apps.py
-
0apps/video/migrations/__init__.py
-
71apps/video/models.py
-
3apps/video/tests.py
-
3apps/video/views.py
-
3config/settings/base.py
-
1config/urls.py
-
20test.py
-
2utils/redis.py
@ -0,0 +1,3 @@ |
|||
from .category import * |
|||
from .hadis import * |
|||
from .transmitter import * |
|||
@ -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) |
|||
|
|||
|
|||
|
|||
@ -0,0 +1,161 @@ |
|||
from django.contrib import admin |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from dj_category.admin import BaseCategoryAdmin |
|||
from ajaxdatatable.admin import AjaxDatatable |
|||
from django.http import JsonResponse |
|||
from django.urls import path |
|||
from django.db.models import Q |
|||
from django.utils.safestring import mark_safe |
|||
from django.forms.widgets import RadioSelect |
|||
|
|||
from apps.hadis.models import * |
|||
from django import forms |
|||
from utils.json_editor_field import JsonEditorWidget |
|||
|
|||
# Define color choices |
|||
COLOR_CHOICES = [ |
|||
('red', _('Red')), |
|||
('blue', _('Blue')), |
|||
('green', _('Green')), |
|||
('yellow', _('Yellow')), |
|||
('orange', _('Orange')), |
|||
('purple', _('Purple')), |
|||
('pink', _('Pink')), |
|||
('brown', _('Brown')), |
|||
('gray', _('Gray')), |
|||
('black', _('Black')), |
|||
] |
|||
|
|||
class ColorRadioSelect(RadioSelect): |
|||
template_name = 'admin/widgets/color_radio.html' |
|||
option_template_name = 'admin/widgets/color_radio_option.html' |
|||
|
|||
|
|||
def get_links_schema(): |
|||
return { |
|||
'type': "array", |
|||
'format': 'table', |
|||
'title': ' ', |
|||
'items': { |
|||
'type': 'object', |
|||
'title': str(_('Link')), |
|||
'properties': { |
|||
'text': {'type': 'string', "format": "textarea",'title': str(_('text'))}, |
|||
'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))}, |
|||
} |
|||
} |
|||
} |
|||
|
|||
class HadisOverviewForm(forms.ModelForm): |
|||
status_color = forms.ChoiceField( |
|||
choices=COLOR_CHOICES, |
|||
widget=ColorRadioSelect(), |
|||
required=False |
|||
) |
|||
|
|||
class Meta: |
|||
model = HadisOverview |
|||
fields = '__all__' |
|||
widgets = { |
|||
'links': JsonEditorWidget(attrs={'schema': get_links_schema}), |
|||
} |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
@admin.register(HadisTag) |
|||
class HadisTagAdmin(AjaxDatatable): |
|||
list_display = ['title', 'status'] |
|||
search_fields = ['title'] |
|||
|
|||
|
|||
class ReferenceImageInline(admin.TabularInline): |
|||
model = ReferenceImage |
|||
extra = 1 |
|||
verbose_name_plural = _('Reference Images') |
|||
fields = ('thumbnail', 'priority') |
|||
|
|||
|
|||
@admin.register(HadisReference) |
|||
class HadisReferenceAdmin(AjaxDatatable): |
|||
list_display = ['hadis', 'book', 'created_at'] |
|||
list_filter = ['book'] |
|||
search_fields = ['hadis__title', 'hadis__number', 'description'] |
|||
autocomplete_fields = ['hadis', 'book'] |
|||
readonly_fields = ['created_at'] |
|||
inlines = [ReferenceImageInline] |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('hadis', 'book', 'description') |
|||
}), |
|||
) |
|||
|
|||
|
|||
|
|||
|
|||
@admin.register(HadisOverview) |
|||
class HadisOverviewAdmin(AjaxDatatable): |
|||
change_form_template = 'admin/hadisowerview_change_form.html' |
|||
form = HadisOverviewForm |
|||
ordering = ['hadis__number'] |
|||
list_display = ['hadis', 'status', 'created_at'] |
|||
search_fields = ['hadis__title', 'hadis__number', 'status_text',] |
|||
autocomplete_fields = ['hadis', 'tags'] |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('hadis', 'status', 'status_color', 'status_text') |
|||
}), |
|||
(_('Reference Information'), { |
|||
'fields': ('address', 'share_link',), |
|||
}), |
|||
(_('Additional Information'), { |
|||
'fields': ('links', 'tags', 'created_at'), |
|||
'classes': ('collapse',), |
|||
}), |
|||
) |
|||
|
|||
|
|||
class HadisOverviewInline(admin.StackedInline): |
|||
change_form_template = 'admin/hadisowerview_change_form.html' |
|||
form = HadisOverviewForm |
|||
model = HadisOverview |
|||
autocomplete_fields = ['tags', ] |
|||
can_delete = False |
|||
verbose_name_plural = _('Hadis Overview') |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), |
|||
}), |
|||
) |
|||
extra = 1 |
|||
min_num = 1 |
|||
|
|||
|
|||
@admin.register(Hadis) |
|||
class HadisAdmin(AjaxDatatable): |
|||
# form = HadisForm |
|||
list_display = ['number', 'title', 'category', 'status', 'created_at'] |
|||
list_filter = ['status', 'category'] |
|||
search_fields = ['title', 'text', 'number'] |
|||
readonly_fields = ['created_at', 'updated_at'] |
|||
autocomplete_fields = ['category'] |
|||
inlines = [HadisOverviewInline] |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('number', 'title', 'category', 'status') |
|||
}), |
|||
(_('Content'), { |
|||
'fields': ('text', 'translation'), |
|||
'classes': ('collapse',), |
|||
}), |
|||
) |
|||
|
|||
|
|||
def get_form(self, request, obj=None, **kwargs): |
|||
form = super().get_form(request, obj, **kwargs) |
|||
if obj is None: |
|||
form.base_fields['category'].widget.can_add_related = False |
|||
|
|||
return form |
|||
|
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class HadisConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.hadis' |
|||
@ -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." |
|||
} |
|||
) |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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, |
|||
), |
|||
] |
|||
@ -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')}, |
|||
), |
|||
] |
|||
@ -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', |
|||
), |
|||
] |
|||
@ -0,0 +1,23 @@ |
|||
# Generated by Django 3.2.7 on 2025-03-21 16:00 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('hadis', '0005_auto_20250321_1550'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterField( |
|||
model_name='hadisoverview', |
|||
name='address', |
|||
field=models.TextField(blank=True, null=True, verbose_name='address'), |
|||
), |
|||
migrations.AlterField( |
|||
model_name='hadisoverview', |
|||
name='status_text', |
|||
field=models.TextField(blank=True, null=True, verbose_name='Status Text'), |
|||
), |
|||
] |
|||
@ -0,0 +1,3 @@ |
|||
from .category import * |
|||
from .hadis import * |
|||
from .transmitter import * |
|||
@ -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 |
|||
|
|||
|
|||
|
|||
|
|||
@ -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) |
|||
|
|||
@ -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})' |
|||
@ -0,0 +1,50 @@ |
|||
|
|||
from rest_framework import serializers |
|||
|
|||
from apps.hadis.models import * |
|||
|
|||
|
|||
class HadisCategorySerializer(serializers.ModelSerializer): |
|||
children = serializers.SerializerMethodField('get_children') |
|||
name = serializers.SerializerMethodField() |
|||
hadis_count = serializers.SerializerMethodField() |
|||
source_type = serializers.CharField(read_only=True) |
|||
|
|||
|
|||
def get_children(self, obj): |
|||
return [self.to_dict(cat) for cat in obj.get_children()] |
|||
|
|||
def to_dict(self, c): |
|||
children = c.get_children() |
|||
|
|||
return { |
|||
'id': c.id, |
|||
'name': c.name, |
|||
'hadis_count': c.hadis_count, |
|||
'source_type': c.source_type, |
|||
'category_type': c.category_type, |
|||
'children': [] if not children else [self.to_dict(i) for i in children], |
|||
} |
|||
|
|||
class Meta: |
|||
model = HadisCategory |
|||
fields = ['id', 'name', 'hadis_count', 'source_type','children'] |
|||
|
|||
|
|||
class HadisTagSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = HadisTag |
|||
fields = ('id', 'title') |
|||
|
|||
|
|||
class HadisSerializer(serializers.ModelSerializer): |
|||
translation = serializers.CharField(source='translation') |
|||
text = serializers.CharField(source='text') |
|||
tags = serializers.SerializerMethodField() |
|||
|
|||
def get_tags(self, obj): |
|||
return HadisTagSerializer(obj.get_tags, many=True).data |
|||
|
|||
class Meta: |
|||
model = Hadis |
|||
fields = ('number', 'title', 'text', 'translation', 'tags') |
|||
2343
apps/hadis/templates/admin/category_index.html
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,42 @@ |
|||
{% extends 'admin/category_index.html' %} |
|||
{% load i18n admin_urls %} |
|||
|
|||
{% block extrahead %} |
|||
{{ block.super }} |
|||
<style> |
|||
.bg-success { |
|||
background-color: #4CAF50 !important; |
|||
} |
|||
.bg-success:hover { |
|||
background-color: #45a049 !important; |
|||
} |
|||
</style> |
|||
{% endblock %} |
|||
|
|||
{% block scripts %} |
|||
{{ block.super }} |
|||
<script> |
|||
$(document).ready(function() { |
|||
// Add the button after the page is loaded |
|||
setTimeout(function() { |
|||
// Find the history button in the header |
|||
var $historyBtn = $(".historylink.float-right.btn"); |
|||
|
|||
// Create the Add Category button with the same styling as the History button |
|||
var addUrl = "{{ request.path }}".replace(/\/\d+\/change\/$/, "/add/"); |
|||
var $addCategoryBtn = $('<a href="' + addUrl + '" class="float-right btn bg-success legitRipple mr-3 ml-3">' + |
|||
'{% trans "Add Category" %} <i class="icon-plus-circle2"></i></a>'); |
|||
|
|||
// Insert the Add Category button before the History button |
|||
if ($historyBtn.length) { |
|||
$historyBtn.before($addCategoryBtn); |
|||
} else { |
|||
// If history button not found, add to the btns-head div |
|||
$(".btns-head .dtr-inline").append($addCategoryBtn); |
|||
} |
|||
|
|||
console.log("Add Category button added"); |
|||
}, 1000); // Increased timeout to ensure DOM is fully loaded |
|||
}); |
|||
</script> |
|||
{% endblock %} |
|||
@ -0,0 +1,153 @@ |
|||
{% extends "admin/change_form.html" %} |
|||
{% load i18n %} |
|||
{% load static %} |
|||
|
|||
{% block submit_buttons_bottom %} |
|||
{{ block.super }} |
|||
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_next"> |
|||
{% translate 'Save And Edit Next Hadis' %} |
|||
<i class="mi-border-color ml-2"></i> |
|||
</button> |
|||
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_prev"> |
|||
{% translate 'Save And Edit Previus Hadis' %} |
|||
<i class="mi-border-color ml-2"></i> |
|||
</button> |
|||
<button type="submit" class="btn-block mt-3 btn bg-indigo-600 legitRipple" name="_save_and_random"> |
|||
{% translate 'Save And Edit Random' %} |
|||
<i class="mi-border-color ml-2"></i> |
|||
</button> |
|||
{% endblock %} |
|||
|
|||
{% block scripts %} |
|||
{{ block.super }} |
|||
|
|||
<style> |
|||
.color-picker-container { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 12px; |
|||
margin: 15px 0; |
|||
justify-content: flex-start; |
|||
max-width: 600px; |
|||
} |
|||
|
|||
.color-option { |
|||
flex: 0 0 auto; |
|||
} |
|||
|
|||
.color-radio-label { |
|||
cursor: pointer; |
|||
margin: 0; |
|||
padding: 0; |
|||
} |
|||
|
|||
.color-option-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
transition: all 0.2s ease; |
|||
padding: 8px; |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.color-option-container:hover { |
|||
background-color: rgba(0, 0, 0, 0.05); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.color-preview { |
|||
display: block; |
|||
width: 36px; |
|||
height: 36px; |
|||
border-radius: 50%; |
|||
border: 2px solid #e0e0e0; |
|||
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
|||
transition: all 0.2s ease; |
|||
position: relative; |
|||
} |
|||
|
|||
.color-name { |
|||
display: block; |
|||
font-size: 12px; |
|||
margin-top: 6px; |
|||
color: #555; |
|||
font-weight: 500; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
input[type="radio"] { |
|||
display: none; |
|||
} |
|||
|
|||
input[type="radio"]:checked + .color-option-container { |
|||
background-color: rgba(0, 0, 0, 0.05); |
|||
} |
|||
|
|||
input[type="radio"]:checked + .color-option-container .color-preview { |
|||
border: 2px solid #333; |
|||
box-shadow: 0 0 0 2px rgba(0,0,0,0.2); |
|||
transform: scale(1.1); |
|||
} |
|||
|
|||
input[type="radio"]:checked + .color-option-container .color-preview:after { |
|||
content: '✓'; |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
color: white; |
|||
text-shadow: 0 0 2px rgba(0,0,0,0.7); |
|||
font-size: 18px; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
input[type="radio"]:checked + .color-option-container .color-name { |
|||
color: #000; |
|||
font-weight: 600; |
|||
} |
|||
</style> |
|||
<script> |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
// Get all color radio options |
|||
const colorOptions = document.querySelectorAll('.color-option input[type="radio"]'); |
|||
|
|||
// Check if there's a pre-selected value |
|||
const statusColorField = document.querySelector('input[name$="-status_color"]'); |
|||
const preSelectedValue = statusColorField ? statusColorField.value : null; |
|||
|
|||
colorOptions.forEach(function(radio) { |
|||
// Add click event to the color container |
|||
const colorContainer = radio.nextElementSibling; |
|||
colorContainer.addEventListener('click', function() { |
|||
radio.checked = true; |
|||
|
|||
// Trigger change event to update any listeners |
|||
const event = new Event('change', { bubbles: true }); |
|||
radio.dispatchEvent(event); |
|||
}); |
|||
|
|||
// If this radio's value matches the pre-selected value, check it |
|||
if (preSelectedValue && radio.value === preSelectedValue) { |
|||
radio.checked = true; |
|||
|
|||
// Scroll to make the selected color visible |
|||
setTimeout(() => { |
|||
radio.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
|||
}, 300); |
|||
} |
|||
}); |
|||
|
|||
// If no color is selected but there's a value in the hidden field, try to match it |
|||
if (preSelectedValue && !document.querySelector('.color-option input[type="radio"]:checked')) { |
|||
// Try to find a color that matches (case insensitive) |
|||
const matchingRadio = Array.from(colorOptions).find(radio => |
|||
radio.value.toLowerCase() === preSelectedValue.toLowerCase() |
|||
); |
|||
|
|||
if (matchingRadio) { |
|||
matchingRadio.checked = true; |
|||
} |
|||
} |
|||
}); |
|||
</script> |
|||
{% endblock %} |
|||
@ -0,0 +1,7 @@ |
|||
{% for group, options, index in widget.optgroups %} |
|||
{% for option in options %} |
|||
<div class="color-option" style="display: inline-block; margin: 5px;"> |
|||
{% include option.template_name with widget=option %} |
|||
</div> |
|||
{% endfor %} |
|||
{% endfor %} |
|||
@ -0,0 +1,9 @@ |
|||
{% if widget.wrap_label %} |
|||
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}> |
|||
{% endif %} |
|||
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}> |
|||
<span class="color-preview" style="display: inline-block; width: 30px; height: 30px; background-color: {{ widget.value }}; border: 1px solid #ccc; border-radius: 5px;"></span> |
|||
<span class="color-name">{{ widget.label }}</span> |
|||
{% if widget.wrap_label %} |
|||
</label> |
|||
{% endif %} |
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,9 @@ |
|||
from django.urls import path, include |
|||
from . import views |
|||
|
|||
|
|||
urlpatterns = [ |
|||
path('categories/', views.CategoryListView.as_view(), name='category-list'), |
|||
|
|||
path('categories/<int:pk>/hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'), |
|||
] |
|||
@ -0,0 +1,3 @@ |
|||
from .category import * |
|||
from .hadis import * |
|||
# from .transmitter import * |
|||
@ -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 |
|||
@ -0,0 +1,29 @@ |
|||
from rest_framework.permissions import IsAuthenticated |
|||
from rest_framework.response import Response |
|||
from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch |
|||
from rest_framework.generics import ListAPIView |
|||
|
|||
|
|||
from apps.hadis.models import * |
|||
from apps.hadis.serializers import * |
|||
from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger |
|||
|
|||
|
|||
|
|||
class CategoryHadisListView(ListAPIView): |
|||
serializer_class = HadisSerializer |
|||
permission_classes = (IsAuthenticated,) |
|||
|
|||
@category_hadis_list_swagger |
|||
def get(self, request, *args, **kwargs): |
|||
return super().get(request, *args, **kwargs) |
|||
def get_queryset(self): |
|||
categories = HadisCategory.objects.filter(id=self.kwargs['pk']).order_by('-order') |
|||
return Hadis.objects.filter( |
|||
Q(category__in=categories), |
|||
status=True, |
|||
).prefetch_related( |
|||
'category', |
|||
'tags', |
|||
) |
|||
|
|||
@ -0,0 +1,192 @@ |
|||
from django.contrib import admin |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from django.urls import reverse |
|||
from django.utils.html import format_html |
|||
from ajaxdatatable.admin import AjaxDatatable |
|||
|
|||
from apps.library.models import * |
|||
|
|||
|
|||
@admin.register(Book) |
|||
class BookAdmin(AjaxDatatable): |
|||
list_display = ('title', 'slug', 'status', 'pin', 'file_type', 'view_count', 'created_at') |
|||
list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at') |
|||
search_fields = ('title', 'slug', 'summary', 'description') |
|||
# autocomplete_fields = ('categories', 'collections', ) |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('title', 'slug', 'summary', 'description', 'thumbnail', 'pages_count') |
|||
}), |
|||
(_('Status'), { |
|||
'fields': ('status', 'pin') |
|||
}), |
|||
(_('File Information'), { |
|||
'fields': ('file_type', 'book_file') |
|||
}), |
|||
(_('Relations'), { |
|||
'fields': ('categories', 'collections') |
|||
}), |
|||
(_('Statistics'), { |
|||
'fields': ('view_count',) |
|||
}), |
|||
) |
|||
|
|||
|
|||
|
|||
class BookCollectionAdminBase(AjaxDatatable): |
|||
"""Base admin class for all book collection types""" |
|||
list_display = ('get_title', 'status', 'order', 'count_books') |
|||
list_filter = ('status',) |
|||
search_fields = ('title',) |
|||
autocomplete_fields = ('books',) |
|||
ordering = ('order',) |
|||
|
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('title', 'summary', 'status', 'order') |
|||
}), |
|||
(_('Books'), { |
|||
'fields': ('books',) |
|||
}), |
|||
) |
|||
|
|||
exclude = ('display_position',) |
|||
|
|||
def get_title(self, obj): |
|||
return str(obj.title) |
|||
get_title.short_description = _('Title') |
|||
|
|||
|
|||
@admin.display(description=_('Number of Books')) |
|||
def count_books(self, obj): |
|||
count = obj.books.count() |
|||
if count > 0: |
|||
url = reverse('admin:library_book_changelist') + f'?collections__id__exact={obj.id}' |
|||
return format_html('<a href="{}">{}</a>', url, count) |
|||
return count |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
@admin.register(PinnedBookCollection) |
|||
class PinnedBookCollectionAdmin(BookCollectionAdminBase): |
|||
"""Admin for pinned book collections only""" |
|||
|
|||
def get_queryset(self, request): |
|||
# Only show pinned collections |
|||
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.PINNED) |
|||
|
|||
def save_model(self, request, obj, form, change): |
|||
# Ensure the display_position is always set to PINNED |
|||
obj.display_position = BookCollection.DisplayPosition.PINNED |
|||
super().save_model(request, obj, form, change) |
|||
|
|||
|
|||
@admin.register(MiddleBookCollection) |
|||
class MiddleBookCollectionAdmin(BookCollectionAdminBase): |
|||
"""Admin for middle section book collections only""" |
|||
|
|||
def get_queryset(self, request): |
|||
# Only show middle section collections |
|||
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.MIDDLE) |
|||
|
|||
def has_add_permission(self, request): |
|||
# Check if a middle collection already exists |
|||
exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() |
|||
# Only allow adding if no middle collection exists |
|||
return not exists |
|||
|
|||
def has_delete_permission(self, request, obj=None): |
|||
# Prevent deletion of the middle collection |
|||
return False |
|||
|
|||
def save_model(self, request, obj, form, change): |
|||
# Ensure the display_position is always set to MIDDLE |
|||
obj.display_position = BookCollection.DisplayPosition.MIDDLE |
|||
super().save_model(request, obj, form, change) |
|||
|
|||
def changelist_view(self, request, extra_context=None): |
|||
# Check if a middle collection exists |
|||
try: |
|||
# Try to get the first (and should be only) middle collection |
|||
obj = self.get_queryset(request).first() |
|||
if obj: |
|||
# If it exists, redirect to the change view for this object |
|||
from django.http import HttpResponseRedirect |
|||
from django.urls import reverse |
|||
url = reverse( |
|||
'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), |
|||
args=[obj.pk] |
|||
) |
|||
return HttpResponseRedirect(url) |
|||
except Exception: |
|||
# If any error occurs, just show the changelist view as usual |
|||
pass |
|||
|
|||
# If no object exists or there was an error, show the default changelist view |
|||
return super().changelist_view(request, extra_context) |
|||
|
|||
|
|||
@admin.register(BottomBookCollection) |
|||
class BottomBookCollectionAdmin(BookCollectionAdminBase): |
|||
"""Admin for bottom section book collections only""" |
|||
|
|||
def get_queryset(self, request): |
|||
# Only show bottom section collections |
|||
return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.BOTTOM) |
|||
|
|||
def has_add_permission(self, request): |
|||
# Check if a bottom collection already exists |
|||
exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.BOTTOM).exists() |
|||
# Only allow adding if no bottom collection exists |
|||
return not exists |
|||
|
|||
def has_delete_permission(self, request, obj=None): |
|||
# Prevent deletion of the bottom collection |
|||
return False |
|||
|
|||
def save_model(self, request, obj, form, change): |
|||
# Ensure the display_position is always set to BOTTOM |
|||
obj.display_position = BookCollection.DisplayPosition.BOTTOM |
|||
super().save_model(request, obj, form, change) |
|||
|
|||
def changelist_view(self, request, extra_context=None): |
|||
# Check if a bottom collection exists |
|||
try: |
|||
# Try to get the first (and should be only) bottom collection |
|||
obj = self.get_queryset(request).first() |
|||
if obj: |
|||
# If it exists, redirect to the change view for this object |
|||
from django.http import HttpResponseRedirect |
|||
from django.urls import reverse |
|||
url = reverse( |
|||
'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), |
|||
args=[obj.pk] |
|||
) |
|||
return HttpResponseRedirect(url) |
|||
except Exception: |
|||
# If any error occurs, just show the changelist view as usual |
|||
pass |
|||
|
|||
# If no object exists or there was an error, show the default changelist view |
|||
return super().changelist_view(request, extra_context) |
|||
|
|||
|
|||
|
|||
@admin.register(Category) |
|||
class CategoryAdmin(AjaxDatatable): |
|||
list_display = ('title', 'slug', 'status', 'count_books', 'created_at') |
|||
list_filter = ('status', 'created_at', 'updated_at') |
|||
search_fields = ('title', 'slug') |
|||
autocomplete_fields = ('books',) |
|||
|
|||
@admin.display(description=_('Number of Books')) |
|||
def count_books(self, obj): |
|||
count = obj.books.count() |
|||
if count > 0: |
|||
url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}' |
|||
return format_html('<a href="{}">{}</a>', url, count) |
|||
return count |
|||
|
|||
@ -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' |
|||
@ -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',), |
|||
), |
|||
] |
|||
@ -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), |
|||
), |
|||
] |
|||
@ -0,0 +1,26 @@ |
|||
# Generated by Django 3.2.7 on 2025-03-21 01:19 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('library', '0002_alter_bookcollection_title'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='book', |
|||
name='author', |
|||
field=models.CharField(blank=True, max_length=255, null=True), |
|||
), |
|||
migrations.AddField( |
|||
model_name='book', |
|||
name='download_count', |
|||
field=models.PositiveBigIntegerField(default=0, verbose_name='view count'), |
|||
), |
|||
migrations.DeleteModel( |
|||
name='BookDownload', |
|||
), |
|||
] |
|||
@ -0,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') |
|||
|
|||
|
|||
@ -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') |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,22 @@ |
|||
from rest_framework.permissions import IsAuthenticated |
|||
from rest_framework.response import Response |
|||
from rest_framework.generics import ListAPIView |
|||
|
|||
from apps.library.models import * |
|||
from apps.library.serializers import * |
|||
|
|||
|
|||
|
|||
class BannerListView(ListAPIView): |
|||
serializer_class = BannerListSerializer |
|||
permission_classes = (IsAuthenticated,) |
|||
pagination_class = None |
|||
|
|||
def get_queryset(self): |
|||
_query = Q(status=True, display_position=BookCollection.DisplayPosition.TOP) |
|||
|
|||
return Collection.objects.filter( |
|||
_query, |
|||
).order_by('-order', '-id', ) |
|||
|
|||
|
|||
@ -0,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',) |
|||
@ -0,0 +1,8 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class PodcastConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.podcast' |
|||
|
|||
|
|||
@ -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') |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,3 @@ |
|||
from django.shortcuts import render |
|||
|
|||
# Create your views here. |
|||
@ -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',) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class VideoConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.video' |
|||
@ -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') |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,3 @@ |
|||
from django.shortcuts import render |
|||
|
|||
# Create your views here. |
|||
@ -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}') |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue