"""
Административная панель для подписок.
"""
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(
'{} дн.',
days
)
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 = 'Цена за ученика'