""" Административная панель для подписок. """ 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', 'billing_period', 'subscription_type', 'trial_days'), 'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. ' 'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". ' 'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).' }), ('Целевая аудитория', { '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): """Отображение периода.""" colors = { 'monthly': '#17a2b8', 'quarterly': '#28a745', 'yearly': '#ffc107', 'lifetime': '#6610f2' } return format_html( '{}', colors.get(obj.billing_period, '#000'), obj.get_billing_period_display() ) billing_period_display.short_description = 'Период' def is_active_badge(self, obj): """Бейдж активности.""" if obj.is_active: return format_html('✓ Активен') return format_html('✗ Неактивен') is_active_badge.short_description = 'Активен' def is_featured_badge(self, obj): """Бейдж рекомендации.""" if obj.is_featured: return format_html('★ Рекомендуем') 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('{}', 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('{}', 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( '{}', 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('✓ Активна') 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( '✗ Неактивна
' '{}', 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('{} дней', days) elif days <= 30: return format_html('{} дней', days) return f"{days} дней" return '-' days_left.short_description = 'Осталось' def auto_renew_badge(self, obj): """Бейдж автопродления.""" if obj.auto_renew: return format_html('✓ Да') return format_html('✗ Нет') 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('
Неоплачено: {}', obj.unpaid_students_count) if obj.pending_payment_amount > 0: result += format_html('
Доплата: {:.2f} ₽', 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( 'Настройка', 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('{}', 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( '{}', 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('{}', 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('{}', 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( '{}', 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('{}', 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('{}', 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 = 'Цена за ученика'