639 lines
25 KiB
Python
639 lines
25 KiB
Python
"""
|
||
Административная панель для подписок.
|
||
"""
|
||
from django.contrib import admin
|
||
from django.utils.html import format_html
|
||
from django.urls import reverse
|
||
from .models import SubscriptionPlan, Subscription, Payment, PaymentHistory, SubscriptionUsageLog, BulkDiscount, DurationDiscount
|
||
|
||
|
||
class BulkDiscountInline(admin.TabularInline):
|
||
"""Inline для прогрессирующих скидок."""
|
||
model = BulkDiscount
|
||
extra = 1
|
||
fields = ('min_students', 'max_students', 'price_per_student', 'total_price_display')
|
||
readonly_fields = ('total_price_display',)
|
||
verbose_name = 'Прогрессирующая скидка'
|
||
verbose_name_plural = 'Прогрессирующие скидки'
|
||
|
||
def total_price_display(self, obj):
|
||
"""Отображение итоговой цены (автоматически рассчитывается)."""
|
||
if obj.pk and obj.price_per_student and obj.min_students:
|
||
from decimal import Decimal
|
||
total = obj.price_per_student * Decimal(str(obj.min_students))
|
||
return f"{total:.2f} {obj.plan.currency if obj.plan else 'RUB'}"
|
||
elif obj.price_per_student and obj.min_students:
|
||
# Для новых объектов (еще не сохраненных)
|
||
from decimal import Decimal
|
||
total = obj.price_per_student * Decimal(str(obj.min_students))
|
||
return f"{total:.2f} {obj.plan.currency if hasattr(obj, 'plan') and obj.plan else 'RUB'} (будет рассчитано)"
|
||
return "-"
|
||
total_price_display.short_description = 'Итоговая цена'
|
||
|
||
def get_queryset(self, request):
|
||
"""Оптимизация запросов."""
|
||
qs = super().get_queryset(request)
|
||
return qs.select_related('plan')
|
||
|
||
|
||
class DurationDiscountInline(admin.TabularInline):
|
||
"""Inline для скидок за длительность (определяет доступные периоды оплаты)."""
|
||
model = DurationDiscount
|
||
extra = 1
|
||
fields = ('duration_days', 'discount_percent', 'duration_display', 'is_available')
|
||
readonly_fields = ('duration_display', 'is_available')
|
||
verbose_name = 'Скидка за длительность (доступные периоды оплаты)'
|
||
verbose_name_plural = 'Скидки за длительность (доступные периоды оплаты)'
|
||
|
||
def duration_display(self, obj):
|
||
"""Отображение длительности в месяцах."""
|
||
if obj.duration_days:
|
||
months = obj.duration_days / 30
|
||
return f"{months:.0f} месяцев ({obj.duration_days} дней)"
|
||
return "-"
|
||
duration_display.short_description = 'Длительность'
|
||
|
||
def is_available(self, obj):
|
||
"""Показывает, что период доступен для оплаты."""
|
||
return "✓ Доступен для оплаты"
|
||
is_available.short_description = 'Статус'
|
||
|
||
def get_queryset(self, request):
|
||
"""Оптимизация запросов."""
|
||
qs = super().get_queryset(request)
|
||
return qs.select_related('plan')
|
||
|
||
|
||
@admin.register(SubscriptionPlan)
|
||
class SubscriptionPlanAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для тарифных планов."""
|
||
|
||
list_display = [
|
||
'name',
|
||
'price_display',
|
||
'billing_period_display',
|
||
'trial_days',
|
||
'is_featured_badge',
|
||
'is_active_badge',
|
||
'subscribers_count',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = [
|
||
'billing_period',
|
||
'subscription_type',
|
||
'target_role',
|
||
'is_active',
|
||
'is_featured',
|
||
'created_at'
|
||
]
|
||
|
||
search_fields = ['name', 'description']
|
||
|
||
readonly_fields = ['subscribers_count', 'current_uses', 'created_at', 'updated_at']
|
||
|
||
prepopulated_fields = {'slug': ('name',)}
|
||
|
||
inlines = [BulkDiscountInline, DurationDiscountInline]
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('name', 'slug', 'description')
|
||
}),
|
||
('Стоимость', {
|
||
'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'),
|
||
'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).'
|
||
}),
|
||
('Устаревшие настройки', {
|
||
'fields': ('billing_period',),
|
||
'classes': ('collapse',),
|
||
'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
|
||
}),
|
||
('Целевая аудитория', {
|
||
'fields': ('target_role',),
|
||
'description': 'Укажите, для кого предназначена эта подписка: для всех, для менторов, для студентов или для родителей.'
|
||
}),
|
||
('Акция', {
|
||
'fields': ('promo_type', 'max_uses'),
|
||
'description': 'Настройки акции для тарифа. '
|
||
'Тип "Ограниченное количество использований" - тариф можно использовать только указанное количество раз. '
|
||
'Тип "Только для новых пользователей" - тариф доступен только пользователям без подписок. '
|
||
'Текущее количество использований отображается в разделе "Статистика".'
|
||
}),
|
||
('Лимиты', {
|
||
'fields': (
|
||
'max_clients',
|
||
'max_lessons_per_month',
|
||
'max_storage_mb',
|
||
'max_video_minutes_per_month'
|
||
)
|
||
}),
|
||
('Функциональность', {
|
||
'fields': (
|
||
'allow_video_calls',
|
||
'allow_screen_sharing',
|
||
'allow_whiteboard',
|
||
'allow_homework',
|
||
'allow_materials',
|
||
'allow_analytics',
|
||
'allow_telegram_bot',
|
||
'allow_api_access'
|
||
)
|
||
}),
|
||
('Настройки', {
|
||
'fields': ('is_active', 'is_featured', 'sort_order')
|
||
}),
|
||
('Статистика', {
|
||
'fields': ('subscribers_count', 'current_uses', 'created_at', 'updated_at')
|
||
})
|
||
)
|
||
|
||
actions = ['activate_plans', 'deactivate_plans', 'feature_plans']
|
||
|
||
def price_display(self, obj):
|
||
"""Отображение цены."""
|
||
return f"{obj.price} {obj.currency}"
|
||
price_display.short_description = 'Цена'
|
||
|
||
def billing_period_display(self, obj):
|
||
"""Отображение периода в днях."""
|
||
days = obj.get_duration_days()
|
||
return format_html(
|
||
'<span style="background-color: #17a2b8; color: white; padding: 3px 10px; border-radius: 3px;">{} дн.</span>',
|
||
days
|
||
)
|
||
billing_period_display.short_description = 'Период'
|
||
|
||
def is_active_badge(self, obj):
|
||
"""Бейдж активности."""
|
||
if obj.is_active:
|
||
return format_html('<span style="color: green;">✓ Активен</span>')
|
||
return format_html('<span style="color: red;">✗ Неактивен</span>')
|
||
is_active_badge.short_description = 'Активен'
|
||
|
||
def is_featured_badge(self, obj):
|
||
"""Бейдж рекомендации."""
|
||
if obj.is_featured:
|
||
return format_html('<span style="color: gold;">★ Рекомендуем</span>')
|
||
return ''
|
||
is_featured_badge.short_description = 'Рекомендуемый'
|
||
|
||
@admin.action(description='Активировать планы')
|
||
def activate_plans(self, request, queryset):
|
||
"""Активировать планы."""
|
||
queryset.update(is_active=True)
|
||
|
||
@admin.action(description='Деактивировать планы')
|
||
def deactivate_plans(self, request, queryset):
|
||
"""Деактивировать планы."""
|
||
queryset.update(is_active=False)
|
||
|
||
@admin.action(description='Сделать рекомендуемыми')
|
||
def feature_plans(self, request, queryset):
|
||
"""Сделать рекомендуемыми."""
|
||
queryset.update(is_featured=True)
|
||
|
||
|
||
@admin.register(Subscription)
|
||
class SubscriptionAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для подписок."""
|
||
|
||
list_display = [
|
||
'user_link',
|
||
'plan_link',
|
||
'status_badge',
|
||
'is_active_badge',
|
||
'student_count_display',
|
||
'start_date',
|
||
'end_date',
|
||
'days_left',
|
||
'auto_renew_badge',
|
||
'settings_link',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = [
|
||
'status',
|
||
'auto_renew',
|
||
'created_at',
|
||
'plan'
|
||
]
|
||
|
||
search_fields = [
|
||
'user__email',
|
||
'user__first_name',
|
||
'user__last_name',
|
||
'plan__name'
|
||
]
|
||
|
||
readonly_fields = [
|
||
'lessons_used',
|
||
'storage_used_mb',
|
||
'video_minutes_used',
|
||
'created_at',
|
||
'updated_at'
|
||
]
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('user', 'plan', 'status')
|
||
}),
|
||
('Даты', {
|
||
'fields': ('start_date', 'end_date', 'trial_end_date', 'cancelled_at', 'duration_days')
|
||
}),
|
||
('Настройки', {
|
||
'fields': ('auto_renew',)
|
||
}),
|
||
('Тариф "За ученика"', {
|
||
'fields': (
|
||
'student_count',
|
||
'unpaid_students_count',
|
||
'pending_payment_amount'
|
||
),
|
||
'description': 'Настройки для тарифа "За ученика": количество оплаченных учеников, неоплаченных и ожидающая доплата.'
|
||
}),
|
||
('Финансы', {
|
||
'fields': (
|
||
'original_amount',
|
||
'discount_amount',
|
||
'final_amount'
|
||
),
|
||
'description': 'Суммы подписки: исходная, скидка, итоговая.'
|
||
}),
|
||
('Использование', {
|
||
'fields': ('lessons_used', 'storage_used_mb', 'video_minutes_used')
|
||
}),
|
||
('Временные метки', {
|
||
'fields': ('created_at', 'updated_at')
|
||
})
|
||
)
|
||
|
||
actions = ['renew_subscriptions', 'cancel_subscriptions']
|
||
|
||
def user_link(self, obj):
|
||
"""Ссылка на пользователя."""
|
||
url = reverse('admin:users_user_change', args=[obj.user.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.user.get_full_name())
|
||
user_link.short_description = 'Пользователь'
|
||
|
||
def plan_link(self, obj):
|
||
"""Ссылка на план."""
|
||
url = reverse('admin:subscriptions_subscriptionplan_change', args=[obj.plan.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.plan.name)
|
||
plan_link.short_description = 'План'
|
||
|
||
def status_badge(self, obj):
|
||
"""Бейдж статуса."""
|
||
colors = {
|
||
'trial': '#17a2b8',
|
||
'active': '#28a745',
|
||
'past_due': '#ffc107',
|
||
'cancelled': '#6c757d',
|
||
'expired': '#dc3545'
|
||
}
|
||
return format_html(
|
||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||
colors.get(obj.status, '#000'),
|
||
obj.get_status_display()
|
||
)
|
||
status_badge.short_description = 'Статус'
|
||
|
||
def is_active_badge(self, obj):
|
||
"""Бейдж активности (проверка через is_active())."""
|
||
from django.utils import timezone
|
||
is_active = obj.is_active()
|
||
if is_active:
|
||
return format_html('<span style="color: green; font-weight: bold;">✓ Активна</span>')
|
||
else:
|
||
# Показываем причину неактивности
|
||
now = timezone.now()
|
||
reasons = []
|
||
if obj.status not in ['trial', 'active']:
|
||
reasons.append(f"статус={obj.status}")
|
||
if obj.start_date > now:
|
||
reasons.append(f"начало={obj.start_date.strftime('%d.%m.%Y')}")
|
||
if obj.end_date < now:
|
||
reasons.append(f"окончание={obj.end_date.strftime('%d.%m.%Y')}")
|
||
|
||
reason_text = ", ".join(reasons) if reasons else "неактивна"
|
||
return format_html(
|
||
'<span style="color: red; font-weight: bold;">✗ Неактивна</span><br>'
|
||
'<small style="color: gray;">{}</small>',
|
||
reason_text
|
||
)
|
||
is_active_badge.short_description = 'Активна?'
|
||
|
||
def days_left(self, obj):
|
||
"""Дней до истечения."""
|
||
if obj.is_active():
|
||
days = obj.days_until_expiration()
|
||
if days <= 7:
|
||
return format_html('<span style="color: red; font-weight: bold;">{} дней</span>', days)
|
||
elif days <= 30:
|
||
return format_html('<span style="color: orange;">{} дней</span>', days)
|
||
return f"{days} дней"
|
||
return '-'
|
||
days_left.short_description = 'Осталось'
|
||
|
||
def auto_renew_badge(self, obj):
|
||
"""Бейдж автопродления."""
|
||
if obj.auto_renew:
|
||
return format_html('<span style="color: green;">✓ Да</span>')
|
||
return format_html('<span style="color: gray;">✗ Нет</span>')
|
||
auto_renew_badge.short_description = 'Автопродление'
|
||
|
||
def student_count_display(self, obj):
|
||
"""Отображение количества учеников."""
|
||
if obj.plan.subscription_type == 'per_student':
|
||
result = f"Оплачено: {obj.student_count}"
|
||
if obj.unpaid_students_count > 0:
|
||
result += format_html('<br><span style="color: orange;">Неоплачено: {}</span>', obj.unpaid_students_count)
|
||
if obj.pending_payment_amount > 0:
|
||
result += format_html('<br><span style="color: red;">Доплата: {:.2f} ₽</span>', float(obj.pending_payment_amount))
|
||
return format_html(result)
|
||
return "-"
|
||
student_count_display.short_description = 'Ученики'
|
||
|
||
def settings_link(self, obj):
|
||
"""Ссылка на настройки подписки."""
|
||
url = reverse('admin:subscriptions_subscription_change', args=[obj.id])
|
||
return format_html(
|
||
'<a href="{}" style="background-color: #007bff; color: white; padding: 5px 10px; border-radius: 4px; text-decoration: none; display: inline-block;">Настройка</a>',
|
||
url
|
||
)
|
||
settings_link.short_description = 'Настройка'
|
||
|
||
@admin.action(description='Продлить подписки')
|
||
def renew_subscriptions(self, request, queryset):
|
||
"""Продлить подписки."""
|
||
for subscription in queryset:
|
||
if subscription.is_active():
|
||
subscription.renew()
|
||
|
||
@admin.action(description='Отменить подписки')
|
||
def cancel_subscriptions(self, request, queryset):
|
||
"""Отменить подписки."""
|
||
queryset.update(status='cancelled', auto_renew=False)
|
||
|
||
|
||
@admin.register(Payment)
|
||
class PaymentAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для платежей."""
|
||
|
||
list_display = [
|
||
'uuid_short',
|
||
'user_link',
|
||
'amount_display',
|
||
'status_badge',
|
||
'payment_method',
|
||
'created_at',
|
||
'paid_at'
|
||
]
|
||
|
||
list_filter = [
|
||
'status',
|
||
'payment_method',
|
||
'created_at'
|
||
]
|
||
|
||
search_fields = [
|
||
'uuid',
|
||
'user__email',
|
||
'external_id'
|
||
]
|
||
|
||
readonly_fields = [
|
||
'uuid',
|
||
'user',
|
||
'subscription',
|
||
'amount',
|
||
'currency',
|
||
'external_id',
|
||
'provider_response',
|
||
'created_at',
|
||
'paid_at',
|
||
'failed_at',
|
||
'refunded_at'
|
||
]
|
||
|
||
def uuid_short(self, obj):
|
||
"""Короткий UUID."""
|
||
return str(obj.uuid)[:8]
|
||
uuid_short.short_description = 'ID'
|
||
|
||
def user_link(self, obj):
|
||
"""Ссылка на пользователя."""
|
||
url = reverse('admin:users_user_change', args=[obj.user.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.user.get_full_name())
|
||
user_link.short_description = 'Пользователь'
|
||
|
||
def amount_display(self, obj):
|
||
"""Отображение суммы."""
|
||
return f"{obj.amount} {obj.currency}"
|
||
amount_display.short_description = 'Сумма'
|
||
|
||
def status_badge(self, obj):
|
||
"""Бейдж статуса."""
|
||
colors = {
|
||
'pending': '#ffc107',
|
||
'processing': '#17a2b8',
|
||
'succeeded': '#28a745',
|
||
'failed': '#dc3545',
|
||
'cancelled': '#6c757d',
|
||
'refunded': '#6610f2'
|
||
}
|
||
return format_html(
|
||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||
colors.get(obj.status, '#000'),
|
||
obj.get_status_display()
|
||
)
|
||
status_badge.short_description = 'Статус'
|
||
|
||
|
||
@admin.register(PaymentHistory)
|
||
class PaymentHistoryAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для истории платежей."""
|
||
|
||
list_display = [
|
||
'payment_link',
|
||
'status',
|
||
'message',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = ['status', 'created_at']
|
||
|
||
search_fields = ['payment__uuid', 'message']
|
||
|
||
readonly_fields = ['payment', 'status', 'message', 'data', 'created_at']
|
||
|
||
def payment_link(self, obj):
|
||
"""Ссылка на платеж."""
|
||
url = reverse('admin:subscriptions_payment_change', args=[obj.payment.id])
|
||
return format_html('<a href="{}">{}</a>', url, str(obj.payment.uuid)[:8])
|
||
payment_link.short_description = 'Платеж'
|
||
|
||
|
||
@admin.register(SubscriptionUsageLog)
|
||
class SubscriptionUsageLogAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для логов использования."""
|
||
|
||
list_display = [
|
||
'subscription_link',
|
||
'usage_type_badge',
|
||
'amount',
|
||
'description',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = ['usage_type', 'created_at']
|
||
|
||
search_fields = ['subscription__user__email', 'description']
|
||
|
||
readonly_fields = ['subscription', 'usage_type', 'amount', 'description', 'created_at']
|
||
|
||
def subscription_link(self, obj):
|
||
"""Ссылка на подписку."""
|
||
url = reverse('admin:subscriptions_subscription_change', args=[obj.subscription.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.subscription.user.email)
|
||
subscription_link.short_description = 'Подписка'
|
||
|
||
def usage_type_badge(self, obj):
|
||
"""Бейдж типа использования."""
|
||
colors = {
|
||
'lesson': '#007bff',
|
||
'storage': '#28a745',
|
||
'video_minutes': '#dc3545'
|
||
}
|
||
return format_html(
|
||
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px;">{}</span>',
|
||
colors.get(obj.usage_type, '#000'),
|
||
obj.get_usage_type_display()
|
||
)
|
||
usage_type_badge.short_description = 'Тип'
|
||
|
||
|
||
@admin.register(DurationDiscount)
|
||
class DurationDiscountAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для скидок за длительность."""
|
||
|
||
list_display = [
|
||
'plan_link',
|
||
'duration_display',
|
||
'discount_percent_display',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = [
|
||
'plan',
|
||
'duration_days',
|
||
'created_at'
|
||
]
|
||
|
||
search_fields = [
|
||
'plan__name',
|
||
'plan__description'
|
||
]
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('plan',)
|
||
}),
|
||
('Скидка и доступный период', {
|
||
'fields': ('duration_days', 'discount_percent'),
|
||
'description': 'Укажите длительность в днях (30, 90, 180, 365) и процент скидки (например, 7.00 для 7%). '
|
||
'ВАЖНО: Периоды, для которых указаны скидки, автоматически становятся доступными для оплаты. '
|
||
'Если скидок нет, доступны все стандартные периоды (30, 90, 180, 365 дней).'
|
||
}),
|
||
('Временные метки', {
|
||
'fields': ('created_at', 'updated_at'),
|
||
'classes': ('collapse',)
|
||
})
|
||
)
|
||
|
||
readonly_fields = ['created_at', 'updated_at']
|
||
|
||
def plan_link(self, obj):
|
||
"""Ссылка на план."""
|
||
url = reverse('admin:subscriptions_subscriptionplan_change', args=[obj.plan.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.plan.name)
|
||
plan_link.short_description = 'Тарифный план'
|
||
|
||
def duration_display(self, obj):
|
||
"""Длительность в месяцах."""
|
||
months = obj.duration_days / 30
|
||
return f"{months:.0f} месяцев ({obj.duration_days} дней)"
|
||
duration_display.short_description = 'Длительность'
|
||
|
||
def discount_percent_display(self, obj):
|
||
"""Отображение процента скидки."""
|
||
return f"{obj.discount_percent}%"
|
||
discount_percent_display.short_description = 'Скидка'
|
||
|
||
|
||
@admin.register(BulkDiscount)
|
||
class BulkDiscountAdmin(admin.ModelAdmin):
|
||
"""Админ интерфейс для прогрессирующих скидок (отдельная страница)."""
|
||
|
||
list_display = [
|
||
'plan_link',
|
||
'students_range',
|
||
'total_price_display',
|
||
'price_per_student_display',
|
||
'created_at'
|
||
]
|
||
|
||
list_filter = [
|
||
'plan',
|
||
'created_at'
|
||
]
|
||
|
||
search_fields = [
|
||
'plan__name',
|
||
'plan__description'
|
||
]
|
||
|
||
fieldsets = (
|
||
('Основная информация', {
|
||
'fields': ('plan',)
|
||
}),
|
||
('Диапазон и цена', {
|
||
'fields': ('min_students', 'max_students', 'price_per_student', 'total_price'),
|
||
'description': 'Укажите диапазон количества учеников и цену за одного ученика. '
|
||
'Итоговая цена рассчитывается автоматически как: цена за ученика × минимальное количество учеников. '
|
||
'Если max_students не указано, скидка действует для всех количеств от min_students и выше.'
|
||
}),
|
||
('Временные метки', {
|
||
'fields': ('created_at', 'updated_at'),
|
||
'classes': ('collapse',)
|
||
})
|
||
)
|
||
|
||
readonly_fields = ['total_price', 'created_at', 'updated_at']
|
||
|
||
def plan_link(self, obj):
|
||
"""Ссылка на план."""
|
||
url = reverse('admin:subscriptions_subscriptionplan_change', args=[obj.plan.id])
|
||
return format_html('<a href="{}">{}</a>', url, obj.plan.name)
|
||
plan_link.short_description = 'Тарифный план'
|
||
|
||
def students_range(self, obj):
|
||
"""Диапазон учеников."""
|
||
if obj.max_students:
|
||
return f"{obj.min_students} - {obj.max_students} учеников"
|
||
else:
|
||
return f"{obj.min_students}+ учеников"
|
||
students_range.short_description = 'Диапазон'
|
||
|
||
def total_price_display(self, obj):
|
||
"""Отображение итоговой цены."""
|
||
return f"{obj.total_price} {obj.plan.currency}"
|
||
total_price_display.short_description = 'Итоговая цена'
|
||
|
||
def price_per_student_display(self, obj):
|
||
"""Цена за одного ученика в этом диапазоне."""
|
||
if obj.price_per_student:
|
||
return f"{obj.price_per_student:.2f} {obj.plan.currency}/ученик"
|
||
return "-"
|
||
price_per_student_display.short_description = 'Цена за ученика'
|