Browse Source

feat: add blog

master
mortezaei 8 months ago
parent
commit
eb632a0073
  1. 31
      apps/api/migrations/0001_initial.py
  2. 42
      apps/api/migrations/0002_auto_20250911_1217.py
  3. 0
      apps/api/migrations/__init__.py
  4. 137
      apps/api/models.py
  5. 54
      apps/api/serializers.py
  6. 5
      apps/api/urls.py
  7. 2
      apps/api/views.py
  8. 5
      apps/api/views/__init__.py
  9. 73
      apps/api/views/api_views.py
  10. 120
      apps/api/views/documentation.py
  11. 0
      apps/blog/__init__.py
  12. 110
      apps/blog/admin.py
  13. 7
      apps/blog/apps.py
  14. 53
      apps/blog/migrations/0001_initial.py
  15. 31
      apps/blog/migrations/0002_blogseo.py
  16. 0
      apps/blog/migrations/__init__.py
  17. 200
      apps/blog/models.py
  18. 142
      apps/blog/serializers.py
  19. 3
      apps/blog/tests.py
  20. 20
      apps/blog/urls.py
  21. 177
      apps/blog/views.py
  22. 3
      config/settings/base.py
  23. 1
      config/urls.py
  24. 181
      templates/utils/widgets/multilang_json_widget.html
  25. 24
      utils/__init__.py
  26. 175
      utils/multilang_json_widget.py

31
apps/api/migrations/0001_initial.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'],
},
),
]

42
apps/api/migrations/0002_auto_20250911_1217.py

@ -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'),
),
]

0
apps/api/migrations/__init__.py

137
apps/api/models.py

@ -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()

54
apps/api/serializers.py

@ -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']

5
apps/api/urls.py

@ -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'),
] ]

2
apps/api/views.py

@ -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

5
apps/api/views/__init__.py

@ -1,13 +1,14 @@
# API Views Package # API Views Package
# This package contains all API-related views organized by functionality # This package contains all API-related views organized by functionality
from .api_views import HomeView, CountryView
from .api_views import HomeView, CountryView, CommentListAPIView
from .documentation import CustomAPIDocumentationView from .documentation import CustomAPIDocumentationView
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
__all__ = [ __all__ = [
'HomeView', 'HomeView',
'CountryView',
'CountryView',
'CommentListAPIView',
'CustomAPIDocumentationView', 'CustomAPIDocumentationView',
'CustomSwaggerView', 'CustomSwaggerView',
'SwaggerTokenAuthView', 'SwaggerTokenAuthView',

73
apps/api/views/api_views.py

@ -1,10 +1,15 @@
import random import random
from rest_framework.generics import GenericAPIView
from rest_framework.generics import GenericAPIView, ListAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import AllowAny
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from apps.account.models import User from apps.account.models import User
from apps.api.models import Comment, AppVersion
from apps.api.serializers import CommentSerializer, AppVersionSerializer
class HomeSerializer(serializers.Serializer): class HomeSerializer(serializers.Serializer):
token = serializers.CharField() token = serializers.CharField()
@ -16,6 +21,24 @@ from utils.countries import countries
class HomeView(GenericAPIView): class HomeView(GenericAPIView):
serializer_class = HomeSerializer serializer_class = HomeSerializer
@swagger_auto_schema(
operation_description="Health check and token test endpoint. Optionally reads BUILD_NUMBER from headers.",
manual_parameters=[
openapi.Parameter(
name='BUILD_NUMBER',
in_=openapi.IN_HEADER,
description='Client build number',
type=openapi.TYPE_STRING,
required=False
)
],
responses={
200: openapi.Response(
description="OK",
schema=HomeSerializer()
)
}
)
def get(self, request): def get(self, request):
# Get build_number from headers # Get build_number from headers
build_number = request.META.get('HTTP_BUILD_NUMBER') build_number = request.META.get('HTTP_BUILD_NUMBER')
@ -26,6 +49,52 @@ class HomeView(GenericAPIView):
return Response({'token': "ok", 'build_number': build_number}) return Response({'token': "ok", 'build_number': build_number})
class CountryView(GenericAPIView): class CountryView(GenericAPIView):
@swagger_auto_schema(
operation_description="List of countries with dialing codes and flags",
responses={200: openapi.Response(description="Countries list")}
)
def get(self, request): def get(self, request):
return Response(countries, status=200) return Response(countries, status=200)
class CommentListAPIView(ListAPIView):
"""
API view to list comments ordered by order field and creation date
"""
queryset = Comment.objects.all()
serializer_class = CommentSerializer
permission_classes = [AllowAny]
ordering = ['order', '-created_at'] # Order by order field first, then by newest
@swagger_auto_schema(
operation_description="List comments ordered by 'order' then '-created_at'",
responses={
200: openapi.Response(
description="List of comments",
schema=CommentSerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset()
return queryset.order_by('order', '-created_at')
class AppVersionListAPIView(ListAPIView):
queryset = AppVersion.objects.all().order_by('-created_at')
serializer_class = AppVersionSerializer
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="List all app versions with fields.",
responses={
200: openapi.Response(
description="List of app versions",
schema=AppVersionSerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)

120
apps/api/views/documentation.py

@ -458,5 +458,125 @@ class CustomAPIDocumentationView(View):
} }
} }
] ]
},
'api': {
'name': 'General API',
'description': 'General endpoints (health, countries, comments, app versions)',
'endpoints': [
{
'name': 'Health / Token Test',
'method': 'GET',
'url': '/api/test/',
'description': 'Health check; echoes optional BUILD_NUMBER header',
'parameters': [
{'name': 'BUILD_NUMBER', 'type': 'header', 'description': 'Client build number', 'required': False},
],
'response_examples': {
'success': json.dumps({
"token": "ok",
"build_number": "1.0.0(100)"
}, indent=2)
}
},
{
'name': 'Countries',
'method': 'GET',
'url': '/api/test/countries/',
'description': 'List of countries with dialing codes and flags',
'parameters': [],
'response_examples': {
'success': json.dumps([
{"name": "Iran", "dial_code": "+98", "code": "IR"}
], indent=2)
}
},
{
'name': 'Comments',
'method': 'GET',
'url': '/api/test/comments/',
'description': 'List comments ordered by order and created_at',
'parameters': [],
'response_examples': {
'success': json.dumps([
{"id": 1, "user_fullname": "Ali Reza", "comment_text": "Great app!"}
], indent=2)
}
},
{
'name': 'App Versions',
'method': 'GET',
'url': '/api/test/app-versions/',
'description': 'List all app versions',
'parameters': [],
'response_examples': {
'success': json.dumps([
{
"id": 3,
"version": "1.2.0",
"apk_file": "https://host/media/app_versions/app-release.apk",
"description": "Bug fixes",
"app_type": "google_play",
"app_store_downloads": 1500,
"google_play_downloads": 23000,
"is_active": true
}
], indent=2)
}
}
]
},
'blog': {
'name': 'Blog',
'description': 'Blog posts listing and details',
'endpoints': [
{
'name': 'Blog List',
'method': 'GET',
'url': '/api/blog/list/',
'description': 'List blogs with optional search and sort_by',
'parameters': [
{'name': 'search', 'type': 'string', 'description': 'Search in title, slogan, or summary', 'required': False},
{'name': 'sort_by', 'type': 'string', 'description': "Sorting: 'latest' or 'most_viewed'", 'required': False},
],
'response_examples': {
'success': json.dumps({
"count": 1,
"results": [
{"id": 1, "title": "First blog", "views_count": 10}
]
}, indent=2)
}
},
{
'name': 'Related Blogs',
'method': 'GET',
'url': '/api/blog/related/<int:blog_id>/',
'description': 'Get up to 10 random related blogs excluding current',
'parameters': [
{'name': 'blog_id', 'type': 'integer', 'description': 'Current blog ID', 'required': True},
],
'response_examples': {
'success': json.dumps([
{"id": 2, "title": "Another blog"}
], indent=2)
}
},
{
'name': 'Blog Detail by Slug',
'method': 'GET',
'url': '/api/blog/detail/<slug>/',
'description': 'Get blog details by slug; increments view count',
'parameters': [
{'name': 'slug', 'type': 'string', 'description': 'Blog slug', 'required': True},
],
'response_examples': {
'success': json.dumps({
"id": 1,
"title": "First blog",
"views_count": 11
}, indent=2)
}
}
]
} }
} }

