@@ -1,9 +1,14 @@ -from ninja import Router +from django.http import FileResponse +from import_export.tmp_storages import TempFolderStorage + +from ninja import Router, Query from ninja.errors import HttpError -from authentication.schemas import UserSchema -from authentication.security import SyncAuthBearer +from authentication.schemas import UserSchema, DownloadingExpensesSchema +from authentication.security import SyncAuthBearer, AsyncAuthBearer from authentication.selectors.user_selector import UserSelector +from payments.models import Invoice +from payments.resources import EmployeeExpensesResource router = Router(auth=SyncAuthBearer(), tags=['auth']) @@ -15,3 +20,31 @@ def get_user_data(request): return UserSelector.detail(user=request.auth, provider=request.provider) except Exception as exc: raise HttpError(400, f'{exc}') + + +@router.get( + 'business-host/download-expenses', tags=['auth/business-host/download-expenses'], auth=AsyncAuthBearer() +) +async def download_employees_expenses(request, data: DownloadingExpensesSchema = Query(...)): + if request.auth.account_type not in ('business_host', 'business_admin'): + raise HttpError(401, 'Unauthorized') + tmp_storage = TempFolderStorage(name='business_accounts_info.xlsx', read_mode='rb') + try: + company = request.auth.host or request.auth.employee.parent_company + employees = [acc['user'] async for acc in company.accounts.values('user')] + qs = [ + invoice + async for invoice in Invoice.objects.filter( + user__in=(company.user, *employees), created_at__date__range=(data.start, data.end) + ) + ] + dataset = await EmployeeExpensesResource().aexport(qs) + with tmp_storage._open(mode='wb') as tmp_file: + tmp_file.write(dataset.xlsx) + return FileResponse( + tmp_storage._open(mode='rb'), filename='business_accounts_info.xlsx', as_attachment=True + ) + except Exception as exc: + raise HttpError(400, f'{exc}') + finally: + tmp_storage.remove() @@ -35,6 +35,9 @@ PATH_PREFETCH_MAP = { 'plan__duration', 'plan__points') ) }, + '/api/v1/auth/business-host/download-expenses': { + 'select': ('host_account', 'business_account'), + }, '/api/v1/payments/user-balance': { 'select': ( 'host_account', @@ -1,7 +1,8 @@ -from datetime import datetime +from datetime import datetime, date from typing import Optional, Dict, List -from ninja import ModelSchema, Schema +from dateutil.relativedelta import relativedelta +from ninja import ModelSchema, Schema, Query from pydantic import UUID4 from social_django.models import UserSocialAuth @@ -43,3 +44,13 @@ class UserSchema(Schema): def resolve_account_type(obj: CustomUserModel): return obj.account_type + +class DownloadingExpensesSchema(Schema): + start: date | None = Query( + default=date.today() - relativedelta(months=1), + example="2024-12-18", + ) + end: date | None = Query( + default=date.today(), + example="2025-12-18", + ) @@ -10,7 +10,6 @@ from drf_spectacular.contrib.rest_framework_simplejwt import ( SimpleJWTScheme as BaseSimpleJWTScheme, ) from ninja.security import HttpBearer -from oauth2_provider.models import AccessToken from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed @@ -24,9 +23,7 @@ from authentication.utils import get_client_ip def _check_ip_client(user: CustomUserModel, request: HttpRequest): if (acc_type := user.account_type) != 'regular' and not user.is_staff and not user.is_superuser: company: BusinessUserHost = ( - user.host_account - if acc_type == 'business_host' - else user.business_account.parent_company + user.host_account if acc_type == 'business_host' else user.business_account.parent_company ) if ( company.ip_whitelist.is_enabled @@ -54,7 +51,7 @@ class JWTAuthentication(BaseAuthentication): 'business_account__group', 'business_account__parent_company__user__payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'host_account' + 'host_account', ).get(uid=payload['uid']) _check_ip_client(user, request) except CustomUserModel.DoesNotExist as exc: @@ -76,24 +73,21 @@ class SyncAuthBearer(HttpBearer): try: user_payload = async_to_sync(TokenService.decode)(token=token) request.provider = 'air' - user = CustomUserModel.objects.select_related( - *mapper.get('select', []) - ).prefetch_related( - *mapper.get('prefetch', []) - ).only( - *mapper.get('only', []) - ).get( - **{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']} + user = ( + CustomUserModel.objects.select_related(*mapper.get('select', [])) + .prefetch_related(*mapper.get('prefetch', [])) + .only(*mapper.get('only', [])) + .get(**{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']}) ) except InvalidToken: try: - user = CustomUserModel.objects.select_related( - *mapper.get('select', []) - ).prefetch_related( - *mapper.get('prefetch', []) - ).only( - *mapper.get('only', []) - ).filter(oauth2_provider_accesstoken__token=token).latest('oauth2_provider_accesstoken__created') + user = ( + CustomUserModel.objects.select_related(*mapper.get('select', [])) + .prefetch_related(*mapper.get('prefetch', [])) + .only(*mapper.get('only', [])) + .filter(oauth2_provider_accesstoken__token=token) + .latest('oauth2_provider_accesstoken__created') + ) request.provider = 'yandex' except: raise HttpError(401, _('Access token expired or does not exist')) @@ -107,24 +101,21 @@ class AsyncAuthBearer(HttpBearer): try: user_payload = await TokenService.decode(token=token) request.provider = 'air' - user = await CustomUserModel.objects.select_related( - *mapper.get('select', []) - ).prefetch_related( - *mapper.get('prefetch', []) - ).only( - *mapper.get('only', []) - ).aget( - **{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']} + user = ( + await CustomUserModel.objects.select_related(*mapper.get('select', [])) + .prefetch_related(*mapper.get('prefetch', [])) + .only(*mapper.get('only', [])) + .aget(**{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']}) ) except InvalidToken: try: - user = await CustomUserModel.objects.select_related( - *mapper.get('select', []) - ).prefetch_related( - *mapper.get('prefetch', []) - ).only( - *mapper.get('only', []) - ).filter(oauth2_provider_accesstoken__token=token).alatest('oauth2_provider_accesstoken__created') + user = ( + await CustomUserModel.objects.select_related(*mapper.get('select', [])) + .prefetch_related(*mapper.get('prefetch', [])) + .only(*mapper.get('only', [])) + .filter(oauth2_provider_accesstoken__token=token) + .alatest('oauth2_provider_accesstoken__created') + ) request.provider = 'yandex' except Exception: raise HttpError(401, _('Access token expired or does not exist')) @@ -99,7 +99,6 @@ urlpatterns = [ views.HostWorkersAPIView.as_view(), name='hosts-workers', ), - path('business-host/download-expenses', views.ExtractHostExpenses.as_view()), path( 'business-host/accounts/', views.HostInvitationAPIView.as_view(), @@ -840,58 +840,3 @@ class LogsAPIView(APIView): content_type__model__in=['apikey', 'companyipwhitelist'], ) return Response(LogSerializer(logs, many=True).data) - - -class ExtractHostExpenses(APIView): - permission_classes = (HasBusinessAdminPermissions,) - - def get(self, request, *args, **kwargs): - acc_type = UserSelector(request.user).check_account_type() - company = ( - request.user.host_account - if acc_type == 'business_host' - else request.user.business_account.parent_company - ) - match request.query_params.get('type', None): - case 'employees': - qs = Invoice.objects.filter( - user__in=[ - company.user, - *[acc['user'] for acc in company.accounts.values('user')], - ], - created_at__range=[ - request.query_params.get('from_date', timezone.now() - timedelta(days=30)), - request.query_params.get('to_date', timezone.now()), - ], - ) - case _: - qs = Invoice.objects.filter( - user__in=[ - company.user, - *[acc['user'] for acc in company.accounts.values('user')], - ], - created_at__range=[ - request.query_params.get('from_date', timezone.now() - timedelta(days=30)), - request.query_params.get('to_date', timezone.now()), - ], - ) - - wb = Workbook() - sheet = wb.active - header = ['Пользователь', 'Сумма', 'Дата затраты', 'Модель'] - sheet.append(header) - - for invoice in qs: - sheet.append( - [ - invoice.user.email, - str(invoice.cost.quantize(Decimal(('1.00')))).replace('.', ','), - str(invoice.created_at), - invoice.model.title if invoice.model else 'Не зафиксирована', - ] - ) - - response = HttpResponse(content_type='application/ms-excel') - response['Content-Disposition'] = 'attachment; filename=business_accounts_info.xlsx' - wb.save(response) - return response @@ -0,0 +1,8 @@ +from asgiref.sync import sync_to_async +from import_export.resources import ModelResource + + +class ExtraModelResource(ModelResource): + + def aexport(self, queryset=None, **kwargs): + return sync_to_async(self.export)(queryset, **kwargs) @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-12-16 19:50+0300\n" +"POT-Creation-Date: 2025-12-18 19:10+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -162,8 +162,8 @@ msgstr "Бизнес Группы" #: authentication/models/business_host.py:22 #: authentication/models/email_token.py:13 authentication/models/user.py:222 #: authentication/models/user.py:223 authentication/models/user_telegram.py:22 -#: authentication/models/user_vk.py:12 payments/admin.py:35 -#: payments/admin.py:89 payments/models/invoice.py:15 +#: authentication/models/user_vk.py:12 authentication/routes/v1.py:45 +#: payments/admin.py:35 payments/admin.py:89 payments/models/invoice.py:15 #: payments/models/payment.py:26 payments/models/payment_plan.py:61 msgid "User" msgstr "Пользователь" @@ -455,6 +455,23 @@ msgstr "Вайтлист для отмены политик" msgid "Whitelists to cancel policies" msgstr "Вайтлисты для отмены политик" +#: authentication/routes/v1.py:45 +msgid "Total amount" +msgstr "Сумма" + +#: authentication/routes/v1.py:45 +msgid "Expense date" +msgstr "Дата затраты" + +#: authentication/routes/v1.py:45 ml_model/models.py:156 ml_model/models.py:402 +#: payments/admin.py:95 +msgid "Model" +msgstr "Модель" + +#: authentication/routes/v1.py:52 +msgid "Not recorded" +msgstr "Не зафиксирована" + #: authentication/security.py:35 #, fuzzy #| msgid "Hidden" @@ -469,7 +486,7 @@ msgstr "Срок действия токена доступа истек" msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:99 authentication/security.py:130 +#: authentication/security.py:103 authentication/security.py:136 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -709,10 +726,6 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:95 -msgid "Model" -msgstr "Модель" - #: ml_model/models.py:172 ml_model/models.py:173 msgid "Settings" msgstr "Настройки" @@ -0,0 +1,26 @@ +from decimal import Decimal + +from django.utils.translation import gettext as _ + +from import_export.fields import Field as IEField +from import_export.widgets import DateWidget + +from lib.resources import ExtraModelResource +from payments.models import Invoice + + +class EmployeeExpensesResource(ExtraModelResource): + email = IEField(attribute='user__email', column_name=_('Email')) + total_amount = IEField(attribute='cost', column_name=_('Total amount')) + expense_date = IEField(attribute='created_at', column_name=_('Expense date'), widget=DateWidget(format='%Y-%m-%d')) + model = IEField(attribute='model', column_name=_('Model')) + + def dehydrate_total_amount(self, obj): + return str(obj.cost.quantize(Decimal(('1.00')))).replace('.', ',') + + def dehydrate_model(self, obj): + return obj.model.title if obj else _('Not recorded') + + class Meta: + model = Invoice + fields = ('email', 'total_amount', 'expense_date', 'model') @@ -1,5 +1,5 @@ from datetime import date -from typing import List, Optional +from typing import List from uuid import UUID from ninja import Schema, ModelSchema, Query @@ -44,13 +44,13 @@ class PromoCodeSchema(ModelSchema): class ExpensesParamsSchema(Schema): interval_strategy: IntervalStrategyEnum | None = None source_strategy: SourceStrategyEnum | None = None - start: Optional[date] = Query( + start: date | None = Query( default=date.min, - example="2025-12-17", + example='2025-12-17', ) - end: Optional[date] = Query( + end: date | None = Query( default=date.min, - example="2025-12-07", + example='2025-12-07', )