@@ -486,6 +486,7 @@ if CACHEOPS_REDIS: 'tools.chats.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.media.*': {'ops': 'all', 'timeout': 60 * 60}, 'payments.paymentplan': {'ops': 'all', 'timeout': 60 * 60}, + 'payments.invoice': {'ops': 'all', 'timeout': 60 * 60 * 24 * 7}, 'messages.*': {'ops': 'all', 'timeout': 60 * 60}, 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-12 17:36+0300\n" +"POT-Creation-Date: 2025-12-16 19:50+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -509,15 +509,15 @@ msgstr "Вы не можете изменить пароль неподтвер msgid "No user_email is provided" msgstr "" -#: authentication/services/email_service.py:51 +#: authentication/services/email_service.py:50 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/email_service.py:117 +#: authentication/services/email_service.py:114 msgid "Regular users cannot send introductory letters" msgstr "Обычные пользователи не могут отсылать письма" -#: authentication/services/email_service.py:143 +#: authentication/services/email_service.py:140 msgid "Regular users cannot send invitation letters" msgstr "Обычные пользователи не могут отправлять письма для приглашений" @@ -743,7 +743,7 @@ msgstr "Версии" msgid "Link to versions" msgstr "Привязка к версиям" -#: ml_model/models.py:221 reports/models/error_report.py:10 +#: ml_model/models.py:221 msgid "Text" msgstr "Текст" @@ -1110,6 +1110,14 @@ msgstr "Платежный метод" msgid "Payment Methods" msgstr "Платежные методы" +#: payments/routes/v1.py:70 +msgid "Expenses" +msgstr "Затраты" + +#: payments/routes/v1.py:71 +msgid "Refills" +msgstr "Пополнения" + #: payments/selectors/model_payment_selector.py:25 msgid "Messages for this model are not registered in a selector" msgstr "" @@ -1150,22 +1158,6 @@ msgstr "Прокси" msgid "Proxies" msgstr "Прокси" -#: reports/models/error_report.py:9 -msgid "Author" -msgstr "Автор" - -#: reports/models/error_report.py:11 -msgid "Attachments" -msgstr "Вложения" - -#: reports/models/error_report.py:14 -msgid "User Report" -msgstr "Пользовательский репорт" - -#: reports/models/error_report.py:15 -msgid "User Reports" -msgstr "Пользовательские репорты" - #: tools/apps.py:9 msgid "Tools" msgstr "Инструменты" @@ -1262,6 +1254,18 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" +#~ msgid "Author" +#~ msgstr "Автор" + +#~ msgid "Attachments" +#~ msgstr "Вложения" + +#~ msgid "User Report" +#~ msgstr "Пользовательский репорт" + +#~ msgid "User Reports" +#~ msgstr "Пользовательские репорты" + #~ msgid "" #~ "Unable to recognize the image. (Supported formats are PNG, JPG, JPEG)" #~ msgstr "" @@ -1,11 +1,20 @@ +import calendar +from datetime import timedelta, date from decimal import Decimal -from ninja import Router +from dateutil.relativedelta import relativedelta +from django.db.models import Sum, F, Value, Func, CharField +from django.db.models.functions import TruncDay, TruncMonth, TruncYear, Round +from django.utils.translation import gettext as _ +from ninja import Router, Query from ninja.errors import HttpError from authentication.security import SyncAuthBearer +from payments.models import Invoice, Payment from payments.schema import UserBalance +from payments.schemas import ExpensesParamsSchema, ExpensesSchema from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -21,3 +30,48 @@ def get_user_balance(request): return UserBalance(current_token_balance=current_balance) except Exception as exc: raise HttpError(401, f'{exc}') + + +@router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) +def list_expenses(request, data: ExpensesParamsSchema = Query(...)): + try: + qs = Invoice.objects.filter(user=request.auth) + current_day = date.today() + start_time = date.min + end_time = date.max + match data.interval_strategy: + case IntervalStrategyEnum.CURRENT_MONTH: + start_time = current_day.replace(day=1) + end_time = current_day.replace(day=calendar.monthrange(current_day.year, current_day.month)[-1]) + case IntervalStrategyEnum.CURRENT_WEEK: + start_time = current_day - timedelta(days=current_day.weekday()) + end_time = start_time + timedelta(days=6) + case IntervalStrategyEnum.PREVIOUS_MONTH: + start_time = current_day.replace(day=1) - relativedelta(months=1) + end_time = start_time.replace(day=calendar.monthrange(start_time.year, start_time.month)[-1]) + case IntervalStrategyEnum.CUSTOM_PRESET: + start_time = data.start + end_time = data.end + qs = qs.filter(created_at__date__range=(start_time, end_time)) + match data.source_strategy: + case SourceStrategyEnum.CATEGORIES: + qs = qs.values('model__category__title').annotate(source=F('model__category__title'), amount=Round(Sum('cost'))) + case SourceStrategyEnum.MODELS: + qs = qs.values('model__title').annotate(source=F('model__title'), amount=Round((Sum('cost')))) + case date_type if date_type in (SourceStrategyEnum.DAYS, SourceStrategyEnum.MONTHS, SourceStrategyEnum.YEARS): + trunc_map = {'days': TruncDay, 'months': TruncMonth, 'years': TruncYear} + field_name = date_type[:-1] + qs = qs.values( + **{field_name:trunc_map[date_type]('created_at')}).annotate( + source = Func(F(field_name), Value('yyyy.MM.dd'), function='to_char', output_field=CharField()), + amount=Round(Sum('cost')) + ) + case SourceStrategyEnum.DAYS.BUDGET: + expenses = {'source': _('Expenses'), 'amount': qs.aggregate(amount=Round(Sum('cost')))['amount']} + refills = {'source': _('Refills'), 'amount': Payment.objects.filter(user=request.auth).aggregate(amount=Round(Sum('amount')))['amount']} + return [expenses, refills] + case _: + raise NotImplementedError + return qs + except Exception as exc: + raise HttpError(400, f'{exc}') @@ -1,10 +1,11 @@ from datetime import date -from typing import List +from typing import List, Optional from uuid import UUID -from ninja import Schema, ModelSchema -from pydantic import field_validator, condecimal +from ninja import Schema, ModelSchema, Query +from pydantic import field_validator, condecimal, field_serializer +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.models import PromoCode @@ -35,6 +36,24 @@ class PromoCodeSchema(ModelSchema): model = PromoCode exclude = ('activated_by',) - @field_validator('code', check_fields=False) - def check_code(cls, value: str): + @field_serializer('code', check_fields=False) + def serialize_code(self, value: str) -> str: return value.strip() + + +class ExpensesParamsSchema(Schema): + interval_strategy: IntervalStrategyEnum | None = None + source_strategy: SourceStrategyEnum | None = None + start: Optional[date] = Query( + default=date.min, + example="2025-12-17", + ) + end: Optional[date] = Query( + default=date.min, + example="2025-12-07", + ) + + +class ExpensesSchema(Schema): + source: str + amount: condecimal(max_digits=10, decimal_places=2) @@ -94,11 +94,6 @@ class InvoiceSerializer(serializers.ModelSerializer): exclude = ('user', 'id') -class ExpenseSerializer(serializers.Serializer): - source = serializers.CharField() - amount = serializers.DecimalField(10, 2) - - class PromoCodeSerializer(serializers.ModelSerializer): class Meta: model = PromoCode @@ -0,0 +1,17 @@ +from enum import Enum + + +class IntervalStrategyEnum(str, Enum): + CURRENT_MONTH = 'current_month' + CURRENT_WEEK = 'current_week' + PREVIOUS_MONTH = 'previous_month' + CUSTOM_PRESET = 'custom_preset' + + +class SourceStrategyEnum(str, Enum): + CATEGORIES = 'categories' + MODELS = 'models' + DAYS = 'days' + MONTHS = 'months' + YEARS = 'years' + BUDGET = 'budget' @@ -4,7 +4,6 @@ from payments import views urlpatterns = [ path('history', views.PaymentAPIView.as_view(), name='history'), - path('expenses', views.ExpensesAPIView.as_view(), name='expenses'), path( 'referral-account', views.ReferralAccountAPIView.as_view(), @@ -1,11 +1,8 @@ import logging -from datetime import date, datetime, timedelta from django.core.exceptions import ObjectDoesNotExist -from django.db.models import F, Sum, functions from django.db.models.query import QuerySet from django.db.transaction import atomic -from django.utils import timezone from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, @@ -23,14 +20,13 @@ from authentication.selectors.user_selector import UserSelector from payments.exceptions.PlanIsFree import PlanIsFree from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice -from payments.models.payment import Payment, PaymentPlan +from payments.models.payment import PaymentPlan from payments.permissions import IsAllowedToPay from payments.selectors.payment_method_selector import PaymentMethodSelector from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.selectors.payment_selector import PaymentSelector from payments.serializers import ( DeletePaymentMethodSerializer, - ExpenseSerializer, InvoiceSerializer, NewSubsriptionSerializer, PaymentLinkSerializer, @@ -188,122 +184,6 @@ class InvoicesAPIView(ListAPIView): return Invoice.objects.filter(user=self.request.user) -class ExpensesAPIView(APIView): - permission_classes = [ - IsAuthenticated, - ] - - @extend_schema( - parameters=[ - OpenApiParameter( - 'source_strategy', - str, - enum=[ - 'categories', - 'models', - 'days', - 'months', - 'years', - ], - ), - OpenApiParameter( - 'interval_strategy', - str, - enum=[ - 'current_month', - 'current_week', - 'previous_month', - 'custom_preset', - ], - ), - OpenApiParameter( - 'from', - date, - description='Применяется, если в interval_strategy выбрано custom_preset. Обозначает время ОТ которого нужно выбрать отчет', - ), - OpenApiParameter( - 'to', - date, - description='Применяется, если в interval_strategy выбрано custom_preset. Обозначает время ДО которого нужно выбрать отчет', - ), - ], - responses={ - 200: ExpenseSerializer(many=True), - }, - ) - def get(self, request, *args, **kwargs): - """List token expenses, group by time period, categories, or models.""" - qs = Invoice.objects.filter(user=request.user) - match request.query_params.get('interval_strategy'): - case 'current_month': - qs = qs.filter( - created_at__range=[ - timezone.now().replace(day=1), - timezone.now(), - ] - ) - case 'current_week': - qs = qs.filter( - created_at__range=[ - timezone.now() - timedelta(days=timezone.now().weekday()), - timezone.now() - timedelta(days=timezone.now().weekday()) + timedelta(days=6), - ] - ) - case 'previous_month': - qs = qs.filter( - created_at__range=[ - (timezone.now().replace(day=1) - timedelta(days=1)).replace(day=1), - timezone.now().replace(day=1) - timedelta(days=1), - ] - ) - case 'custom_preset': - qs = qs.filter( - created_at__range=[ - datetime.strptime( - request.query_params.get('from', '01.01.00'), - '%d.%m.%y', - ), - datetime.strptime( - request.query_params.get('to', '01.01.50'), - '%d.%m.%y', - ), - ] - ) - match request.query_params.get('source_strategy'): - case 'categories': - qs = qs.values('model__category__title').annotate( - source=F('model__category__title'), - amount=functions.Round(Sum('cost')), - ) - case 'models': - qs = qs.values('model__title').annotate( - source=F('model__title'), - amount=functions.Round((Sum('cost'))), - ) - case 'days': - qs = qs.values(day=functions.TruncDay('created_at')).annotate( - source=F('day'), amount=functions.Round(Sum('cost')) - ) - case 'months': - qs = qs.values(month=functions.TruncMonth('created_at')).annotate( - source=F('month'), amount=functions.Round(Sum('cost')) - ) - case 'years': - qs = qs.values(year=functions.TruncYear('created_at')).annotate( - source=F('year'), amount=functions.Round(Sum('cost')) - ) - case _: - dct = [{}, {}] - dct[1] = qs.aggregate(amount=functions.Round(Sum('cost'))) - dct[0] = Payment.objects.filter(user=request.user).aggregate( - amount=functions.Round(Sum('amount')) - ) - dct[1]['source'] = 'Затраты' - dct[0]['source'] = 'Пополнения' - return Response(ExpenseSerializer(data=dct).initial_data, 200) - return Response(ExpenseSerializer(qs, many=True).data, 200) - - class ReferralAccountAPIView(APIView): def get(self, request: Request, *args, **kwargs): return Response(ReferralAccountSerializer(request.user.referral_account).data)