0
apps/blog/__init__.py

110
apps/blog/admin.py

@ -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')

7
apps/blog/apps.py

@ -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'

53
apps/blog/migrations/0001_initial.py

@ -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'],
},
),
]

31
apps/blog/migrations/0002_blogseo.py

@ -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
apps/blog/migrations/__init__.py

200
apps/blog/models.py

@ -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 ''}"

142
apps/blog/serializers.py

@ -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())

3
apps/blog/tests.py

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

20
apps/blog/urls.py

@ -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'),
]

177
apps/blog/views.py

@ -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
)

3
config/settings/base.py

@ -54,7 +54,8 @@ LOCAL_APPS = [
'apps.podcast.apps.PodcastConfig', 'apps.podcast.apps.PodcastConfig',
'apps.bookmark.apps.BookmarkConfig', 'apps.bookmark.apps.BookmarkConfig',
'apps.article.apps.ArticleConfig', 'apps.article.apps.ArticleConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'apps.blog.apps.BlogConfig',
'dynamic_preferences', 'dynamic_preferences',
] ]

1
config/urls.py

@ -83,6 +83,7 @@ api_patterns = [
path('podcast/', include('apps.podcast.urls')), path('podcast/', include('apps.podcast.urls')),
path('bookmarks/', include('apps.bookmark.urls')), path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')), path('calendar/', include('apps.dobodbi_calendar.urls')),
path('blog/', include('apps.blog.urls')),
path('settings/', include('dynamic_preferences.urls')), path('settings/', include('dynamic_preferences.urls')),

181
templates/utils/widgets/multilang_json_widget.html

@ -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>

24
utils/__init__.py

@ -158,6 +158,30 @@ def generate_slug_for_model(model, value: str, recycled_count: int = 0):
return slug[:50] return slug[:50]
def generate_language_slugs(translations):
"""
Build a list of {language_code, title} where title is a slugified string
from provided multilingual translations list.
Expected input shape:
- list[dict]: [{'language_code': 'fa', 'title': 'متن'}, ...]
Fallback keys supported: code/lang/language for language, value/text for content.
"""
try:
result = []
if isinstance(translations, list):
for tr in translations:
if isinstance(tr, dict):
language_code = tr.get('language_code') or tr.get('code') or tr.get('lang') or tr.get('language')
text = tr.get('title') or tr.get('text') or tr.get('value')
if language_code and text:
slug_text = slugify(text, allow_unicode=True)
result.append({'language_code': str(language_code), 'title': slug_text})
return result
except Exception as e:
print(f"Error generating slugs: {e}")
return []
def absolute_url(req, url): def absolute_url(req, url):
""" """
can either be a file instance or a URL string can either be a file instance or a URL string

175
utils/multilang_json_widget.py

@ -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())
Loading…
Cancel
Save