26 changed files with 1588 additions and 8 deletions
-
31apps/api/migrations/0001_initial.py
-
42apps/api/migrations/0002_auto_20250911_1217.py
-
0apps/api/migrations/__init__.py
-
137apps/api/models.py
-
54apps/api/serializers.py
-
5apps/api/urls.py
-
2apps/api/views.py
-
5apps/api/views/__init__.py
-
73apps/api/views/api_views.py
-
120apps/api/views/documentation.py
-
0apps/blog/__init__.py
-
110apps/blog/admin.py
-
7apps/blog/apps.py
-
53apps/blog/migrations/0001_initial.py
-
31apps/blog/migrations/0002_blogseo.py
-
0apps/blog/migrations/__init__.py
-
200apps/blog/models.py
-
142apps/blog/serializers.py
-
3apps/blog/tests.py
-
20apps/blog/urls.py
-
177apps/blog/views.py
-
3config/settings/base.py
-
1config/urls.py
-
181templates/utils/widgets/multilang_json_widget.html
-
24utils/__init__.py
-
175utils/multilang_json_widget.py
@ -0,0 +1,31 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-09 16:27 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='Comment', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('user_avatar', models.ImageField(blank=True, null=True, upload_to='comments/avatars/%Y/%m/', verbose_name='User Avatar')), |
||||
|
('user_fullname', models.CharField(help_text='Full name of the user who made the comment', max_length=255, verbose_name='User Full Name')), |
||||
|
('user_slogan', models.CharField(blank=True, help_text='User slogan or bio', max_length=500, null=True, verbose_name='User Slogan')), |
||||
|
('comment_text', models.TextField(help_text='The actual comment content', verbose_name='Comment Text')), |
||||
|
('order', models.PositiveIntegerField(default=0, help_text='Order for sorting comments', verbose_name='Order')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Comment', |
||||
|
'verbose_name_plural': 'Comments', |
||||
|
'ordering': ['order', '-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,42 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-11 12:17 |
||||
|
|
||||
|
import dj_language.field |
||||
|
import django.core.validators |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('dj_language', '0002_auto_20220120_1344'), |
||||
|
('api', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='AppVersion', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('version', models.CharField(help_text='Application version in format X.Y.Z (e.g., 1.0.0)', max_length=20, unique=True, validators=[django.core.validators.RegexValidator(message='Version must be in format X.Y.Z (e.g., 1.0.0)', regex='^\\d+\\.\\d+\\.\\d+$')], verbose_name='Version')), |
||||
|
('apk_file', models.FileField(help_text='Application APK file', upload_to='app_versions/', verbose_name='APK File')), |
||||
|
('description', models.TextField(blank=True, help_text='Release notes and changes for this version', verbose_name='Description')), |
||||
|
('app_type', models.CharField(choices=[('google_play', 'Google Play'), ('app_store', 'App Store')], default='google_play', help_text='App distribution platform', max_length=20, verbose_name='App Type')), |
||||
|
('app_store_downloads', models.PositiveBigIntegerField(default=0, help_text='Total number of downloads on Apple App Store', verbose_name='App Store Downloads')), |
||||
|
('google_play_downloads', models.PositiveBigIntegerField(default=0, help_text='Total number of downloads on Google Play', verbose_name='Google Play Downloads')), |
||||
|
('is_active', models.BooleanField(default=True, help_text='Is this version active?', verbose_name='Active')), |
||||
|
('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': 'App Version', |
||||
|
'verbose_name_plural': 'App Versions', |
||||
|
'ordering': ['-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='comment', |
||||
|
name='language', |
||||
|
field=dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='language'), |
||||
|
), |
||||
|
] |
||||
@ -1,3 +1,138 @@ |
|||||
from django.db import models |
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from dj_language.field import LanguageField |
||||
|
from django.core.validators import RegexValidator |
||||
|
|
||||
# Create your models here. |
|
||||
|
|
||||
|
class Comment(models.Model): |
||||
|
""" |
||||
|
Comment model that stores user information directly |
||||
|
""" |
||||
|
# User information |
||||
|
user_avatar = models.ImageField( |
||||
|
upload_to='comments/avatars/%Y/%m/', |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('User Avatar') |
||||
|
) |
||||
|
user_fullname = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_('User Full Name'), |
||||
|
help_text=_('Full name of the user who made the comment') |
||||
|
) |
||||
|
user_slogan = models.CharField( |
||||
|
max_length=500, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('User Slogan'), |
||||
|
help_text=_('User slogan or bio') |
||||
|
) |
||||
|
|
||||
|
# Comment content |
||||
|
comment_text = models.TextField( |
||||
|
verbose_name=_('Comment Text'), |
||||
|
help_text=_('The actual comment content') |
||||
|
) |
||||
|
language = LanguageField(null=True) |
||||
|
# Ordering and timestamps |
||||
|
order = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Order'), |
||||
|
help_text=_('Order for sorting comments') |
||||
|
) |
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ['order', '-created_at'] |
||||
|
verbose_name = _('Comment') |
||||
|
verbose_name_plural = _('Comments') |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user_fullname}: {self.comment_text[:50]}..." |
||||
|
|
||||
|
|
||||
|
class AppVersion(models.Model): |
||||
|
""" |
||||
|
Model for storing app versions with APK files and descriptions |
||||
|
""" |
||||
|
class AppType(models.TextChoices): |
||||
|
GOOGLE_PLAY = 'google_play', _('Google Play') |
||||
|
APP_STORE = 'app_store', _('App Store') |
||||
|
version = models.CharField( |
||||
|
max_length=20, |
||||
|
unique=True, |
||||
|
validators=[ |
||||
|
RegexValidator( |
||||
|
regex=r'^\d+\.\d+\.\d+$', |
||||
|
message='Version must be in format X.Y.Z (e.g., 1.0.0)' |
||||
|
) |
||||
|
], |
||||
|
verbose_name=_('Version'), |
||||
|
help_text=_('Application version in format X.Y.Z (e.g., 1.0.0)') |
||||
|
) |
||||
|
|
||||
|
apk_file = models.FileField( |
||||
|
upload_to='app_versions/', |
||||
|
verbose_name=_('APK File'), |
||||
|
help_text=_('Application APK file') |
||||
|
) |
||||
|
|
||||
|
description = models.TextField( |
||||
|
verbose_name=_('Description'), |
||||
|
help_text=_('Release notes and changes for this version'), |
||||
|
blank=True |
||||
|
) |
||||
|
|
||||
|
app_type = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=AppType.choices, |
||||
|
default=AppType.GOOGLE_PLAY, |
||||
|
verbose_name=_('App Type'), |
||||
|
help_text=_('App distribution platform') |
||||
|
) |
||||
|
|
||||
|
app_store_downloads = models.PositiveBigIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('App Store Downloads'), |
||||
|
help_text=_('Total number of downloads on Apple App Store') |
||||
|
) |
||||
|
|
||||
|
google_play_downloads = models.PositiveBigIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Google Play Downloads'), |
||||
|
help_text=_('Total number of downloads on Google Play') |
||||
|
) |
||||
|
|
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_('Active'), |
||||
|
help_text=_('Is this version active?') |
||||
|
) |
||||
|
|
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
|
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_('Updated At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('App Version') |
||||
|
verbose_name_plural = _('App Versions') |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f'Version {self.version}' |
||||
|
|
||||
|
@classmethod |
||||
|
def get_latest_active(cls): |
||||
|
""" |
||||
|
Get the latest active version |
||||
|
""" |
||||
|
return cls.objects.filter(is_active=True).order_by('-created_at').first() |
||||
@ -0,0 +1,54 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from utils import FileFieldSerializer |
||||
|
from .models import Comment, AppVersion |
||||
|
|
||||
|
|
||||
|
class CommentSerializer(serializers.ModelSerializer): |
||||
|
""" |
||||
|
Serializer for Comment model with proper file field serialization for avatar |
||||
|
""" |
||||
|
user_avatar = FileFieldSerializer(required=False, allow_null=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = Comment |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'user_avatar', |
||||
|
'user_fullname', |
||||
|
'user_slogan', |
||||
|
'comment_text', |
||||
|
'order', |
||||
|
'created_at' |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at'] |
||||
|
|
||||
|
def validate_user_fullname(self, value): |
||||
|
if not value or not value.strip(): |
||||
|
raise serializers.ValidationError("User full name is required.") |
||||
|
return value |
||||
|
|
||||
|
def validate_comment_text(self, value): |
||||
|
if not value or not value.strip(): |
||||
|
raise serializers.ValidationError("Comment text is required.") |
||||
|
return value |
||||
|
|
||||
|
|
||||
|
|
||||
|
class AppVersionSerializer(serializers.ModelSerializer): |
||||
|
apk_file = FileFieldSerializer() |
||||
|
|
||||
|
class Meta: |
||||
|
model = AppVersion |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'version', |
||||
|
'apk_file', |
||||
|
'description', |
||||
|
'app_type', |
||||
|
'app_store_downloads', |
||||
|
'google_play_downloads', |
||||
|
'is_active', |
||||
|
'created_at', |
||||
|
'updated_at', |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at', 'updated_at'] |
||||
@ -1,10 +1,13 @@ |
|||||
|
|
||||
from django.urls import path |
from django.urls import path |
||||
from .views import HomeView, CountryView |
|
||||
|
from .views import HomeView, CountryView, CommentListAPIView |
||||
|
from .views.api_views import AppVersionListAPIView |
||||
|
|
||||
|
|
||||
|
|
||||
urlpatterns = [ |
urlpatterns = [ |
||||
path('', HomeView.as_view()), |
path('', HomeView.as_view()), |
||||
path('countries/', CountryView.as_view()), |
path('countries/', CountryView.as_view()), |
||||
|
path('comments/', CommentListAPIView.as_view(), name='comment-list'), |
||||
|
path('app-versions/', AppVersionListAPIView.as_view(), name='appversion-list'), |
||||
] |
] |
||||
@ -1,2 +1,2 @@ |
|||||
# Legacy views - moved to views/api_views.py for better organization |
# Legacy views - moved to views/api_views.py for better organization |
||||
from .views.api_views import HomeView, CountryView |
|
||||
|
from .views.api_views import HomeView, CountryView, CommentListAPIView |
||||
@ -0,0 +1,110 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin, TabularInline, StackedInline |
||||
|
from unfold.contrib.forms.widgets import WysiwygWidget |
||||
|
from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget |
||||
|
from utils.multilang_json_widget import MultiLanguageJSONWidget |
||||
|
from django import forms |
||||
|
from .models import Blog, BlogContent |
||||
|
from utils.admin import project_admin_site |
||||
|
|
||||
|
class BlogContentForm(forms.ModelForm): |
||||
|
""" |
||||
|
Custom form for BlogContent to use WysiwygWidget for content field |
||||
|
""" |
||||
|
class Meta: |
||||
|
model = BlogContent |
||||
|
fields = '__all__' |
||||
|
widgets = { |
||||
|
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget), |
||||
|
'content': MultiLanguageJSONWidget(input_widget_class=WysiwygWidget), |
||||
|
'slug': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget), |
||||
|
} |
||||
|
class BlogAdminForm(forms.ModelForm): |
||||
|
class Meta: |
||||
|
model = Blog |
||||
|
fields = '__all__' |
||||
|
widgets = { |
||||
|
# You can switch between UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget,UnfoldAdminTextareaWidget or WysiwygWidget |
||||
|
'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget), |
||||
|
'slogan': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextareaWidget), |
||||
|
'summary': MultiLanguageJSONWidget(input_widget_class=WysiwygWidget), |
||||
|
'slug': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget), |
||||
|
} |
||||
|
|
||||
|
|
||||
|
class BlogContentInline(StackedInline): |
||||
|
""" |
||||
|
Inline admin for BlogContent in Blog admin |
||||
|
""" |
||||
|
model = BlogContent |
||||
|
form = BlogContentForm |
||||
|
extra = 1 |
||||
|
fields = ('title', 'content', 'slug', 'image', 'order') |
||||
|
ordering = ['order'] |
||||
|
|
||||
|
|
||||
|
@admin.register(Blog, site=project_admin_site) |
||||
|
class BlogAdmin(ModelAdmin): |
||||
|
""" |
||||
|
Admin interface for Blog model using Django unfold |
||||
|
""" |
||||
|
form = BlogAdminForm |
||||
|
list_display = ('title', 'slogan', 'views_count', 'created_at', 'updated_at') |
||||
|
list_filter = ('created_at', 'updated_at') |
||||
|
search_fields = ('title', 'slogan', 'summary') |
||||
|
prepopulated_fields = {'slug': ('title',)} |
||||
|
readonly_fields = ('views_count', 'created_at', 'updated_at') |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(_('Basic Information'), { |
||||
|
'fields': ('title', 'slug', 'thumbnail', 'slogan') |
||||
|
}), |
||||
|
(_('Content'), { |
||||
|
'fields': ('summary',) |
||||
|
}), |
||||
|
(_('Statistics'), { |
||||
|
'fields': ('views_count',), |
||||
|
'classes': ('collapse',) |
||||
|
}), |
||||
|
(_('Timestamps'), { |
||||
|
'fields': ('created_at', 'updated_at'), |
||||
|
'classes': ('collapse',) |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
inlines = [BlogContentInline] |
||||
|
|
||||
|
def get_queryset(self, request): |
||||
|
queryset = super().get_queryset(request) |
||||
|
print(f'--get_queryset-->{queryset}') |
||||
|
for blog in queryset: |
||||
|
print(f'-get_queryset-blog-->{blog.title}') |
||||
|
return queryset.prefetch_related('contents') |
||||
|
|
||||
|
|
||||
|
@admin.register(BlogContent, site=project_admin_site) |
||||
|
class BlogContentAdmin(ModelAdmin): |
||||
|
""" |
||||
|
Admin interface for BlogContent model using Django unfold |
||||
|
""" |
||||
|
form = BlogContentForm |
||||
|
list_display = ('title', 'blog', 'order', 'created_at', 'updated_at') |
||||
|
list_filter = ('blog', 'created_at', 'updated_at') |
||||
|
search_fields = ('title', 'content', 'blog__title') |
||||
|
list_select_related = ('blog',) |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(_('Basic Information'), { |
||||
|
'fields': ('blog', 'title', 'slug', 'order') |
||||
|
}), |
||||
|
(_('Content'), { |
||||
|
'fields': ('content', 'image') |
||||
|
}), |
||||
|
(_('Timestamps'), { |
||||
|
'fields': ('created_at', 'updated_at'), |
||||
|
'classes': ('collapse',) |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
readonly_fields = ('created_at', 'updated_at') |
||||
@ -0,0 +1,7 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class BlogConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.blog' |
||||
|
verbose_name = 'Blog' |
||||
@ -0,0 +1,53 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-10 20:47 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='Blog', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.JSONField(blank=True, default=list, null=True, verbose_name='title')), |
||||
|
('thumbnail', models.ImageField(help_text='Blog thumbnail image', upload_to='blog/thumbnails/%Y/%m/', verbose_name='Thumbnail')), |
||||
|
('slogan', models.JSONField(blank=True, default=list, null=True, verbose_name='slogan')), |
||||
|
('summary', models.JSONField(blank=True, default=list, null=True, verbose_name='summary')), |
||||
|
('views_count', models.PositiveIntegerField(default=0, help_text='Number of times this blog was viewed', verbose_name='Views Count')), |
||||
|
('slug', models.JSONField(blank=True, default=list, help_text='URL slug for the blog', null=True, verbose_name='slug')), |
||||
|
('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': 'Blog', |
||||
|
'verbose_name_plural': 'Blogs', |
||||
|
'ordering': ['-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='BlogContent', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.JSONField(blank=True, default=list, help_text='Title of this content section', null=True, verbose_name='Content title')), |
||||
|
('content', models.JSONField(blank=True, default=list, help_text='The main content text', null=True, verbose_name='content')), |
||||
|
('slug', models.JSONField(blank=True, default=list, help_text='URL slug for this content (optional)', null=True, verbose_name='slug')), |
||||
|
('image', models.ImageField(blank=True, help_text='Optional image for this content section', null=True, upload_to='blog/content_images/%Y/%m/', verbose_name='Image')), |
||||
|
('order', models.PositiveIntegerField(default=0, help_text='Order of this content within the blog', verbose_name='Order')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contents', to='blog.blog', verbose_name='Blog')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Blog Content', |
||||
|
'verbose_name_plural': 'Blog Contents', |
||||
|
'ordering': ['order', 'created_at'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,31 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-09-11 12:17 |
||||
|
|
||||
|
import dj_language.field |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('dj_language', '0002_auto_20220120_1344'), |
||||
|
('blog', '0001_initial'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='BlogSeo', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(blank=True, help_text='maximum length of page title is 70 characters and minimum length is 30', max_length=140, null=True, verbose_name='seo title')), |
||||
|
('keywords', models.CharField(blank=True, help_text='keywords in the content that make it possible for people to find the site via search engines', max_length=700, null=True)), |
||||
|
('description', models.CharField(blank=True, help_text='describes and summarizes the contents of the page for the benefit of users and search engines', max_length=170, null=True)), |
||||
|
('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seos', to='blog.blog', verbose_name='blog')), |
||||
|
('language', dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='language')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Blog SEO', |
||||
|
'verbose_name_plural': 'Blog SEOs', |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,200 @@ |
|||||
|
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from utils import generate_slug_for_model, generate_language_slugs |
||||
|
from dj_language.models import Language |
||||
|
from unfold.contrib.forms.widgets import ArrayWidget |
||||
|
from dj_language.field import LanguageField |
||||
|
|
||||
|
class Blog(models.Model): |
||||
|
""" |
||||
|
Blog model with title, thumbnail, slogan, summary, views count and timestamps |
||||
|
""" |
||||
|
title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('title')) |
||||
|
thumbnail = models.ImageField( |
||||
|
upload_to='blog/thumbnails/%Y/%m/', |
||||
|
verbose_name=_('Thumbnail'), |
||||
|
help_text=_('Blog thumbnail image') |
||||
|
) |
||||
|
slogan = models.JSONField(default=list, null=True, blank=True, verbose_name=_('slogan')) |
||||
|
summary = models.JSONField(default=list, null=True, blank=True, verbose_name=_('summary')) |
||||
|
views_count = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Views Count'), |
||||
|
help_text=_('Number of times this blog was viewed') |
||||
|
) |
||||
|
|
||||
|
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('slug'), help_text=_('URL slug for the blog')) |
||||
|
|
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_('Updated At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ['-created_at'] |
||||
|
verbose_name = _('Blog') |
||||
|
verbose_name_plural = _('Blogs') |
||||
|
|
||||
|
def __str__(self): |
||||
|
text = self._extract_text_from_json(self.title) |
||||
|
if text: |
||||
|
return text |
||||
|
return f"Blog #{self.pk}" if self.pk else "Blog" |
||||
|
|
||||
|
@staticmethod |
||||
|
def _extract_text_from_json(value): |
||||
|
if not value: |
||||
|
return "" |
||||
|
|
||||
|
# cases: list of dicts, list of strings, dict mapping, plain string |
||||
|
if isinstance(value, list): |
||||
|
for item in value: |
||||
|
if isinstance(item, dict): |
||||
|
text = item.get('title') or item.get('value') or item.get('text') |
||||
|
if text: |
||||
|
return str(text) |
||||
|
else: |
||||
|
if item: |
||||
|
return str(item) |
||||
|
return "" |
||||
|
if isinstance(value, dict): |
||||
|
# Prefer common language codes if present |
||||
|
for lang in ("fa", "en", "ru"): |
||||
|
if lang in value and value[lang]: |
||||
|
v = value[lang] |
||||
|
if isinstance(v, dict): |
||||
|
return str(v.get('title') or v.get('value') or v.get('text') or "") |
||||
|
return str(v) |
||||
|
# Fallback to first non-empty value |
||||
|
for v in value.values(): |
||||
|
if isinstance(v, dict): |
||||
|
txt = v.get('title') or v.get('value') or v.get('text') |
||||
|
if txt: |
||||
|
return str(txt) |
||||
|
elif v: |
||||
|
return str(v) |
||||
|
return "" |
||||
|
if isinstance(value, (str, int, float)): |
||||
|
return str(value) |
||||
|
return "" |
||||
|
|
||||
|
def increment_view_count(self): |
||||
|
"""Increment the view count by 1""" |
||||
|
self.views_count += 1 |
||||
|
self.save(update_fields=['views_count']) |
||||
|
return self.views_count |
||||
|
|
||||
|
def get_seo_for_language(self, language_code): |
||||
|
try: |
||||
|
seo_field_object = self.seos.filter(language__code=language_code).first() |
||||
|
if seo_field_object: |
||||
|
return { |
||||
|
"title": seo_field_object.title, |
||||
|
"description": seo_field_object.description, |
||||
|
} |
||||
|
return None |
||||
|
except Exception: |
||||
|
return None |
||||
|
|
||||
|
def get_blog_filed(self, lang, blog_field): |
||||
|
try: |
||||
|
if isinstance(blog_field, list) and blog_field: |
||||
|
for tr in blog_field: |
||||
|
if isinstance(tr, dict) and tr.get('language_code') == lang: |
||||
|
return tr.get('title') or tr.get('text') or tr.get('value') |
||||
|
return None |
||||
|
except Exception as exp: |
||||
|
print(f'---> Error in get_blog_filed: {exp}') |
||||
|
return None |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
try: |
||||
|
self.slug = generate_language_slugs(self.title) |
||||
|
except Exception: |
||||
|
self.slug = [] |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
|
||||
|
class BlogContent(models.Model): |
||||
|
""" |
||||
|
BlogContent model related to Blog with title, content, slug, image, order and timestamps |
||||
|
""" |
||||
|
blog = models.ForeignKey( |
||||
|
Blog, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name='contents', |
||||
|
verbose_name=_('Blog') |
||||
|
) |
||||
|
title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Content title'), help_text=_('Title of this content section')) |
||||
|
content = models.JSONField(default=list, null=True, blank=True, verbose_name=_('content'), help_text=_('The main content text')) |
||||
|
slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('slug'), help_text=_('URL slug for this content (optional)')) |
||||
|
image = models.ImageField( |
||||
|
upload_to='blog/content_images/%Y/%m/', |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('Image'), |
||||
|
help_text=_('Optional image for this content section') |
||||
|
) |
||||
|
order = models.PositiveIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Order'), |
||||
|
help_text=_('Order of this content within the blog') |
||||
|
) |
||||
|
|
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_('Updated At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ['order', 'created_at'] |
||||
|
verbose_name = _('Blog Content') |
||||
|
verbose_name_plural = _('Blog Contents') |
||||
|
# unique_together = ['blog', 'order'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
title_text = Blog._extract_text_from_json(self.title) |
||||
|
if title_text: |
||||
|
return title_text |
||||
|
blog_text = Blog._extract_text_from_json(self.blog.title) if self.blog_id else "Blog" |
||||
|
return f"{blog_text} - Content #{self.pk or ''}".strip() |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
try: |
||||
|
self.slug = generate_language_slugs(self.slug) |
||||
|
except Exception: |
||||
|
pass |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
|
||||
|
class BlogSeo(models.Model): |
||||
|
blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='seos', verbose_name=_('blog')) |
||||
|
title = models.CharField( |
||||
|
_('seo title'), max_length=140, null=True, blank=True, |
||||
|
help_text=_('maximum length of page title is 70 characters and minimum length is 30'), |
||||
|
) |
||||
|
keywords = models.CharField( |
||||
|
max_length=700, null=True, blank=True, |
||||
|
help_text=_('keywords in the content that make it possible for people to find the site via search engines') |
||||
|
) |
||||
|
description = models.CharField( |
||||
|
max_length=170, null=True, blank=True, |
||||
|
help_text=_('describes and summarizes the contents of the page for the benefit of users and search engines'), |
||||
|
) |
||||
|
language = LanguageField(null=True) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Blog SEO') |
||||
|
verbose_name_plural = _('Blog SEOs') |
||||
|
|
||||
|
def __str__(self): |
||||
|
lang = getattr(self.language, 'code', None) if self.language else None |
||||
|
return f"SEO({lang or '-'}) - {self.title or ''}" |
||||
@ -0,0 +1,142 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from utils import FileFieldSerializer |
||||
|
from .models import Blog, BlogContent |
||||
|
|
||||
|
|
||||
|
class BlogContentSerializer(serializers.ModelSerializer): |
||||
|
""" |
||||
|
Serializer for BlogContent model with all details |
||||
|
""" |
||||
|
image = FileFieldSerializer(required=False, allow_null=True) |
||||
|
title = serializers.SerializerMethodField() |
||||
|
content = serializers.SerializerMethodField() |
||||
|
slug = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = BlogContent |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'title', |
||||
|
'content', |
||||
|
'slug', |
||||
|
'image', |
||||
|
'order', |
||||
|
'created_at', |
||||
|
'updated_at' |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at', 'updated_at'] |
||||
|
|
||||
|
def _lang(self): |
||||
|
request = self.context.get('request') |
||||
|
return getattr(request, 'LANGUAGE_CODE', None) or 'en' |
||||
|
|
||||
|
def get_title(self, obj: BlogContent): |
||||
|
return obj.blog.get_blog_filed(self._lang(), obj.title) |
||||
|
|
||||
|
def get_content(self, obj: BlogContent): |
||||
|
return obj.blog.get_blog_filed(self._lang(), obj.content) |
||||
|
|
||||
|
def get_slug(self, obj: BlogContent): |
||||
|
return obj.blog.get_blog_filed(self._lang(), obj.slug) |
||||
|
|
||||
|
|
||||
|
class BlogListSerializer(serializers.ModelSerializer): |
||||
|
""" |
||||
|
Serializer for Blog list view with file field for thumbnail |
||||
|
""" |
||||
|
thumbnail = FileFieldSerializer(required=False) |
||||
|
title = serializers.SerializerMethodField() |
||||
|
slogan = serializers.SerializerMethodField() |
||||
|
summary = serializers.SerializerMethodField() |
||||
|
slug = serializers.SerializerMethodField() |
||||
|
seo = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = Blog |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'title', |
||||
|
'thumbnail', |
||||
|
'slogan', |
||||
|
'summary', |
||||
|
'views_count', |
||||
|
'slug', |
||||
|
'seo', |
||||
|
'created_at', |
||||
|
'updated_at' |
||||
|
] |
||||
|
read_only_fields = ['id', 'views_count', 'created_at', 'updated_at'] |
||||
|
|
||||
|
def _lang(self): |
||||
|
request = self.context.get('request') |
||||
|
return getattr(request, 'LANGUAGE_CODE', None) or 'en' |
||||
|
|
||||
|
def get_title(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.title) |
||||
|
|
||||
|
def get_slogan(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.slogan) |
||||
|
|
||||
|
def get_summary(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.summary) |
||||
|
|
||||
|
def get_slug(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.slug) |
||||
|
|
||||
|
def get_seo(self, obj: Blog): |
||||
|
return obj.get_seo_for_language(self._lang()) |
||||
|
|
||||
|
|
||||
|
class BlogDetailSerializer(serializers.ModelSerializer): |
||||
|
""" |
||||
|
Serializer for Blog detail view with related BlogContent |
||||
|
""" |
||||
|
thumbnail = FileFieldSerializer(required=False) |
||||
|
contents = serializers.SerializerMethodField() |
||||
|
title = serializers.SerializerMethodField() |
||||
|
slogan = serializers.SerializerMethodField() |
||||
|
summary = serializers.SerializerMethodField() |
||||
|
slug = serializers.SerializerMethodField() |
||||
|
seo = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = Blog |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'title', |
||||
|
'thumbnail', |
||||
|
'slogan', |
||||
|
'summary', |
||||
|
'views_count', |
||||
|
'slug', |
||||
|
'seo', |
||||
|
'created_at', |
||||
|
'updated_at', |
||||
|
'contents' |
||||
|
] |
||||
|
def get_contents(self, obj: Blog): |
||||
|
# Pass down context (request) to nested serializer |
||||
|
ser = BlogContentSerializer(obj.contents.all().order_by('order'), many=True, context=self.context) |
||||
|
return ser.data |
||||
|
read_only_fields = ['id', 'views_count', 'created_at', 'updated_at'] |
||||
|
|
||||
|
def _lang(self): |
||||
|
request = self.context.get('request') |
||||
|
return getattr(request, 'LANGUAGE_CODE', None) or 'en' |
||||
|
|
||||
|
def get_title(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.title) |
||||
|
|
||||
|
def get_slogan(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.slogan) |
||||
|
|
||||
|
def get_summary(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.summary) |
||||
|
|
||||
|
def get_slug(self, obj: Blog): |
||||
|
return obj.get_blog_filed(self._lang(), obj.slug) |
||||
|
|
||||
|
def get_seo(self, obj: Blog): |
||||
|
return obj.get_seo_for_language(self._lang()) |
||||
|
|
||||
|
|
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -0,0 +1,20 @@ |
|||||
|
from django.urls import path, re_path |
||||
|
from .views import BlogListAPIView, RelatedBlogsAPIView, BlogDetailBySlugAPIView |
||||
|
|
||||
|
app_name = 'blog' |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
# Blog list with search and sort_by filters |
||||
|
path('list/', BlogListAPIView.as_view(), name='blog-list'), |
||||
|
|
||||
|
# Related blogs for a specific blog ID |
||||
|
path('related/<int:blog_id>/', RelatedBlogsAPIView.as_view(), name='related-blogs'), |
||||
|
|
||||
|
# Blog detail by slug (using regex to support different languages) |
||||
|
re_path(r'^detail/(?P<slug>[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$', |
||||
|
BlogDetailBySlugAPIView.as_view(), |
||||
|
name='blog-detail'), |
||||
|
] |
||||
|
|
||||
|
|
||||
|
|
||||
@ -0,0 +1,177 @@ |
|||||
|
from rest_framework.generics import ListAPIView, GenericAPIView |
||||
|
from rest_framework.permissions import AllowAny |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework import status |
||||
|
from django.db.models import Q |
||||
|
from django.shortcuts import get_object_or_404 |
||||
|
from .models import Blog |
||||
|
from .serializers import BlogListSerializer, BlogDetailSerializer |
||||
|
import random |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
|
||||
|
|
||||
|
class BlogListAPIView(ListAPIView): |
||||
|
""" |
||||
|
API view to list blogs with search and sort_by filters |
||||
|
""" |
||||
|
serializer_class = BlogListSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="List blogs with optional search and sort_by filters", |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
name='search', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description='Search in title, slogan, or summary', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
), |
||||
|
openapi.Parameter( |
||||
|
name='sort_by', |
||||
|
in_=openapi.IN_QUERY, |
||||
|
description="Sorting: 'latest' or 'most_viewed'", |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=False |
||||
|
), |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="List of blogs", |
||||
|
schema=BlogListSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
queryset = Blog.objects.all() |
||||
|
|
||||
|
# Search filter |
||||
|
search = self.request.query_params.get('search', None) |
||||
|
if search: |
||||
|
queryset = queryset.filter( |
||||
|
Q(title__icontains=search) | |
||||
|
Q(slogan__icontains=search) | |
||||
|
Q(summary__icontains=search) |
||||
|
) |
||||
|
|
||||
|
# Sort by filter |
||||
|
sort_by = self.request.query_params.get('sort_by', None) |
||||
|
if sort_by == 'latest': |
||||
|
queryset = queryset.order_by('-created_at') |
||||
|
elif sort_by == 'most_viewed': |
||||
|
queryset = queryset.order_by('-views_count') |
||||
|
else: |
||||
|
# Default ordering |
||||
|
queryset = queryset.order_by('-created_at') |
||||
|
|
||||
|
return queryset |
||||
|
|
||||
|
|
||||
|
class RelatedBlogsAPIView(GenericAPIView): |
||||
|
""" |
||||
|
API view to get 10 random related blogs for a given blog ID |
||||
|
""" |
||||
|
serializer_class = BlogListSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get up to 10 random related blogs for the given blog_id", |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
name='blog_id', |
||||
|
in_=openapi.IN_PATH, |
||||
|
description='Current blog ID to exclude', |
||||
|
type=openapi.TYPE_INTEGER, |
||||
|
required=True |
||||
|
) |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="Related blogs", |
||||
|
schema=BlogListSerializer(many=True) |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, blog_id): |
||||
|
""" |
||||
|
Get 10 random blogs excluding the current blog |
||||
|
""" |
||||
|
try: |
||||
|
# Get the current blog to exclude it from results |
||||
|
current_blog = get_object_or_404(Blog, id=blog_id) |
||||
|
|
||||
|
# Get all blogs except the current one |
||||
|
all_blogs = list(Blog.objects.exclude(id=blog_id)) |
||||
|
|
||||
|
# Get random 10 blogs (or less if there are fewer blogs) |
||||
|
random_count = min(10, len(all_blogs)) |
||||
|
if random_count > 0: |
||||
|
related_blogs = random.sample(all_blogs, random_count) |
||||
|
else: |
||||
|
related_blogs = [] |
||||
|
|
||||
|
serializer = self.get_serializer(related_blogs, many=True) |
||||
|
return Response(serializer.data, status=status.HTTP_200_OK) |
||||
|
|
||||
|
except Exception as e: |
||||
|
return Response( |
||||
|
{'error': 'Blog not found or error occurred'}, |
||||
|
status=status.HTTP_404_NOT_FOUND |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class BlogDetailBySlugAPIView(GenericAPIView): |
||||
|
""" |
||||
|
API view to get blog details by slug and increment view count |
||||
|
""" |
||||
|
serializer_class = BlogDetailSerializer |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get blog details by slug and increment view count", |
||||
|
manual_parameters=[ |
||||
|
openapi.Parameter( |
||||
|
name='slug', |
||||
|
in_=openapi.IN_PATH, |
||||
|
description='Blog slug', |
||||
|
type=openapi.TYPE_STRING, |
||||
|
required=True |
||||
|
) |
||||
|
], |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="Blog detail", |
||||
|
schema=BlogDetailSerializer() |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request, slug): |
||||
|
""" |
||||
|
Get blog details by slug and increment view count |
||||
|
""" |
||||
|
try: |
||||
|
# Slug is stored as list of objects in JSONField -> filter accordingly |
||||
|
blog = Blog.objects.filter(slug__contains=[{'title': slug}]).first() |
||||
|
if not blog: |
||||
|
return Response({'error': 'Blog not found'}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
# Increment view count |
||||
|
blog.increment_view_count() |
||||
|
|
||||
|
# Get related blog contents ordered by order field |
||||
|
blog_with_contents = Blog.objects.prefetch_related( |
||||
|
'contents' |
||||
|
).get(id=blog.id) |
||||
|
|
||||
|
serializer = self.get_serializer(blog_with_contents, context={'request': request}) |
||||
|
return Response(serializer.data, status=status.HTTP_200_OK) |
||||
|
|
||||
|
except Exception as e: |
||||
|
return Response( |
||||
|
{'error': 'Blog not found'}, |
||||
|
status=status.HTTP_404_NOT_FOUND |
||||
|
) |
||||
@ -0,0 +1,181 @@ |
|||||
|
{% load i18n %} |
||||
|
<div class="space-y-3 max-w-2xl" data-multilang-json data-field-name="{{ widget.field_name }}"> |
||||
|
<div class="relative"> |
||||
|
<div class="w-full max-w-2xl overflow-x-auto scrollbar-hover pr-2"> |
||||
|
<div class="inline-flex flex-nowrap items-center gap-1 whitespace-nowrap py-1 min-w-max" data-lang-bar> |
||||
|
{% for code in widget.languages %} |
||||
|
<button type="button" |
||||
|
class="lang-btn px-3 py-1.5 rounded-md border transition-all duration-150 text-xs font-medium |
||||
|
border-base-200 text-font-default-light |
||||
|
dark:border-base-700 dark:text-font-default-dark{% if widget.has_value_codes and code in widget.has_value_codes %} has-value{% endif %}" |
||||
|
data-lang-code="{{ code }}"> |
||||
|
{{ code|upper }} |
||||
|
</button> |
||||
|
{% endfor %} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="space-y-3" data-inputs> |
||||
|
{% for input in widget.inputs %} |
||||
|
<div class="hidden" data-input-wrapper data-lang-code="{{ input.code }}"> |
||||
|
<div class="flex items-center gap-2 mb-1"> |
||||
|
<span class="text-xs font-medium text-font-subtle-light dark:text-font-subtle-dark"> |
||||
|
{{ input.code|upper }} |
||||
|
</span> |
||||
|
</div> |
||||
|
{{ input.html|safe }} |
||||
|
</div> |
||||
|
{% endfor %} |
||||
|
<input type="hidden" name="{{ widget.field_name }}" value='{{ widget.serialized|escapejs }}'> |
||||
|
</div> |
||||
|
|
||||
|
<div class="text-xs text-font-subtle-light dark:text-font-subtle-dark"> |
||||
|
{% trans "Click a language code to add or edit its title." %} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.scrollbar-hover { |
||||
|
--scrollbar-track: rgb(var(--color-base-100)); |
||||
|
--scrollbar-thumb: rgb(var(--color-base-300)); |
||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); |
||||
|
scrollbar-width: none; |
||||
|
} |
||||
|
.scrollbar-hover:hover { scrollbar-width: thin; } |
||||
|
.dark .scrollbar-hover { --scrollbar-track: rgb(var(--color-base-800)); --scrollbar-thumb: rgb(var(--color-base-600)); } |
||||
|
.scrollbar-hover::-webkit-scrollbar { height: 0; } |
||||
|
.scrollbar-hover:hover::-webkit-scrollbar { height: 6px; } |
||||
|
.scrollbar-hover::-webkit-scrollbar-track { background: var(--scrollbar-track); border-radius: 3px; } |
||||
|
.scrollbar-hover::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } |
||||
|
.scrollbar-hover::-webkit-scrollbar-corner { background: var(--scrollbar-track); } |
||||
|
.scrollbar-hover:hover::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-base-400)); } |
||||
|
.dark .scrollbar-hover:hover::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-base-500)); } |
||||
|
|
||||
|
.lang-btn { background: transparent; } |
||||
|
.lang-btn:hover { background: rgb(var(--color-base-50)); border-color: rgb(var(--color-base-300)); } |
||||
|
.dark .lang-btn:hover { background: rgb(var(--color-base-800)); border-color: rgb(var(--color-base-600)); } |
||||
|
.lang-btn.is-active { border-color: rgb(var(--color-primary-500)); background: rgb(var(--color-primary-50)); color: rgb(var(--color-primary-700)); box-shadow: 0 0 0 1px rgb(var(--color-primary-200)); } |
||||
|
.dark .lang-btn.is-active { border-color: rgb(var(--color-primary-600)); background: rgb(var(--color-primary-900)); color: rgb(var(--color-primary-300)); box-shadow: 0 0 0 1px rgb(var(--color-primary-700)); } |
||||
|
.lang-btn.has-value { border-color: rgb(var(--color-primary-400)); color: rgb(var(--color-primary-600)); } |
||||
|
.dark .lang-btn.has-value { border-color: rgb(var(--color-primary-600)); color: rgb(var(--color-primary-300)); } |
||||
|
.lang-btn.is-active:hover { background: rgb(var(--color-primary-50)); border-color: rgb(var(--color-primary-500)); } |
||||
|
.dark .lang-btn.is-active:hover { background: rgb(var(--color-primary-900)); border-color: rgb(var(--color-primary-600)); } |
||||
|
</style> |
||||
|
|
||||
|
<script> |
||||
|
(function () { |
||||
|
function init(root) { |
||||
|
var fieldName = root.getAttribute("data-field-name"); |
||||
|
if (!fieldName) return; |
||||
|
|
||||
|
var buttons = root.querySelectorAll(".lang-btn[data-lang-code]"); |
||||
|
var inputsRoot = root.querySelector("[data-inputs]"); |
||||
|
if (!inputsRoot) return; |
||||
|
|
||||
|
var hasActiveLanguage = false; |
||||
|
var withValue = []; |
||||
|
var withoutValue = []; |
||||
|
buttons.forEach(function (btn) { |
||||
|
var code = btn.getAttribute("data-lang-code"); |
||||
|
var wrapper = inputsRoot.querySelector('[data-input-wrapper][data-lang-code="' + code + '"]'); |
||||
|
var hasValue = false; |
||||
|
if (wrapper) { |
||||
|
var input = wrapper.querySelector('input[name="' + fieldName + '__' + code + '"], textarea[name="' + fieldName + '__' + code + '"], input[id*="' + fieldName + '__' + code + '"]'); |
||||
|
hasValue = !!(input && input.value && input.value.trim() !== ""); |
||||
|
} |
||||
|
if (hasValue) { |
||||
|
btn.classList.add("has-value"); |
||||
|
withValue.push(btn); |
||||
|
if (!hasActiveLanguage && wrapper) { |
||||
|
btn.classList.add("is-active"); |
||||
|
wrapper.classList.remove("hidden"); |
||||
|
hasActiveLanguage = true; |
||||
|
} |
||||
|
} else { |
||||
|
withoutValue.push(btn); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!hasActiveLanguage && buttons.length) { |
||||
|
var firstBtn = (withValue[0] || buttons[0]); |
||||
|
var firstCode = firstBtn.getAttribute("data-lang-code"); |
||||
|
var firstWrapper = inputsRoot.querySelector('[data-input-wrapper][data-lang-code="' + firstCode + '"]'); |
||||
|
if (firstWrapper) { |
||||
|
firstBtn.classList.add("is-active"); |
||||
|
firstWrapper.classList.remove("hidden"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var bar = root.querySelector('[data-lang-bar]'); |
||||
|
if (bar) { |
||||
|
withValue.concat(withoutValue).forEach(function (btn) { |
||||
|
bar.appendChild(btn); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
buttons.forEach(function (btn) { |
||||
|
btn.addEventListener("click", function () { |
||||
|
var code = btn.getAttribute("data-lang-code"); |
||||
|
var wrapper = inputsRoot.querySelector('[data-input-wrapper][data-lang-code="' + code + '"]'); |
||||
|
if (!wrapper) return; |
||||
|
|
||||
|
var isActive = btn.classList.contains("is-active"); |
||||
|
buttons.forEach(function (b) { b.classList.remove("is-active"); }); |
||||
|
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) { w.classList.add("hidden"); }); |
||||
|
if (!isActive) { |
||||
|
btn.classList.add("is-active"); |
||||
|
wrapper.classList.remove("hidden"); |
||||
|
var input = wrapper.querySelector('input, textarea'); |
||||
|
if (input) { setTimeout(function(){ input.focus(); }, 50); } |
||||
|
var hidden = root.querySelector('input[type="hidden"][name="' + fieldName + '"]'); |
||||
|
if (hidden) { |
||||
|
var result = []; |
||||
|
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) { |
||||
|
var c = w.getAttribute('data-lang-code'); |
||||
|
var inp = w.querySelector('input, textarea'); |
||||
|
if (inp && inp.value && inp.value.trim() !== '') { result.push({ language_code: c, title: inp.value }); } |
||||
|
}); |
||||
|
try { hidden.value = JSON.stringify(result); } catch (e) {} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
var hidden = root.querySelector('input[type="hidden"][name="' + fieldName + '"]'); |
||||
|
if (hidden) { |
||||
|
inputsRoot.querySelectorAll('input, textarea').forEach(function (inp) { |
||||
|
inp.addEventListener('input', function () { |
||||
|
var result = []; |
||||
|
inputsRoot.querySelectorAll('[data-input-wrapper]').forEach(function (w) { |
||||
|
var c = w.getAttribute('data-lang-code'); |
||||
|
var i = w.querySelector('input, textarea'); |
||||
|
if (i && i.value && i.value.trim() !== '') { |
||||
|
result.push({ language_code: c, title: i.value }); |
||||
|
var btn = root.querySelector('.lang-btn[data-lang-code="' + c + '"]'); |
||||
|
if (btn) btn.classList.add('has-value'); |
||||
|
} else { |
||||
|
var btn2 = root.querySelector('.lang-btn[data-lang-code="' + c + '"]'); |
||||
|
if (btn2) btn2.classList.remove('has-value'); |
||||
|
} |
||||
|
}); |
||||
|
try { hidden.value = JSON.stringify(result); } catch (e) {} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
document.addEventListener("DOMContentLoaded", function () { |
||||
|
document.querySelectorAll('[data-multilang-json]').forEach(init); |
||||
|
}); |
||||
|
document.addEventListener("formset:added", function (event) { |
||||
|
var newFormset = event.detail.formsetRow; |
||||
|
if (newFormset) { |
||||
|
newFormset.querySelectorAll('[data-multilang-json]').forEach(init); |
||||
|
} |
||||
|
}); |
||||
|
})(); |
||||
|
</script> |
||||
|
|
||||
|
|
||||
|
|
||||
@ -0,0 +1,175 @@ |
|||||
|
from __future__ import annotations |
||||
|
|
||||
|
from typing import Any, Optional |
||||
|
import json |
||||
|
|
||||
|
from django.conf import settings |
||||
|
from django.forms.widgets import Media, Widget |
||||
|
|
||||
|
try: |
||||
|
from dj_language.models import Language # type: ignore |
||||
|
except Exception: # pragma: no cover - fallback when app is missing |
||||
|
Language = None # type: ignore |
||||
|
|
||||
|
from unfold.widgets import ( |
||||
|
UnfoldAdminTextInputWidget, |
||||
|
UnfoldAdminTextareaWidget, |
||||
|
) |
||||
|
from unfold.contrib.forms.widgets import WysiwygWidget |
||||
|
|
||||
|
|
||||
|
class MultiLanguageJSONWidget(Widget): |
||||
|
""" |
||||
|
Unfold-styled widget for JSONField storing list of objects with keys: |
||||
|
- language_code |
||||
|
- title |
||||
|
|
||||
|
Renders a horizontal, scrollable list of active language codes; clicking a code toggles |
||||
|
the corresponding input rendered using the provided input widget class |
||||
|
(UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget, or WysiwygWidget). |
||||
|
|
||||
|
The widget submits values via sub-inputs named as "<field_name>__<langcode>" |
||||
|
and converts them in value_from_datadict to the required JSON string (list[dict]). |
||||
|
""" |
||||
|
|
||||
|
template_name = "utils/widgets/multilang_json_widget.html" |
||||
|
|
||||
|
def __init__( |
||||
|
self, |
||||
|
input_widget_class: type[Widget] | None = None, |
||||
|
attrs: Optional[dict[str, Any]] = None, |
||||
|
) -> None: |
||||
|
super().__init__(attrs) |
||||
|
if input_widget_class is None: |
||||
|
input_widget_class = UnfoldAdminTextInputWidget |
||||
|
|
||||
|
self.input_widget_class: type[Widget] = input_widget_class |
||||
|
self.input_widget: Widget = input_widget_class() |
||||
|
|
||||
|
@property |
||||
|
def media(self) -> Media: # type: ignore[override] |
||||
|
# Only include child media (e.g. trix for Wysiwyg). JS is inlined in template. |
||||
|
try: |
||||
|
child_media = self.input_widget.media # type: ignore[attr-defined] |
||||
|
except Exception: |
||||
|
child_media = Media() |
||||
|
return child_media |
||||
|
|
||||
|
def _get_active_language_codes(self) -> list[str]: |
||||
|
codes: list[str] = [] |
||||
|
if Language is not None: |
||||
|
try: |
||||
|
codes = list( |
||||
|
Language.objects.filter(status=True).values_list("code", flat=True) # type: ignore[attr-defined] |
||||
|
) |
||||
|
except Exception: |
||||
|
try: |
||||
|
codes = list(Language.objects.values_list("code", flat=True)) |
||||
|
except Exception: |
||||
|
codes = [] |
||||
|
|
||||
|
if not codes: |
||||
|
codes = [code for code, _ in getattr(settings, "LANGUAGES", [("en", "English")])] |
||||
|
|
||||
|
return list(dict.fromkeys(codes)) |
||||
|
|
||||
|
def _normalize_value(self, value: Any) -> dict[str, Any]: |
||||
|
mapping: dict[str, Any] = {} |
||||
|
if not value: |
||||
|
return mapping |
||||
|
|
||||
|
if isinstance(value, str): |
||||
|
try: |
||||
|
value = json.loads(value) |
||||
|
except Exception: |
||||
|
return mapping |
||||
|
|
||||
|
if isinstance(value, list): |
||||
|
for item in value: |
||||
|
if not isinstance(item, dict): |
||||
|
continue |
||||
|
code = ( |
||||
|
item.get("language_code") |
||||
|
or item.get("code") |
||||
|
or item.get("lang") |
||||
|
or item.get("language") |
||||
|
) |
||||
|
text = item.get("title") or item.get("value") or item.get("text") |
||||
|
if code and text is not None: |
||||
|
mapping[str(code)] = text |
||||
|
elif isinstance(value, dict): |
||||
|
if "language_code" in value and "title" in value: |
||||
|
mapping[str(value["language_code"])] = value["title"] |
||||
|
else: |
||||
|
for code, text in value.items(): |
||||
|
mapping[str(code)] = text |
||||
|
|
||||
|
return mapping |
||||
|
|
||||
|
def get_context(self, name: str, value: Any, attrs: Optional[dict[str, Any]]): |
||||
|
context = super().get_context(name, value, attrs) |
||||
|
|
||||
|
languages = self._get_active_language_codes() |
||||
|
values_map = self._normalize_value(value) |
||||
|
|
||||
|
# Ensure languages include any language codes present in value |
||||
|
for code in values_map.keys(): |
||||
|
if code not in languages: |
||||
|
languages.append(code) |
||||
|
|
||||
|
# Reorder: languages with existing values first |
||||
|
codes_with_values = [code for code in languages if values_map.get(code) not in (None, "")] |
||||
|
codes_without_values = [code for code in languages if code not in codes_with_values] |
||||
|
languages = [*codes_with_values, *codes_without_values] |
||||
|
|
||||
|
# Build per-language rendered inputs using the child widget |
||||
|
rendered_inputs: list[dict[str, str]] = [] |
||||
|
for code in languages: |
||||
|
input_name = f"{name}__{code}" |
||||
|
rendered_html = self.input_widget.render(input_name, values_map.get(code, ""), attrs) |
||||
|
rendered_inputs.append({"code": code, "html": rendered_html}) |
||||
|
|
||||
|
# Prepare serialized hidden value (JSON string) |
||||
|
serialized_list: list[dict[str, Any]] = [] |
||||
|
for code in languages: |
||||
|
text_value = values_map.get(code) |
||||
|
if text_value not in (None, ""): |
||||
|
serialized_list.append({"language_code": code, "title": text_value}) |
||||
|
|
||||
|
context["widget"].update( |
||||
|
{ |
||||
|
"languages": languages, |
||||
|
"inputs": rendered_inputs, |
||||
|
"values_map": values_map, |
||||
|
"field_name": name, |
||||
|
"serialized": json.dumps(serialized_list, ensure_ascii=False), |
||||
|
"has_value_codes": codes_with_values, |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
return context |
||||
|
|
||||
|
def value_from_datadict(self, data, files, name): |
||||
|
hidden_value = data.get(name) |
||||
|
if hidden_value not in (None, ""): |
||||
|
return hidden_value |
||||
|
|
||||
|
prefix = f"{name}__" |
||||
|
results: list[dict[str, Any]] = [] |
||||
|
|
||||
|
for key in data.keys(): |
||||
|
if not key.startswith(prefix): |
||||
|
continue |
||||
|
code = key[len(prefix) :] |
||||
|
text = data.get(key) |
||||
|
if text not in (None, ""): |
||||
|
results.append({"language_code": code, "title": text}) |
||||
|
|
||||
|
return json.dumps(results, ensure_ascii=False) |
||||
|
|
||||
|
def value_omitted_from_data(self, data, files, name): |
||||
|
prefix = f"{name}__" |
||||
|
return not any(k.startswith(prefix) for k in data.keys()) |
||||
|
|
||||
|
|
||||
|
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue