Browse Source

multi currency logic for course prices added

master
Mohsen Taba 1 month ago
parent
commit
deec75f670
  1. 22
      apps/course/admin/course.py
  2. 21
      apps/course/migrations/0010_course_rub_prices.py
  3. 50
      apps/course/models/course.py
  4. 22
      apps/course/serializers/admin.py
  5. 64
      apps/course/serializers/course.py
  6. 2
      apps/course/serializers/professor.py
  7. 38
      apps/course/tests/test_multiple_roles_api.py
  8. 11
      apps/course/views/admin.py

22
apps/course/admin/course.py

@ -269,7 +269,7 @@ class CourseAdmin(DirectCourseAdmin):
warn_unsaved_form = True warn_unsaved_form = True
search_fields = ('id','title', 'description') search_fields = ('id','title', 'description')
exclude = ('slug', ) exclude = ('slug', )
readonly_fields = ('final_price',)
readonly_fields = ('final_price', 'final_price_rub')
autocomplete_fields = ('category', 'professor',) autocomplete_fields = ('category', 'professor',)
list_filter_submit = True list_filter_submit = True
change_form_show_cancel_button = True change_form_show_cancel_button = True
@ -280,8 +280,10 @@ class CourseAdmin(DirectCourseAdmin):
conditional_fields = { conditional_fields = {
'price': "is_free == false", 'price': "is_free == false",
'price_rub': "is_free == false",
'discount_percentage': "is_free == false", 'discount_percentage': "is_free == false",
'final_price': "is_free == false", 'final_price': "is_free == false",
'final_price_rub': "is_free == false",
'online_link': "is_online", 'online_link': "is_online",
'video_file': "video_type == 'video_file'", 'video_file': "video_type == 'video_file'",
'video_link': "video_type == 'youtube_link'", 'video_link': "video_type == 'youtube_link'",
@ -308,8 +310,8 @@ class CourseAdmin(DirectCourseAdmin):
}), }),
(_('Pricing'), { (_('Pricing'), {
'fields': ( 'fields': (
('is_free', 'price'),
('discount_percentage', 'final_price')
('is_free', 'price', 'price_rub'),
('discount_percentage', 'final_price', 'final_price_rub')
), ),
'classes': ['tab'], 'classes': ['tab'],
}), }),
@ -347,12 +349,18 @@ class CourseAdmin(DirectCourseAdmin):
if instance.discount_percentage > 0: if instance.discount_percentage > 0:
return format_html( return format_html(
'<span class="line-through text-gray-400 mr-2">${}</span>'
'<span class="text-green-600 font-medium">${}</span>',
'<span class="block text-xs text-gray-400">USD: <span class="line-through">${}</span> -> <span class="text-green-600 font-medium">${}</span></span>'
'<span class="block text-xs text-gray-400 mt-1">RUB: <span class="line-through">₽{}</span> -> <span class="text-green-600 font-medium">₽{}</span></span>',
instance.price, instance.price,
instance.final_price
instance.final_price,
instance.price_rub,
instance.final_price_rub,
) )
return format_html('<span>${}</span>', instance.final_price)
return format_html(
'<span class="block">USD: ${}</span><span class="block mt-1">RUB: ₽{}</span>',
instance.final_price,
instance.final_price_rub,
)
actions_row = ["add_student_to_course"] actions_row = ["add_student_to_course"]

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

50
apps/course/models/course.py

@ -151,6 +151,9 @@ STATUS_TRANSLATIONS = {
} }
} }
CURRENCY_USD = 'USD'
CURRENCY_RUB = 'RUB'
def normalize_level(value): def normalize_level(value):
if not value: if not value:
return 'beginner' return 'beginner'
@ -292,11 +295,16 @@ class Course(models.Model):
status = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Status')) status = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Status'))
is_free = models.BooleanField(default=True, verbose_name=_('Is Free')) 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 = 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')) discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage'))
final_price = models.DecimalField( final_price = models.DecimalField(
verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True, 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.') 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( is_group_chat_locked = models.BooleanField(
default=False, default=False,
@ -339,6 +347,26 @@ class Course(models.Model):
def recalculate_lessons_count(self): def recalculate_lessons_count(self):
return self.__class__.recalculate_lessons_count_for_course(self.pk) 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): def save(self, *args, **kwargs):
self.title = format_multilingual_field(self.title) self.title = format_multilingual_field(self.title)
@ -424,21 +452,31 @@ class Course(models.Model):
] ]
self.features = format_multilingual_field(self.features) 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.is_free = True
self.discount_percentage = 0 self.discount_percentage = 0
self.final_price = Decimal('0.00') self.final_price = Decimal('0.00')
self.final_price_rub = Decimal('0.00')
elif self.is_free: elif self.is_free:
self.price = Decimal('0.00') self.price = Decimal('0.00')
self.price_rub = Decimal('0.00')
self.discount_percentage = 0 self.discount_percentage = 0
self.final_price = Decimal('0.00') self.final_price = Decimal('0.00')
self.final_price_rub = Decimal('0.00')
elif self.discount_percentage > 0: 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: 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) super().save(*args, **kwargs)

22
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): def get_request_lang(serializer_instance):
request = serializer_instance.context.get('request') 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 AdminLessonSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -102,8 +116,10 @@ class AdminCourseListSerializer(serializers.ModelSerializer):
'status', 'status',
'is_free', 'is_free',
'price', 'price',
'price_rub',
'discount_percentage', 'discount_percentage',
'final_price', 'final_price',
'final_price_rub',
'participant_count', 'participant_count',
'created_at' 'created_at'
] ]
@ -162,8 +178,10 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer):
'status', 'status',
'is_free', 'is_free',
'price', 'price',
'price_rub',
'discount_percentage', 'discount_percentage',
'final_price', 'final_price',
'final_price_rub',
'is_group_chat_locked', 'is_group_chat_locked',
'is_professor_chat_locked', 'is_professor_chat_locked',
'timing', 'timing',
@ -172,7 +190,7 @@ class AdminCourseDetailSerializer(serializers.ModelSerializer):
'created_at', 'created_at',
'updated_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): def get_participant_count(self, obj):
return obj.participants.count() return obj.participants.count()

64
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.course.models.course import get_localized_field
from apps.chat.models import RoomMessage from apps.chat.models import RoomMessage
from apps.account.serializers import UserProfileSerializer from apps.account.serializers import UserProfileSerializer
from decimal import Decimal
import ast import ast
import json 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): class CourseCategorySerializer(serializers.ModelSerializer):
course_count = serializers.SerializerMethodField() course_count = serializers.SerializerMethodField()
@ -21,7 +42,7 @@ class CourseCategorySerializer(serializers.ModelSerializer):
def _lang(self): def _lang(self):
request = self.context.get('request') request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
return resolve_request_lang(request)
def get_name(self, obj): def get_name(self, obj):
return get_localized_field(self._lang(), obj.name) return get_localized_field(self._lang(), obj.name)
@ -41,6 +62,7 @@ class CourseListSerializer(serializers.ModelSerializer):
price = serializers.SerializerMethodField() price = serializers.SerializerMethodField()
discount_percentage = serializers.SerializerMethodField() discount_percentage = serializers.SerializerMethodField()
final_price = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField()
currency = serializers.SerializerMethodField()
is_free = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField()
title = serializers.SerializerMethodField() title = serializers.SerializerMethodField()
slug = serializers.SerializerMethodField() slug = serializers.SerializerMethodField()
@ -68,11 +90,12 @@ class CourseListSerializer(serializers.ModelSerializer):
'price', 'price',
'discount_percentage', 'discount_percentage',
'final_price', 'final_price',
'currency',
] ]
def _lang(self): def _lang(self):
request = self.context.get('request') request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
return resolve_request_lang(request)
def get_title(self, obj): def get_title(self, obj):
return get_localized_field(self._lang(), obj.title) return get_localized_field(self._lang(), obj.title)
@ -110,22 +133,27 @@ class CourseListSerializer(serializers.ModelSerializer):
return max(lessons_count, obj.lessons_count) return max(lessons_count, obj.lessons_count)
def get_price(self, obj): 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 "0.00"
return str(obj.price)
return str(amount)
def get_discount_percentage(self, obj): 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 0
return obj.discount_percentage return obj.discount_percentage
def get_final_price(self, obj): 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 "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): 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): class CourseDetailSerializer(serializers.ModelSerializer):
@ -142,6 +170,7 @@ class CourseDetailSerializer(serializers.ModelSerializer):
price = serializers.SerializerMethodField() price = serializers.SerializerMethodField()
discount_percentage = serializers.SerializerMethodField() discount_percentage = serializers.SerializerMethodField()
final_price = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField()
currency = serializers.SerializerMethodField()
is_free = serializers.SerializerMethodField() is_free = serializers.SerializerMethodField()
is_professor = serializers.SerializerMethodField() is_professor = serializers.SerializerMethodField()
title = serializers.SerializerMethodField() title = serializers.SerializerMethodField()
@ -181,6 +210,7 @@ class CourseDetailSerializer(serializers.ModelSerializer):
'price', 'price',
'discount_percentage', 'discount_percentage',
'final_price', 'final_price',
'currency',
'timing', 'timing',
'features', 'features',
'last_lesson_id', 'last_lesson_id',
@ -389,25 +419,29 @@ class CourseDetailSerializer(serializers.ModelSerializer):
return obj.participants.count() return obj.participants.count()
def get_price(self, obj): 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 "0.00"
return str(obj.price)
return str(amount)
def get_discount_percentage(self, obj): 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 0
return obj.discount_percentage return obj.discount_percentage
def get_final_price(self, obj): 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 "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): 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): def _lang(self):
request = self.context.get('request') request = self.context.get('request')
return getattr(request, 'LANGUAGE_CODE', None) or 'en'
return resolve_request_lang(request)
def get_title(self, obj): def get_title(self, obj):
return get_localized_field(self._lang(), obj.title) return get_localized_field(self._lang(), obj.title)

2
apps/course/serializers/professor.py

@ -15,7 +15,7 @@ class ProfessorListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User 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): class ProfessorDetailSerializer(UserProfileSerializer):

38
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.test import APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from decimal import Decimal
from apps.account.models import User from apps.account.models import User
from apps.course.models import Course, CourseCategory, Participant from apps.course.models import Course, CourseCategory, Participant
from apps.transaction.models import TransactionParticipant from apps.transaction.models import TransactionParticipant
@ -214,3 +215,40 @@ class MultipleRolesAPITestCase(TestCase):
# user_type field باید بروزرسانی شود # user_type field باید بروزرسانی شود
self.assertEqual(self.user.user_type, User.UserType.PROFESSOR) 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')

11
apps/course/views/admin.py

@ -64,11 +64,20 @@ class AdminCourseViewSet(viewsets.ModelViewSet):
# Search Query # Search Query
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)
has_search = False
if search_query: if search_query:
has_search = True
from django.db.models import Case, When, Value, IntegerField
queryset = queryset.filter( queryset = queryset.filter(
Q(title__icontains=search_query) | Q(title__icontains=search_query) |
Q(description__icontains=search_query) | Q(description__icontains=search_query) |
Q(short_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 # Filters
@ -95,6 +104,8 @@ class AdminCourseViewSet(viewsets.ModelViewSet):
if student_id: if student_id:
queryset = queryset.filter(participants__student_id=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') return queryset.order_by('-id')
def perform_create(self, serializer): def perform_create(self, serializer):

Loading…
Cancel
Save