diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index 04904b8..03b0ac5 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -269,7 +269,7 @@ class CourseAdmin(DirectCourseAdmin): warn_unsaved_form = True search_fields = ('id','title', 'description') exclude = ('slug', ) - readonly_fields = ('final_price',) + readonly_fields = ('final_price', 'final_price_rub') autocomplete_fields = ('category', 'professor',) list_filter_submit = True change_form_show_cancel_button = True @@ -280,8 +280,10 @@ class CourseAdmin(DirectCourseAdmin): conditional_fields = { 'price': "is_free == false", + 'price_rub': "is_free == false", 'discount_percentage': "is_free == false", 'final_price': "is_free == false", + 'final_price_rub': "is_free == false", 'online_link': "is_online", 'video_file': "video_type == 'video_file'", 'video_link': "video_type == 'youtube_link'", @@ -308,8 +310,8 @@ class CourseAdmin(DirectCourseAdmin): }), (_('Pricing'), { 'fields': ( - ('is_free', 'price'), - ('discount_percentage', 'final_price') + ('is_free', 'price', 'price_rub'), + ('discount_percentage', 'final_price', 'final_price_rub') ), 'classes': ['tab'], }), @@ -347,12 +349,18 @@ class CourseAdmin(DirectCourseAdmin): if instance.discount_percentage > 0: return format_html( - '${}' - '${}', + 'USD: ${} -> ${}' + 'RUB: ₽{} -> ₽{}', instance.price, - instance.final_price + instance.final_price, + instance.price_rub, + instance.final_price_rub, ) - return format_html('${}', instance.final_price) + return format_html( + 'USD: ${}RUB: ₽{}', + instance.final_price, + instance.final_price_rub, + ) actions_row = ["add_student_to_course"] @@ -592,4 +600,4 @@ class HiddenCourseAdmin(ModelAdmin): def has_delete_permission(self, request, obj=None): return False -dovoodi_admin_site.register(Course, HiddenCourseAdmin) \ No newline at end of file +dovoodi_admin_site.register(Course, HiddenCourseAdmin) diff --git a/apps/course/migrations/0010_course_rub_prices.py b/apps/course/migrations/0010_course_rub_prices.py new file mode 100644 index 0000000..3d21519 --- /dev/null +++ b/apps/course/migrations/0010_course_rub_prices.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0009_alter_course_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='final_price_rub', + field=models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price (RUB)'), + ), + migrations.AddField( + model_name='course', + name='price_rub', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Course Price (RUB)'), + ), + ] diff --git a/apps/course/models/course.py b/apps/course/models/course.py index c90e15b..9976dc9 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -151,6 +151,9 @@ STATUS_TRANSLATIONS = { } } +CURRENCY_USD = 'USD' +CURRENCY_RUB = 'RUB' + def normalize_level(value): if not value: return 'beginner' @@ -292,11 +295,16 @@ class Course(models.Model): status = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Status')) is_free = models.BooleanField(default=True, verbose_name=_('Is Free')) price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price')) + price_rub = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price (RUB)')) discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage')) final_price = models.DecimalField( verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True, help_text=_('This field is automatically calculated based on the discount percentage.') ) + final_price_rub = models.DecimalField( + verbose_name=_('Course Final Price (RUB)'), decimal_places=2, max_digits=10, default=0.00, blank=True, + help_text=_('This field is automatically calculated based on the discount percentage.') + ) is_group_chat_locked = models.BooleanField( default=False, @@ -339,6 +347,26 @@ class Course(models.Model): def recalculate_lessons_count(self): return self.__class__.recalculate_lessons_count_for_course(self.pk) + def get_currency_for_language(self, lang): + normalized = (lang or 'en').lower() + if normalized.startswith('ru'): + return CURRENCY_RUB + return CURRENCY_USD + + def get_price_for_language(self, lang): + if self.is_free: + return Decimal('0.00') + if self.get_currency_for_language(lang) == CURRENCY_RUB: + return Decimal(self.price_rub or 0) + return Decimal(self.price or 0) + + def get_final_price_for_language(self, lang): + if self.is_free: + return Decimal('0.00') + if self.get_currency_for_language(lang) == CURRENCY_RUB: + return Decimal(self.final_price_rub or 0) + return Decimal(self.final_price or 0) + def save(self, *args, **kwargs): self.title = format_multilingual_field(self.title) @@ -424,21 +452,31 @@ class Course(models.Model): ] self.features = format_multilingual_field(self.features) - # Ensure consistency: if price is 0, set is_free to True and discount_percentage to 0 - if self.price == 0: + usd_price = Decimal(self.price or 0) + rub_price = Decimal(self.price_rub or 0) + + # Ensure consistency: if both prices are 0, set is_free to True and discount_percentage to 0 + if usd_price == 0 and rub_price == 0: self.is_free = True self.discount_percentage = 0 self.final_price = Decimal('0.00') + self.final_price_rub = Decimal('0.00') elif self.is_free: self.price = Decimal('0.00') + self.price_rub = Decimal('0.00') self.discount_percentage = 0 self.final_price = Decimal('0.00') + self.final_price_rub = Decimal('0.00') elif self.discount_percentage > 0: - discount_amount = (self.price * self.discount_percentage) / 100 - final_price = self.price - discount_amount - self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00')) + usd_discount_amount = (usd_price * self.discount_percentage) / 100 + rub_discount_amount = (rub_price * self.discount_percentage) / 100 + usd_final_price = usd_price - usd_discount_amount + rub_final_price = rub_price - rub_discount_amount + self.final_price = Decimal(math.ceil(usd_final_price)).quantize(Decimal('0.00')) + self.final_price_rub = Decimal(math.ceil(rub_final_price)).quantize(Decimal('0.00')) else: - self.final_price = Decimal(math.ceil(self.price)).quantize(Decimal('0.00')) + self.final_price = Decimal(math.ceil(usd_price)).quantize(Decimal('0.00')) + self.final_price_rub = Decimal(math.ceil(rub_price)).quantize(Decimal('0.00')) super().save(*args, **kwargs) diff --git a/apps/course/serializers/admin.py b/apps/course/serializers/admin.py index 468956b..8597a92 100644 --- a/apps/course/serializers/admin.py +++ b/apps/course/serializers/admin.py @@ -20,7 +20,21 @@ from apps.course.models.course import extract_text_from_json, get_localized_fiel def get_request_lang(serializer_instance): request = serializer_instance.context.get('request') - return getattr(request, 'LANGUAGE_CODE', None) or 'en' + language_code = getattr(request, 'LANGUAGE_CODE', None) + if language_code: + return str(language_code).lower() + + header_value = '' + if request is not None: + if hasattr(request, 'headers'): + header_value = request.headers.get('Accept-Language', '') + if not header_value and hasattr(request, 'META'): + header_value = request.META.get('HTTP_ACCEPT_LANGUAGE', '') + + if header_value: + return header_value.split(',')[0].strip().lower() + + return 'en' class AdminLessonSerializer(serializers.ModelSerializer): class Meta: @@ -102,8 +116,10 @@ class AdminCourseListSerializer(serializers.ModelSerializer): 'status', 'is_free', 'price', + 'price_rub', 'discount_percentage', 'final_price', + 'final_price_rub', 'participant_count', 'created_at' ] @@ -162,8 +178,10 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): 'status', 'is_free', 'price', + 'price_rub', 'discount_percentage', 'final_price', + 'final_price_rub', 'is_group_chat_locked', 'is_professor_chat_locked', 'timing', @@ -172,7 +190,7 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer): 'created_at', 'updated_at' ] - read_only_fields = ['id', 'final_price', 'created_at', 'updated_at'] + read_only_fields = ['id', 'final_price', 'final_price_rub', 'created_at', 'updated_at'] def get_participant_count(self, obj): return obj.participants.count() diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index eb848c4..954279a 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -5,10 +5,31 @@ from apps.course.models import Course, CourseCategory, Attachment, Glossary, Les from apps.course.models.course import get_localized_field from apps.chat.models import RoomMessage from apps.account.serializers import UserProfileSerializer +from decimal import Decimal import ast import json +def resolve_request_lang(request, default='en'): + language_code = getattr(request, 'LANGUAGE_CODE', None) + if language_code: + return str(language_code).lower() + + if not request: + return default + + header_value = '' + if hasattr(request, 'headers'): + header_value = request.headers.get('Accept-Language', '') + if not header_value and hasattr(request, 'META'): + header_value = request.META.get('HTTP_ACCEPT_LANGUAGE', '') + + if header_value: + return header_value.split(',')[0].strip().lower() + + return default + + class CourseCategorySerializer(serializers.ModelSerializer): course_count = serializers.SerializerMethodField() @@ -21,7 +42,7 @@ class CourseCategorySerializer(serializers.ModelSerializer): def _lang(self): request = self.context.get('request') - return getattr(request, 'LANGUAGE_CODE', None) or 'en' + return resolve_request_lang(request) def get_name(self, obj): return get_localized_field(self._lang(), obj.name) @@ -41,6 +62,7 @@ class CourseListSerializer(serializers.ModelSerializer): price = serializers.SerializerMethodField() discount_percentage = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() + currency = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField() title = serializers.SerializerMethodField() slug = serializers.SerializerMethodField() @@ -67,12 +89,13 @@ class CourseListSerializer(serializers.ModelSerializer): 'is_free', 'price', 'discount_percentage', - 'final_price', + 'final_price', + 'currency', ] def _lang(self): request = self.context.get('request') - return getattr(request, 'LANGUAGE_CODE', None) or 'en' + return resolve_request_lang(request) def get_title(self, obj): return get_localized_field(self._lang(), obj.title) @@ -110,22 +133,27 @@ class CourseListSerializer(serializers.ModelSerializer): return max(lessons_count, obj.lessons_count) def get_price(self, obj): - if obj.is_free or obj.price == 0: + amount = obj.get_price_for_language(self._lang()) + if obj.is_free or amount == 0: return "0.00" - return str(obj.price) + return str(amount) def get_discount_percentage(self, obj): - if obj.is_free or obj.price == 0: + if obj.is_free or obj.get_price_for_language(self._lang()) == 0: return 0 return obj.discount_percentage def get_final_price(self, obj): - if obj.is_free or obj.price == 0: + amount = obj.get_final_price_for_language(self._lang()) + if obj.is_free or amount == 0: return "0.00" - return str(obj.final_price) + return str(amount) + + def get_currency(self, obj): + return obj.get_currency_for_language(self._lang()) def get_is_free(self, obj): - return obj.is_free or obj.price == 0 + return obj.is_free or (Decimal(obj.price or 0) == 0 and Decimal(obj.price_rub or 0) == 0) class CourseDetailSerializer(serializers.ModelSerializer): @@ -142,6 +170,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): price = serializers.SerializerMethodField() discount_percentage = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() + currency = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField() is_professor = serializers.SerializerMethodField() title = serializers.SerializerMethodField() @@ -181,6 +210,7 @@ class CourseDetailSerializer(serializers.ModelSerializer): 'price', 'discount_percentage', 'final_price', + 'currency', 'timing', 'features', 'last_lesson_id', @@ -389,25 +419,29 @@ class CourseDetailSerializer(serializers.ModelSerializer): return obj.participants.count() def get_price(self, obj): - if obj.is_free or obj.price == 0: + amount = obj.get_price_for_language(self._lang()) + if obj.is_free or amount == 0: return "0.00" - return str(obj.price) + return str(amount) def get_discount_percentage(self, obj): - if obj.is_free or obj.price == 0: + if obj.is_free or obj.get_price_for_language(self._lang()) == 0: return 0 return obj.discount_percentage def get_final_price(self, obj): - if obj.is_free or obj.price == 0: + amount = obj.get_final_price_for_language(self._lang()) + if obj.is_free or amount == 0: return "0.00" - return str(obj.final_price) + return str(amount) + def get_currency(self, obj): + return obj.get_currency_for_language(self._lang()) def get_is_free(self, obj): - return obj.is_free or obj.price == 0 + return obj.is_free or (Decimal(obj.price or 0) == 0 and Decimal(obj.price_rub or 0) == 0) def _lang(self): request = self.context.get('request') - return getattr(request, 'LANGUAGE_CODE', None) or 'en' + return resolve_request_lang(request) def get_title(self, obj): return get_localized_field(self._lang(), obj.title) diff --git a/apps/course/serializers/professor.py b/apps/course/serializers/professor.py index a82b212..b0bea9b 100644 --- a/apps/course/serializers/professor.py +++ b/apps/course/serializers/professor.py @@ -15,7 +15,7 @@ class ProfessorListSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'slug', 'fullname', 'avatar','experience_years', 'course_count', 'lesson_count'] + fields = ['id', 'slug', 'fullname', 'email', 'avatar', 'experience_years', 'course_count', 'lesson_count'] class ProfessorDetailSerializer(UserProfileSerializer): diff --git a/apps/course/tests/test_multiple_roles_api.py b/apps/course/tests/test_multiple_roles_api.py index 12be84d..9eacac5 100644 --- a/apps/course/tests/test_multiple_roles_api.py +++ b/apps/course/tests/test_multiple_roles_api.py @@ -6,6 +6,7 @@ from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status from django.contrib.auth.models import Group +from decimal import Decimal from apps.account.models import User from apps.course.models import Course, CourseCategory, Participant from apps.transaction.models import TransactionParticipant @@ -214,3 +215,40 @@ class MultipleRolesAPITestCase(TestCase): # user_type field باید بروزرسانی شود self.assertEqual(self.user.user_type, User.UserType.PROFESSOR) + + def test_course_detail_serializer_uses_rub_price_for_russian_language(self): + """برای زبان روسی باید قیمت روبلی برگردانده شود، نه دلار""" + self.user.add_role('professor') + + course = Course.objects.create( + title=[ + {"title": "English Title", "language_code": "en"}, + {"title": "Русский заголовок", "language_code": "ru"}, + ], + slug='localized-course', + category=self.category, + professor=self.user, + level='beginner', + duration=10, + lessons_count=5, + description='Test description', + is_free=False, + price=Decimal('100.00'), + price_rub=Decimal('7500.00'), + discount_percentage=10, + ) + + from apps.course.serializers import CourseDetailSerializer + from django.test import RequestFactory + + factory = RequestFactory() + request = factory.get('/', HTTP_ACCEPT_LANGUAGE='ru') + request.user = self.user + request.LANGUAGE_CODE = 'ru' + + serializer = CourseDetailSerializer(course, context={'request': request}) + data = serializer.data + + self.assertEqual(data['currency'], 'RUB') + self.assertEqual(data['price'], '7500.00') + self.assertEqual(data['final_price'], '6750.00') diff --git a/apps/course/views/admin.py b/apps/course/views/admin.py index f1125e6..7d2a174 100644 --- a/apps/course/views/admin.py +++ b/apps/course/views/admin.py @@ -64,11 +64,20 @@ class AdminCourseViewSet(viewsets.ModelViewSet): # Search Query search_query = self.request.query_params.get('search', None) + has_search = False if search_query: + has_search = True + from django.db.models import Case, When, Value, IntegerField queryset = queryset.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(short_description__icontains=search_query) + ).annotate( + search_relevance=Case( + When(title__icontains=search_query, then=Value(2)), + default=Value(1), + output_field=IntegerField() + ) ) # Filters @@ -95,6 +104,8 @@ class AdminCourseViewSet(viewsets.ModelViewSet): if student_id: queryset = queryset.filter(participants__student_id=student_id) + if has_search: + return queryset.order_by('-search_relevance', '-id') return queryset.order_by('-id') def perform_create(self, serializer):