diff --git a/apps/api/migrations/0001_initial.py b/apps/api/migrations/0001_initial.py new file mode 100644 index 0000000..0b77e11 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/api/migrations/0002_auto_20250911_1217.py b/apps/api/migrations/0002_auto_20250911_1217.py new file mode 100644 index 0000000..0525771 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/api/migrations/__init__.py b/apps/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/models.py b/apps/api/models.py index 71a8362..af8dfea 100644 --- a/apps/api/models.py +++ b/apps/api/models.py @@ -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() diff --git a/apps/api/serializers.py b/apps/api/serializers.py new file mode 100644 index 0000000..cdb4707 --- /dev/null +++ b/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'] diff --git a/apps/api/urls.py b/apps/api/urls.py index d240486..d694f8e 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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'), ] diff --git a/apps/api/views.py b/apps/api/views.py index 7eaef73..caedbe6 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -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 diff --git a/apps/api/views/__init__.py b/apps/api/views/__init__.py index 7c44fc3..3503bcd 100644 --- a/apps/api/views/__init__.py +++ b/apps/api/views/__init__.py @@ -1,13 +1,14 @@ # API Views Package # 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 .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth __all__ = [ 'HomeView', - 'CountryView', + 'CountryView', + 'CommentListAPIView', 'CustomAPIDocumentationView', 'CustomSwaggerView', 'SwaggerTokenAuthView', diff --git a/apps/api/views/api_views.py b/apps/api/views/api_views.py index ec4690a..d072585 100644 --- a/apps/api/views/api_views.py +++ b/apps/api/views/api_views.py @@ -1,10 +1,15 @@ import random -from rest_framework.generics import GenericAPIView +from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response 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 apps.account.models import User +from apps.api.models import Comment, AppVersion +from apps.api.serializers import CommentSerializer, AppVersionSerializer class HomeSerializer(serializers.Serializer): token = serializers.CharField() @@ -16,6 +21,24 @@ from utils.countries import countries class HomeView(GenericAPIView): 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): # Get build_number from headers build_number = request.META.get('HTTP_BUILD_NUMBER') @@ -26,6 +49,52 @@ class HomeView(GenericAPIView): return Response({'token': "ok", 'build_number': build_number}) 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): 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) diff --git a/apps/api/views/documentation.py b/apps/api/views/documentation.py index fd5db0f..916650b 100644 --- a/apps/api/views/documentation.py +++ b/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//', + '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//', + '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) + } + } + ] } } diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/admin.py b/apps/blog/admin.py new file mode 100644 index 0000000..cd281d1 --- /dev/null +++ b/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') \ No newline at end of file diff --git a/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..743ab47 --- /dev/null +++ b/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' diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..a30b226 --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/apps/blog/migrations/0002_blogseo.py b/apps/blog/migrations/0002_blogseo.py new file mode 100644 index 0000000..9426a87 --- /dev/null +++ b/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', + }, + ), + ] diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models.py b/apps/blog/models.py new file mode 100644 index 0000000..9d5c59e --- /dev/null +++ b/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 ''}" \ No newline at end of file diff --git a/apps/blog/serializers.py b/apps/blog/serializers.py new file mode 100644 index 0000000..02ea4ba --- /dev/null +++ b/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()) + + diff --git a/apps/blog/tests.py b/apps/blog/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/blog/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/blog/urls.py b/apps/blog/urls.py new file mode 100644 index 0000000..3bf342c --- /dev/null +++ b/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//', RelatedBlogsAPIView.as_view(), name='related-blogs'), + + # Blog detail by slug (using regex to support different languages) + re_path(r'^detail/(?P[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$', + BlogDetailBySlugAPIView.as_view(), + name='blog-detail'), +] + + + diff --git a/apps/blog/views.py b/apps/blog/views.py new file mode 100644 index 0000000..4538d3a --- /dev/null +++ b/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 + ) \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index 9fc0f3b..7101dd1 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -54,7 +54,8 @@ LOCAL_APPS = [ 'apps.podcast.apps.PodcastConfig', 'apps.bookmark.apps.BookmarkConfig', 'apps.article.apps.ArticleConfig', - 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig', + 'apps.dobodbi_calendar.apps.DobodbiCalendarConfig', + 'apps.blog.apps.BlogConfig', 'dynamic_preferences', ] diff --git a/config/urls.py b/config/urls.py index e452dfe..6c7c519 100644 --- a/config/urls.py +++ b/config/urls.py @@ -83,6 +83,7 @@ api_patterns = [ path('podcast/', include('apps.podcast.urls')), path('bookmarks/', include('apps.bookmark.urls')), path('calendar/', include('apps.dobodbi_calendar.urls')), + path('blog/', include('apps.blog.urls')), path('settings/', include('dynamic_preferences.urls')), diff --git a/templates/utils/widgets/multilang_json_widget.html b/templates/utils/widgets/multilang_json_widget.html new file mode 100644 index 0000000..6248d21 --- /dev/null +++ b/templates/utils/widgets/multilang_json_widget.html @@ -0,0 +1,181 @@ +{% load i18n %} +
+
+
+
+ {% for code in widget.languages %} + + {% endfor %} +
+
+
+ +
+ {% for input in widget.inputs %} + + {% endfor %} + +
+ +
+ {% trans "Click a language code to add or edit its title." %} +
+
+ + + + + + + diff --git a/utils/__init__.py b/utils/__init__.py index acd3b4d..88fb9dc 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -158,6 +158,30 @@ def generate_slug_for_model(model, value: str, recycled_count: int = 0): 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): """ can either be a file instance or a URL string diff --git a/utils/multilang_json_widget.py b/utils/multilang_json_widget.py new file mode 100644 index 0000000..a16f3ea --- /dev/null +++ b/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 "__" + 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()) + + +