uchill/backend/apps/subscriptions/admin.py

639 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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