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.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 .views import HomeView, CountryView |
|||
from .views import HomeView, CountryView, CommentListAPIView |
|||
from .views.api_views import AppVersionListAPIView |
|||
|
|||
|
|||
|
|||
urlpatterns = [ |
|||
path('', HomeView.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 |
|||
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