@@ -0,0 +1,4 @@ +from lib.exporters.base import BaseExporter +from lib.exporters.excel import ExcelExporter + +__all__ = ['BaseExporter', 'ExcelExporter'] @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from django.http import HttpResponse + + +class BaseExporter(ABC): + @abstractmethod + def export(self) -> HttpResponse: + raise NotImplementedError @@ -0,0 +1,38 @@ +from collections.abc import Iterable, Sequence + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + +from lib.exporters.base import BaseExporter + + +class ExcelExporter(BaseExporter): + CONTENT_TYPE = 'application/ms-excel' + SHEET_TITLE = 'Sheet' + COLUMN_WIDTH = 150 / 7 + + def get_headers(self) -> Sequence: + raise NotImplementedError + + def get_rows(self) -> Iterable[Sequence]: + raise NotImplementedError + + def get_filename(self) -> str: + raise NotImplementedError + + def export(self) -> HttpResponse: + wb = Workbook() + sheet = wb.active + sheet.title = self.SHEET_TITLE + sheet.append(list(self.get_headers())) + for row in self.get_rows(): + sheet.append(list(row)) + + for index in range(1, sheet.max_column + 1): + sheet.column_dimensions[get_column_letter(index)].width = self.COLUMN_WIDTH + + response = HttpResponse(content_type=self.CONTENT_TYPE) + response['Content-Disposition'] = f'attachment; filename={self.get_filename()}' + wb.save(response) + return response @@ -0,0 +1,4 @@ +from payments.exporters.invoice_daily_by_model import InvoiceDailyByModelExporter +from payments.exporters.payment_daily import PaymentDailyExporter + +__all__ = ['InvoiceDailyByModelExporter', 'PaymentDailyExporter'] @@ -0,0 +1,51 @@ +from collections import defaultdict +from datetime import date, datetime, time, timedelta + +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Invoice + + +class InvoiceDailyByModelExporter(ExcelExporter): + SHEET_TITLE = 'Invoices by model' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + self._days = [date_from + timedelta(days=offset) for offset in range((date_to - date_from).days + 1)] + + def get_headers(self): + return ['Модель', *[day.strftime('%d.%m.%Y') for day in self._days]] + + def get_filename(self) -> str: + return f'invoices_by_model_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Invoice.objects.filter( + created_at__gte=start, + created_at__lt=end, + model__isnull=False, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('model__slug', 'day') + .annotate(count=Count('id')) + .order_by('model__slug', 'day') + ) + + matrix: dict[str, dict[date, int]] = defaultdict(lambda: {day: 0 for day in self._days}) + for row in rows: + matrix[row['model__slug']][row['day']] = row['count'] + + for slug in sorted(matrix): + yield slug, *[matrix[slug][day] for day in self._days] @@ -0,0 +1,49 @@ +from datetime import date, datetime, time, timedelta +from decimal import Decimal + +from django.db import models +from django.db.models import Count, Sum +from django.db.models.functions import Coalesce, TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Payment + + +class PaymentDailyExporter(ExcelExporter): + SHEET_TITLE = 'Payments by day' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + + def get_headers(self): + return ['Дата', 'Кол-во платежей', 'Сумма'] + + def get_filename(self) -> str: + return f'payments_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Payment.objects.filter( + status=Payment.SUCCEEDED, + created_at__gte=start, + created_at__lt=end, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('day') + .annotate( + count=Count('uid'), + total=Coalesce(Sum('amount'), Decimal(0), output_field=models.DecimalField()), + ) + .order_by('day') + ) + for row in rows: + yield row['day'].strftime('%d.%m.%Y'), row['count'], row['total'] @@ -0,0 +1,3 @@ +from payments.mixins.admin import DateRangeExportAdminMixin + +__all__ = ['DateRangeExportAdminMixin'] @@ -0,0 +1,52 @@ +from datetime import date, timedelta + +from django.contrib.admin import helpers +from django.http import HttpRequest, HttpResponse +from django.template.response import TemplateResponse +from django.utils import timezone + +from lib.exporters import BaseExporter + + +class DateRangeExportAdminMixin: + TEMPLATE = 'admin/payments/export_daily.html' + + def export_by_date_range( + self, + request: HttpRequest, + queryset, + *, + exporter_class: type[BaseExporter], + action_name: str, + title: str, + ) -> HttpResponse: + date_from = request.POST.get('date_from') or (timezone.localdate() - timedelta(days=6)).isoformat() + date_to = request.POST.get('date_to') or timezone.localdate().isoformat() + error = None + + if 'apply' in request.POST: + try: + parsed_from = date.fromisoformat(date_from) + parsed_to = date.fromisoformat(date_to) + except ValueError: + error = 'Некорректный формат даты.' + else: + if parsed_from > parsed_to: + error = 'Дата «С» не может быть позже «По».' + else: + return exporter_class(parsed_from, parsed_to).export() + + return TemplateResponse( + request, + self.TEMPLATE, + { + **self.admin_site.each_context(request), + 'title': title, + 'queryset': queryset, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'action_name': action_name, + 'date_from': date_from, + 'date_to': date_to, + 'error': error, + }, + ) @@ -0,0 +1,34 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+ {% csrf_token %} + + + {% for obj in queryset %} + + {% endfor %} + +
+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+{% endblock %} @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from ordered_model.admin import OrderedInlineModelAdminMixin, OrderedModelAdmin from authentication.admin import CustomUserModelAdmin +from payments.mixins import DateRangeExportAdminMixin from payments.models import ( Invoice, Payment, @@ -18,15 +19,17 @@ from payments.models import ( PromoCodeActivation, PaymentMethod, ) +from payments.exporters import InvoiceDailyByModelExporter, PaymentDailyExporter from payments.models.attempt import PaymentAttempt from payments.models.referral_account import ReferralAccount, ReferralInvite @admin.register(Payment) -class PaymentAdmin(admin.ModelAdmin): +class PaymentAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['uid', '_user', 'created_at', 'status'] raw_id_fields = ['user'] date_hierarchy = 'created_at' + actions = ['export_daily_totals'] search_fields = [ 'uid', @@ -41,6 +44,16 @@ class PaymentAdmin(admin.ModelAdmin): return _('Missing') return str(obj.user) + @admin.action(description='Выгрузить суммы платежей по дням') + def export_daily_totals(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=PaymentDailyExporter, + action_name='export_daily_totals', + title='Выгрузить суммы платежей по дням', + ) + @admin.register(PaymentPlan) class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @@ -151,18 +164,16 @@ class PaymentAttemptAdmin(admin.ModelAdmin): @admin.register(Invoice) -class InvoiceAdmin(admin.ModelAdmin): +class InvoiceAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['_user', '_model'] - raw_id_fields = ['user', 'message', 'model'] - search_fields = [ 'user__email', 'user__host_account__company_name', 'user__business_account__parent_company__company_name', ] - search_help_text = _('You can search by user email, exacted company name') + actions = ['export_daily_by_model'] @admin.display(description=_('User')) def _user(self, obj): @@ -174,6 +185,16 @@ class InvoiceAdmin(admin.ModelAdmin): def _model(self, obj): return str(obj.model) + @admin.action(description='Выгрузить кол-во инвойсов по моделям и дням') + def export_daily_by_model(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=InvoiceDailyByModelExporter, + action_name='export_daily_by_model', + title='Выгрузить кол-во инвойсов по моделям и дням', + ) + @admin.register(PromoCode) class PromoCodeAdmin(admin.ModelAdmin):