diff --git a/input/demo-periods/Ф-1_2025-09.xlsx b/input/demo-periods/Ф-1_2025-09.xlsx new file mode 100644 index 0000000..cd48307 Binary files /dev/null and b/input/demo-periods/Ф-1_2025-09.xlsx differ diff --git a/input/demo-periods/Ф-1_2025-12.xlsx b/input/demo-periods/Ф-1_2025-12.xlsx new file mode 100644 index 0000000..139cc30 Binary files /dev/null and b/input/demo-periods/Ф-1_2025-12.xlsx differ diff --git a/input/demo-periods/Ф-1_2026-03.xlsx b/input/demo-periods/Ф-1_2026-03.xlsx new file mode 100644 index 0000000..c4bcd0e Binary files /dev/null and b/input/demo-periods/Ф-1_2026-03.xlsx differ diff --git a/input/demo-periods/Ф-1_2026-06.xlsx b/input/demo-periods/Ф-1_2026-06.xlsx new file mode 100644 index 0000000..9d77c6f Binary files /dev/null and b/input/demo-periods/Ф-1_2026-06.xlsx differ diff --git a/input/demo-periods/Ф-2_2025-Q3.xlsx b/input/demo-periods/Ф-2_2025-Q3.xlsx new file mode 100644 index 0000000..86b52c6 Binary files /dev/null and b/input/demo-periods/Ф-2_2025-Q3.xlsx differ diff --git a/input/demo-periods/Ф-2_2025-Q4.xlsx b/input/demo-periods/Ф-2_2025-Q4.xlsx new file mode 100644 index 0000000..4e6bb37 Binary files /dev/null and b/input/demo-periods/Ф-2_2025-Q4.xlsx differ diff --git a/input/demo-periods/Ф-2_2026-Q1.xlsx b/input/demo-periods/Ф-2_2026-Q1.xlsx new file mode 100644 index 0000000..4469acc Binary files /dev/null and b/input/demo-periods/Ф-2_2026-Q1.xlsx differ diff --git a/input/demo-periods/Ф-2_2026-Q2.xlsx b/input/demo-periods/Ф-2_2026-Q2.xlsx new file mode 100644 index 0000000..c9cc0b7 Binary files /dev/null and b/input/demo-periods/Ф-2_2026-Q2.xlsx differ diff --git a/input/demo-periods/Ф-3_2022.xlsx b/input/demo-periods/Ф-3_2022.xlsx new file mode 100644 index 0000000..51d935b Binary files /dev/null and b/input/demo-periods/Ф-3_2022.xlsx differ diff --git a/input/demo-periods/Ф-3_2023.xlsx b/input/demo-periods/Ф-3_2023.xlsx new file mode 100644 index 0000000..9e96cfc Binary files /dev/null and b/input/demo-periods/Ф-3_2023.xlsx differ diff --git a/input/demo-periods/Ф-3_2024.xlsx b/input/demo-periods/Ф-3_2024.xlsx new file mode 100644 index 0000000..be590ea Binary files /dev/null and b/input/demo-periods/Ф-3_2024.xlsx differ diff --git a/input/demo-periods/Ф-3_2025.xlsx b/input/demo-periods/Ф-3_2025.xlsx new file mode 100644 index 0000000..46bc5f8 Binary files /dev/null and b/input/demo-periods/Ф-3_2025.xlsx differ diff --git a/input/demo-periods/Ф-4_2024-H2.xlsx b/input/demo-periods/Ф-4_2024-H2.xlsx new file mode 100644 index 0000000..e3faab5 Binary files /dev/null and b/input/demo-periods/Ф-4_2024-H2.xlsx differ diff --git a/input/demo-periods/Ф-4_2025-H1.xlsx b/input/demo-periods/Ф-4_2025-H1.xlsx new file mode 100644 index 0000000..e5ec366 Binary files /dev/null and b/input/demo-periods/Ф-4_2025-H1.xlsx differ diff --git a/input/demo-periods/Ф-4_2025-H2.xlsx b/input/demo-periods/Ф-4_2025-H2.xlsx new file mode 100644 index 0000000..32ac433 Binary files /dev/null and b/input/demo-periods/Ф-4_2025-H2.xlsx differ diff --git a/input/demo-periods/Ф-4_2026-H1.xlsx b/input/demo-periods/Ф-4_2026-H1.xlsx new file mode 100644 index 0000000..0004938 Binary files /dev/null and b/input/demo-periods/Ф-4_2026-H1.xlsx differ diff --git a/input/demo-periods/Ф-5_2022.xlsx b/input/demo-periods/Ф-5_2022.xlsx new file mode 100644 index 0000000..5095e08 Binary files /dev/null and b/input/demo-periods/Ф-5_2022.xlsx differ diff --git a/input/demo-periods/Ф-5_2023.xlsx b/input/demo-periods/Ф-5_2023.xlsx new file mode 100644 index 0000000..4f0f708 Binary files /dev/null and b/input/demo-periods/Ф-5_2023.xlsx differ diff --git a/input/demo-periods/Ф-5_2024.xlsx b/input/demo-periods/Ф-5_2024.xlsx new file mode 100644 index 0000000..b07e6ea Binary files /dev/null and b/input/demo-periods/Ф-5_2024.xlsx differ diff --git a/input/demo-periods/Ф-5_2025.xlsx b/input/demo-periods/Ф-5_2025.xlsx new file mode 100644 index 0000000..a432213 Binary files /dev/null and b/input/demo-periods/Ф-5_2025.xlsx differ diff --git a/input/demo-periods/Ф-6_2022.xlsx b/input/demo-periods/Ф-6_2022.xlsx new file mode 100644 index 0000000..dfb6910 Binary files /dev/null and b/input/demo-periods/Ф-6_2022.xlsx differ diff --git a/input/demo-periods/Ф-6_2023.xlsx b/input/demo-periods/Ф-6_2023.xlsx new file mode 100644 index 0000000..b204cfd Binary files /dev/null and b/input/demo-periods/Ф-6_2023.xlsx differ diff --git a/input/demo-periods/Ф-6_2024.xlsx b/input/demo-periods/Ф-6_2024.xlsx new file mode 100644 index 0000000..96b2c3e Binary files /dev/null and b/input/demo-periods/Ф-6_2024.xlsx differ diff --git a/input/demo-periods/Ф-6_2025.xlsx b/input/demo-periods/Ф-6_2025.xlsx new file mode 100644 index 0000000..60f7822 Binary files /dev/null and b/input/demo-periods/Ф-6_2025.xlsx differ diff --git a/input/ПЕРИОДЫ-ДЛЯ-ДЕМО.md b/input/ПЕРИОДЫ-ДЛЯ-ДЕМО.md new file mode 100644 index 0000000..6c1199e --- /dev/null +++ b/input/ПЕРИОДЫ-ДЛЯ-ДЕМО.md @@ -0,0 +1,32 @@ +# Периоды загрузки демонстрационных форм + +Отчётный период передаётся в API отдельно от Excel-файла. Полный набор находится в каталоге `demo-periods/`. + +| Файл | Параметры периода | Отображаемый период | +|---|---|---| +| `Ф-1_2025-09.xlsx` | `report_year=2025`, `report_month=9` | Сентябрь 2025 | +| `Ф-1_2025-12.xlsx` | `report_year=2025`, `report_month=12` | Декабрь 2025 | +| `Ф-1_2026-03.xlsx` | `report_year=2026`, `report_month=3` | Март 2026 | +| `Ф-1_2026-06.xlsx` | `report_year=2026`, `report_month=6` | Июнь 2026 | +| `Ф-2_2025-Q3.xlsx` | `report_year=2025`, `report_quarter=3` | III квартал 2025 | +| `Ф-2_2025-Q4.xlsx` | `report_year=2025`, `report_quarter=4` | IV квартал 2025 | +| `Ф-2_2026-Q1.xlsx` | `report_year=2026`, `report_quarter=1` | I квартал 2026 | +| `Ф-2_2026-Q2.xlsx` | `report_year=2026`, `report_quarter=2` | II квартал 2026 | +| `Ф-3_2022.xlsx` | `report_year=2022` | 2022 год | +| `Ф-3_2023.xlsx` | `report_year=2023` | 2023 год | +| `Ф-3_2024.xlsx` | `report_year=2024` | 2024 год | +| `Ф-3_2025.xlsx` | `report_year=2025` | 2025 год | +| `Ф-4_2024-H2.xlsx` | `report_year=2024`, `report_half_year=2` | II полугодие 2024 | +| `Ф-4_2025-H1.xlsx` | `report_year=2025`, `report_half_year=1` | I полугодие 2025 | +| `Ф-4_2025-H2.xlsx` | `report_year=2025`, `report_half_year=2` | II полугодие 2025 | +| `Ф-4_2026-H1.xlsx` | `report_year=2026`, `report_half_year=1` | I полугодие 2026 | +| `Ф-5_2022.xlsx` | `report_year=2022` | 2022 год | +| `Ф-5_2023.xlsx` | `report_year=2023` | 2023 год | +| `Ф-5_2024.xlsx` | `report_year=2024` | 2024 год | +| `Ф-5_2025.xlsx` | `report_year=2025` | 2025 год | +| `Ф-6_2022.xlsx` | `report_year=2022` | 2022 год | +| `Ф-6_2023.xlsx` | `report_year=2023` | 2023 год | +| `Ф-6_2024.xlsx` | `report_year=2024` | 2024 год | +| `Ф-6_2025.xlsx` | `report_year=2025` | 2025 год | + +Каждый файл содержит только организации `Тестовая компания 1` — `Тестовая компания 20`. ИНН, ОГРН и ОКПО совпадают с данными команды Mostovik `create_test_companies`. Показатели плавно растут от ранних периодов к поздним, чтобы графики и сравнения на фронте показывали заметную динамику. diff --git a/input/Ф-1.xlsx b/input/Ф-1.xlsx index 9950bb5..6087128 100644 Binary files a/input/Ф-1.xlsx and b/input/Ф-1.xlsx differ diff --git a/input/Ф-2.xlsx b/input/Ф-2.xlsx index d494c8e..fee2bf4 100644 Binary files a/input/Ф-2.xlsx and b/input/Ф-2.xlsx differ diff --git a/input/Ф-3.xlsx b/input/Ф-3.xlsx index 8095582..5dd0e49 100644 Binary files a/input/Ф-3.xlsx and b/input/Ф-3.xlsx differ diff --git a/input/Ф-4.xlsx b/input/Ф-4.xlsx index f3a523a..604f6d6 100644 Binary files a/input/Ф-4.xlsx and b/input/Ф-4.xlsx differ diff --git a/input/Ф-5.xlsx b/input/Ф-5.xlsx index bb1ae39..8f497b3 100644 Binary files a/input/Ф-5.xlsx and b/input/Ф-5.xlsx differ diff --git a/input/Ф-6.xlsx b/input/Ф-6.xlsx index 46df81d..a42d043 100644 Binary files a/input/Ф-6.xlsx and b/input/Ф-6.xlsx differ diff --git a/src/apps/core/migrations/0002_report_upload.py b/src/apps/core/migrations/0002_report_upload.py new file mode 100644 index 0000000..4a449dd --- /dev/null +++ b/src/apps/core/migrations/0002_report_upload.py @@ -0,0 +1,110 @@ +import uuid + +import apps.core.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0001_background_job"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ReportUpload", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="Дата и время создания записи", + verbose_name="создано", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Дата и время последнего обновления", + verbose_name="обновлено", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "form", + models.CharField( + choices=[ + ("f1", "Ф-1"), + ("f2", "Ф-2"), + ("f3", "Ф-3"), + ("f4", "Ф-4"), + ("f5", "Ф-5"), + ("f6", "Ф-6"), + ], + db_index=True, + max_length=2, + ), + ), + ( + "load_batch", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "original_file", + models.FileField( + max_length=500, + upload_to=apps.core.models.report_upload_path, + ), + ), + ("file_name", models.CharField(max_length=255)), + ("content_type", models.CharField(max_length=127)), + ("file_size", models.PositiveBigIntegerField()), + ("file_hash", models.CharField(db_index=True, max_length=64)), + ( + "status", + models.CharField( + choices=[ + ("queued", "Ожидает обработки"), + ("processing", "Обрабатывается"), + ("success", "Обработан"), + ("failed", "Ошибка"), + ], + db_index=True, + default="processing", + max_length=16, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="report_uploads", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["-created_at"]}, + ), + migrations.AddConstraint( + model_name="reportupload", + constraint=models.UniqueConstraint( + condition=models.Q(load_batch__isnull=False), + fields=("form", "load_batch"), + name="unique_report_upload_form_batch", + ), + ), + ] diff --git a/src/apps/core/models.py b/src/apps/core/models.py index 9b4cb7c..abaa961 100644 --- a/src/apps/core/models.py +++ b/src/apps/core/models.py @@ -8,14 +8,70 @@ Background Job Tracking - отслеживание статуса Celery зад """ import uuid +from pathlib import Path from typing import Any from apps.core.mixins import TimestampMixin +from django.conf import settings from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ +def report_upload_path(instance, filename: str) -> str: + """Build a storage key without trusting client-supplied path components.""" + safe_name = Path(filename).name or "report.xlsx" + return f"report_uploads/{instance.form}/{instance.id}/{safe_name}" + + +class ReportUploadStatus(models.TextChoices): + QUEUED = "queued", _("Ожидает обработки") + PROCESSING = "processing", _("Обрабатывается") + SUCCESS = "success", _("Обработан") + FAILED = "failed", _("Ошибка") + + +class ReportUpload(TimestampMixin, models.Model): + """Persistent original file for one F-1 ... F-6 report upload.""" + + FORM_CHOICES = tuple((f"f{number}", f"Ф-{number}") for number in range(1, 7)) + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + form = models.CharField(max_length=2, choices=FORM_CHOICES, db_index=True) + load_batch = models.PositiveIntegerField(null=True, blank=True, db_index=True) + original_file = models.FileField(upload_to=report_upload_path, max_length=500) + file_name = models.CharField(max_length=255) + content_type = models.CharField(max_length=127) + file_size = models.PositiveBigIntegerField() + file_hash = models.CharField(max_length=64, db_index=True) + status = models.CharField( + max_length=16, + choices=ReportUploadStatus.choices, + default=ReportUploadStatus.PROCESSING, + db_index=True, + ) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="report_uploads", + ) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["form", "load_batch"], + condition=models.Q(load_batch__isnull=False), + name="unique_report_upload_form_batch", + ) + ] + + def __str__(self) -> str: + return f"{self.form}:{self.load_batch or self.id}" + + class JobStatus(models.TextChoices): """Статусы фоновых задач.""" diff --git a/src/apps/core/report_uploads.py b/src/apps/core/report_uploads.py new file mode 100644 index 0000000..bee21f9 --- /dev/null +++ b/src/apps/core/report_uploads.py @@ -0,0 +1,165 @@ +"""Persistence and download contract for original reporting files.""" + +from __future__ import annotations + +import hashlib +import mimetypes +from pathlib import Path +from typing import Any +from uuid import UUID + +from apps.core.models import ReportUpload, ReportUploadStatus +from apps.core.response import api_error_response +from django.core.files.base import ContentFile +from django.http import FileResponse +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +ALLOWED_FORMS = frozenset(f"f{number}" for number in range(1, 7)) +EXCEL_CONTENT_TYPES = { + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +} + + +class ReportUploadService: + """Own lifecycle transitions for a persisted report upload.""" + + @staticmethod + def create(*, uploaded_file, form: str, user=None) -> ReportUpload: + if form not in ALLOWED_FORMS: + raise ValueError(f"Unsupported report form: {form}") + + file_name = Path(str(uploaded_file.name)).name + content = uploaded_file.read() + uploaded_file.seek(0) + suffix = Path(file_name).suffix.lower() + content_type = EXCEL_CONTENT_TYPES.get(suffix) or ( + mimetypes.guess_type(file_name)[0] or "application/octet-stream" + ) + report_upload = ReportUpload( + form=form, + file_name=file_name, + content_type=content_type, + file_size=len(content), + file_hash=hashlib.sha256(content).hexdigest(), + status=ReportUploadStatus.PROCESSING, + uploaded_by=user if getattr(user, "is_authenticated", False) else None, + ) + report_upload.original_file.save( + file_name, + ContentFile(content), + save=False, + ) + report_upload.save() + return report_upload + + @staticmethod + def finalize(upload_id: UUID | str, result: Any) -> ReportUpload: + batch_id = ( + result.get("batch_id") + if isinstance(result, dict) + else getattr(result, "batch_id", None) + ) + if batch_id is None: + raise ValueError("Parser result does not contain batch_id") + + report_upload = ReportUpload.objects.get(pk=upload_id) + report_upload.load_batch = int(batch_id) + report_upload.status = ReportUploadStatus.SUCCESS + report_upload.save(update_fields=["load_batch", "status", "updated_at"]) + return report_upload + + @staticmethod + def mark_failed(upload_id: UUID | str) -> None: + ReportUpload.objects.filter(pk=upload_id).update( + status=ReportUploadStatus.FAILED + ) + + @staticmethod + def mark_queued(upload_id: UUID | str) -> None: + ReportUpload.objects.filter(pk=upload_id).update( + status=ReportUploadStatus.QUEUED + ) + + @staticmethod + def mark_processing(upload_id: UUID | str) -> None: + ReportUpload.objects.filter(pk=upload_id).update( + status=ReportUploadStatus.PROCESSING + ) + + +class ReportUploadDownloadView(APIView): + """Stream the unchanged original Excel file for a processed load batch.""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + tags=["Формы отчетности"], + operation_summary="Скачать исходный файл загрузки отчетности", + responses={ + 200: openapi.Response( + "Исходный .xls или .xlsx файл", + schema=openapi.Schema(type=openapi.TYPE_STRING, format="binary"), + ), + 401: "Требуется JWT-аутентификация", + 403: "Нет доступа к загрузке", + 404: "Загрузка не найдена", + 410: "Исходный файл был удален", + }, + ) + def get(self, request, form: str, load_batch: int): + if form not in ALLOWED_FORMS: + return api_error_response( + [{"code": "upload_not_found", "message": "Загрузка не найдена."}], + status_code=status.HTTP_404_NOT_FOUND, + ) + + report_upload = ReportUpload.objects.filter( + form=form, + load_batch=load_batch, + ).first() + if report_upload is None: + return api_error_response( + [{"code": "upload_not_found", "message": "Загрузка не найдена."}], + status_code=status.HTTP_404_NOT_FOUND, + ) + if ( + report_upload.uploaded_by_id != request.user.id + and not request.user.is_staff + ): + return api_error_response( + [ + { + "code": "upload_forbidden", + "message": "Нет доступа к исходному файлу загрузки.", + } + ], + status_code=status.HTTP_403_FORBIDDEN, + ) + if ( + not report_upload.original_file + or not report_upload.original_file.storage.exists( + report_upload.original_file.name + ) + ): + return api_error_response( + [ + { + "code": "source_file_gone", + "message": "Исходный файл загрузки удален.", + } + ], + status_code=status.HTTP_410_GONE, + ) + + file_handle = report_upload.original_file.open("rb") + return FileResponse( + file_handle, + as_attachment=True, + filename=Path(report_upload.file_name).name, + content_type=report_upload.content_type, + ) diff --git a/src/apps/core/upload_contracts.py b/src/apps/core/upload_contracts.py index 3a9a1ec..f004ced 100644 --- a/src/apps/core/upload_contracts.py +++ b/src/apps/core/upload_contracts.py @@ -79,10 +79,11 @@ def build_upload_success_payload( result: dict[str, Any] | None = None, job_id: str | None = None, created_at: datetime | None = None, + upload_id: str | None = None, ) -> dict[str, Any]: created = created_at or timezone.now() payload: dict[str, Any] = { - "upload_id": str(uuid4()), + "upload_id": upload_id or str(uuid4()), "form": form, "report_year": report_year, "status": status, diff --git a/src/apps/exchange/serializers.py b/src/apps/exchange/serializers.py index c19b4e2..202c0f1 100644 --- a/src/apps/exchange/serializers.py +++ b/src/apps/exchange/serializers.py @@ -1,5 +1,6 @@ """Serializers for exchange package upload endpoints.""" +from apps.exchange.models import ExchangePackageImport from rest_framework import serializers @@ -16,3 +17,21 @@ class ExchangePackageUploadSerializer(serializers.Serializer): "Поддерживаются только архивы .zip или контейнеры .bin." ) return value + + +class ExchangePackageImportSerializer(serializers.ModelSerializer): + """Frontend-facing metadata for an imported exchange package.""" + + class Meta: + model = ExchangePackageImport + fields = ( + "id", + "package_id", + "package_name", + "source_system", + "schema_version", + "delivery_channel", + "status", + "created_at", + ) + read_only_fields = fields diff --git a/src/apps/exchange/urls.py b/src/apps/exchange/urls.py index e37fad3..0e9ac33 100644 --- a/src/apps/exchange/urls.py +++ b/src/apps/exchange/urls.py @@ -1,11 +1,14 @@ """URL routes for exchange uploads.""" -from apps.exchange.views import ExchangePackageUploadView +from apps.exchange.views import ExchangePackageLatestView, ExchangePackageUploadView from django.urls import path app_name = "exchange" urlpatterns = [ + path( + "packages/latest/", ExchangePackageLatestView.as_view(), name="package-latest" + ), path( "packages/upload/", ExchangePackageUploadView.as_view(), name="package-upload" ), diff --git a/src/apps/exchange/views.py b/src/apps/exchange/views.py index e7c1c86..c89a5cf 100644 --- a/src/apps/exchange/views.py +++ b/src/apps/exchange/views.py @@ -2,8 +2,12 @@ from __future__ import annotations -from apps.exchange.models import ExchangeDeliveryChannel -from apps.exchange.serializers import ExchangePackageUploadSerializer +from apps.core.response import api_response +from apps.exchange.models import ExchangeDeliveryChannel, ExchangePackageImport +from apps.exchange.serializers import ( + ExchangePackageImportSerializer, + ExchangePackageUploadSerializer, +) from apps.exchange.services import ExchangeImportError, ExchangePackageImportService from django.conf import settings from django.utils.crypto import constant_time_compare @@ -11,13 +15,38 @@ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.parsers import MultiPartParser -from rest_framework.permissions import AllowAny +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView EXCHANGE_TAG = "Обмен данными" +class ExchangePackageLatestView(APIView): + """Return metadata for the most recent package import attempt.""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + tags=[EXCHANGE_TAG], + operation_summary="Получить последнюю загрузку пакета обмена", + responses={ + 200: ExchangePackageImportSerializer(), + 401: "Требуется JWT-аутентификация", + }, + ) + def get(self, request): + package_import = ExchangePackageImport.objects.order_by( + "-created_at", "-id" + ).first() + data = ( + ExchangePackageImportSerializer(package_import).data + if package_import is not None + else None + ) + return api_response(data) + + class ExchangePackageUploadView(APIView): """Accept encrypted exchange package uploads for dev integration.""" diff --git a/src/apps/form_1/api.py b/src/apps/form_1/api.py index a4cd4b5..c2f1d6a 100644 --- a/src/apps/form_1/api.py +++ b/src/apps/form_1/api.py @@ -8,6 +8,7 @@ API для формы Ф-1. import logging +from apps.core.report_uploads import ReportUploadService from apps.core.response import api_response from apps.core.upload_contracts import ( build_upload_error_response, @@ -140,6 +141,11 @@ class FormF1UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_month = serializer.validated_data["report_month"] + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f1", + user=request.user, + ) # Определяем размер файла для выбора режима обработки file_size = file.size @@ -152,6 +158,7 @@ class FormF1UploadView(APIView): report_year=report_year, report_month=report_month, ) + ReportUploadService.finalize(report_upload.id, result) result_serializer = ParseResultSerializer(result) return Response( build_upload_success_payload( @@ -160,10 +167,12 @@ class FormF1UploadView(APIView): report_month=report_month, status="done", result=result_serializer.data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-1") return build_upload_error_response( error_code="processing_error", @@ -172,6 +181,7 @@ class FormF1UploadView(APIView): ) # Для больших файлов - фоновая обработка + ReportUploadService.mark_queued(report_upload.id) # Сохраняем файл во временное хранилище file_path = f"uploads/form_f1/{file.name}" saved_path = default_storage.save(file_path, file) @@ -182,6 +192,7 @@ class FormF1UploadView(APIView): user_id=request.user.id, report_year=report_year, report_month=report_month, + report_upload_id=str(report_upload.id), ) return Response( @@ -191,6 +202,7 @@ class FormF1UploadView(APIView): report_month=report_month, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) diff --git a/src/apps/form_1/tasks.py b/src/apps/form_1/tasks.py index 03b775b..ecf1fef 100644 --- a/src/apps/form_1/tasks.py +++ b/src/apps/form_1/tasks.py @@ -8,6 +8,7 @@ Celery задачи для формы Ф-1. import logging from contextlib import suppress +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_1.services import parse_form_f1_file from celery import shared_task @@ -25,6 +26,7 @@ def process_form_f1_file( report_year: int | None = None, report_month: int | None = None, report_quarter: int | None = None, + report_upload_id: str | None = None, ): """ Фоновая обработка файла формы Ф-1. @@ -40,6 +42,8 @@ def process_form_f1_file( job.update_progress(10, "Загрузка файла...") try: + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) # Открываем файл из storage with default_storage.open(file_path, "rb") as f: job.update_progress(20, "Парсинг данных...") @@ -52,6 +56,8 @@ def process_form_f1_file( or (report_quarter * 3 if report_quarter is not None else None) or timezone.now().month, ) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) job.update_progress(90, "Завершение...") @@ -61,6 +67,8 @@ def process_form_f1_file( return result.to_dict() except Exception as e: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) logger.exception(f"Ошибка обработки файла Ф-1: {e}") # Пытаемся удалить файл при ошибке with suppress(Exception): diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py index 6120006..2a385df 100644 --- a/src/apps/form_2/api.py +++ b/src/apps/form_2/api.py @@ -8,6 +8,7 @@ API формы Ф-2. import logging +from apps.core.report_uploads import ReportUploadService from apps.core.upload_contracts import ( build_upload_error_response, build_upload_success_payload, @@ -65,15 +66,22 @@ class FormF2UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f2", + user=request.user, + ) # Для больших файлов - фоновая обработка if file.size > BACKGROUND_THRESHOLD: + ReportUploadService.mark_queued(report_upload.id) file_content = file.read() task = process_form_f2_file.delay( file_content, file.name, report_year, report_quarter, + str(report_upload.id), ) return Response( @@ -83,6 +91,7 @@ class FormF2UploadView(APIView): report_quarter=report_quarter, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) @@ -94,6 +103,7 @@ class FormF2UploadView(APIView): report_year=report_year, report_quarter=report_quarter, ) + ReportUploadService.finalize(report_upload.id, result) result_serializer = FormF2ParseResultSerializer(result) return Response( @@ -103,10 +113,12 @@ class FormF2UploadView(APIView): report_quarter=report_quarter, status="done", result=result_serializer.data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-2") return build_upload_error_response( error_code="processing_error", diff --git a/src/apps/form_2/tasks.py b/src/apps/form_2/tasks.py index d292b1f..be4d54a 100644 --- a/src/apps/form_2/tasks.py +++ b/src/apps/form_2/tasks.py @@ -7,6 +7,7 @@ Celery задачи для формы Ф-2. import logging +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_2.services import FormF2Parser from celery import shared_task @@ -21,6 +22,7 @@ def process_form_f2_file( file_name: str, report_year: int, report_quarter: int | None = None, + report_upload_id: str | None = None, ) -> dict: """ Фоновая обработка файла формы Ф-2. @@ -40,8 +42,17 @@ def process_form_f2_file( file_io = BytesIO(file_content) # Парсим файл + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) parser = FormF2Parser(report_year=report_year, report_quarter=report_quarter) - result = parser.parse(file_io) + try: + result = parser.parse(file_io) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) + except Exception: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) + raise logger.info( f"Обработка Ф-2 завершена: загружено {result.loaded_count}, " diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py index 84c10fd..ac8c8ab 100644 --- a/src/apps/form_3/api.py +++ b/src/apps/form_3/api.py @@ -8,6 +8,7 @@ API формы Ф-3. import logging +from apps.core.report_uploads import ReportUploadService from apps.core.upload_contracts import ( build_upload_error_response, build_upload_success_payload, @@ -64,14 +65,21 @@ class FormF3UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f3", + user=request.user, + ) if file.size > BACKGROUND_THRESHOLD: + ReportUploadService.mark_queued(report_upload.id) file_content = file.read() task = process_form_f3_file.delay( file_content, file.name, report_year, report_quarter, + str(report_upload.id), ) return Response( @@ -80,6 +88,7 @@ class FormF3UploadView(APIView): report_year=report_year, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) @@ -90,6 +99,7 @@ class FormF3UploadView(APIView): report_year=report_year, report_quarter=report_quarter, ) + ReportUploadService.finalize(report_upload.id, result) result_serializer = FormF3ParseResultSerializer(result) return Response( @@ -98,10 +108,12 @@ class FormF3UploadView(APIView): report_year=report_year, status="done", result=result_serializer.data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-3") return build_upload_error_response( error_code="processing_error", diff --git a/src/apps/form_3/tasks.py b/src/apps/form_3/tasks.py index 1423678..fd47a69 100644 --- a/src/apps/form_3/tasks.py +++ b/src/apps/form_3/tasks.py @@ -7,6 +7,7 @@ Celery задачи для формы Ф-3. import logging +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_3.services import FormF3Parser from celery import shared_task @@ -21,6 +22,7 @@ def process_form_f3_file( file_name: str, report_year: int, report_quarter: int | None = None, + report_upload_id: str | None = None, ) -> dict: """ Фоновая обработка файла формы Ф-3. @@ -37,8 +39,17 @@ def process_form_f3_file( logger.info(f"Начало обработки файла Ф-3: {file_name}") file_io = BytesIO(file_content) + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) parser = FormF3Parser(report_year=report_year, report_quarter=report_quarter) - result = parser.parse(file_io) + try: + result = parser.parse(file_io) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) + except Exception: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) + raise logger.info( f"Обработка Ф-3 завершена: загружено {result.loaded_count}, " diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py index d84e9ab..5252efc 100644 --- a/src/apps/form_4/api.py +++ b/src/apps/form_4/api.py @@ -2,6 +2,7 @@ import logging +from apps.core.report_uploads import ReportUploadService from apps.core.upload_contracts import ( build_upload_error_response, build_upload_success_payload, @@ -49,13 +50,21 @@ class FormF4UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_half_year = serializer.validated_data["report_half_year"] + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f4", + user=request.user, + ) if file.size > BACKGROUND_THRESHOLD: + ReportUploadService.mark_queued(report_upload.id) task = process_form_f4_file.delay( file.read(), file.name, report_year, report_half_year, + None, + str(report_upload.id), ) return Response( build_upload_success_payload( @@ -64,6 +73,7 @@ class FormF4UploadView(APIView): report_half_year=report_half_year, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) @@ -74,6 +84,7 @@ class FormF4UploadView(APIView): report_year=report_year, report_half_year=report_half_year, ) + ReportUploadService.finalize(report_upload.id, result) return Response( build_upload_success_payload( form="f4", @@ -81,10 +92,12 @@ class FormF4UploadView(APIView): report_half_year=report_half_year, status="done", result=FormF4ParseResultSerializer(result).data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-4") return build_upload_error_response( error_code="processing_error", diff --git a/src/apps/form_4/tasks.py b/src/apps/form_4/tasks.py index bb8e91d..dc4582c 100644 --- a/src/apps/form_4/tasks.py +++ b/src/apps/form_4/tasks.py @@ -3,6 +3,7 @@ import logging from io import BytesIO +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_4.services import FormF4Parser from celery import shared_task @@ -18,13 +19,23 @@ def process_form_f4_file( report_year: int, report_half_year: int | None = None, report_quarter: int | None = None, + report_upload_id: str | None = None, ) -> dict: logger.info(f"Начало обработки файла Ф-4: {file_name}") + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) parser = FormF4Parser( report_year=report_year, report_half_year=report_half_year or report_quarter, ) - result = parser.parse(BytesIO(file_content)) + try: + result = parser.parse(BytesIO(file_content)) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) + except Exception: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) + raise logger.info( f"Обработка Ф-4 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено" ) diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py index a1d2c59..e7e4784 100644 --- a/src/apps/form_5/api.py +++ b/src/apps/form_5/api.py @@ -2,6 +2,7 @@ import logging +from apps.core.report_uploads import ReportUploadService from apps.core.upload_contracts import ( build_upload_error_response, build_upload_success_payload, @@ -50,13 +51,20 @@ class FormF5UploadView(APIView): report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f5", + user=request.user, + ) if file.size > BACKGROUND_THRESHOLD: + ReportUploadService.mark_queued(report_upload.id) task = process_form_f5_file.delay( file.read(), file.name, report_year, report_quarter, + str(report_upload.id), ) return Response( build_upload_success_payload( @@ -64,6 +72,7 @@ class FormF5UploadView(APIView): report_year=report_year, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) @@ -74,16 +83,19 @@ class FormF5UploadView(APIView): report_year=report_year, report_quarter=report_quarter, ) + ReportUploadService.finalize(report_upload.id, result) return Response( build_upload_success_payload( form="f5", report_year=report_year, status="done", result=FormF5ParseResultSerializer(result).data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-5") return build_upload_error_response( error_code="processing_error", diff --git a/src/apps/form_5/tasks.py b/src/apps/form_5/tasks.py index 1937261..8843d5a 100644 --- a/src/apps/form_5/tasks.py +++ b/src/apps/form_5/tasks.py @@ -3,6 +3,7 @@ import logging from io import BytesIO +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_5.services import FormF5Parser from celery import shared_task @@ -17,10 +18,20 @@ def process_form_f5_file( file_name: str, report_year: int, report_quarter: int | None = None, + report_upload_id: str | None = None, ) -> dict: logger.info(f"Начало обработки файла Ф-5: {file_name}") + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) parser = FormF5Parser(report_year=report_year, report_quarter=report_quarter) - result = parser.parse(BytesIO(file_content)) + try: + result = parser.parse(BytesIO(file_content)) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) + except Exception: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) + raise logger.info( f"Обработка Ф-5 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено" ) diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py index de8d18f..3a35539 100644 --- a/src/apps/form_6/api.py +++ b/src/apps/form_6/api.py @@ -2,6 +2,7 @@ import logging +from apps.core.report_uploads import ReportUploadService from apps.core.upload_contracts import ( build_upload_error_response, build_upload_success_payload, @@ -49,13 +50,20 @@ class FormF6UploadView(APIView): file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") + report_upload = ReportUploadService.create( + uploaded_file=file, + form="f6", + user=request.user, + ) if file.size > BACKGROUND_THRESHOLD: + ReportUploadService.mark_queued(report_upload.id) task = process_form_f6_file.delay( file.read(), file.name, report_year, report_quarter, + str(report_upload.id), ) return Response( build_upload_success_payload( @@ -63,6 +71,7 @@ class FormF6UploadView(APIView): report_year=report_year, status="queued", job_id=task.id, + upload_id=str(report_upload.id), ), status=status.HTTP_202_ACCEPTED, ) @@ -73,16 +82,19 @@ class FormF6UploadView(APIView): report_year=report_year, report_quarter=report_quarter, ) + ReportUploadService.finalize(report_upload.id, result) return Response( build_upload_success_payload( form="f6", report_year=report_year, status="done", result=FormF6ParseResultSerializer(result).data, + upload_id=str(report_upload.id), ), status=status.HTTP_200_OK, ) except Exception as e: + ReportUploadService.mark_failed(report_upload.id) logger.exception("Ошибка обработки файла Ф-6") return build_upload_error_response( error_code="processing_error", diff --git a/src/apps/form_6/tasks.py b/src/apps/form_6/tasks.py index 7c88e57..3fe45f5 100644 --- a/src/apps/form_6/tasks.py +++ b/src/apps/form_6/tasks.py @@ -3,6 +3,7 @@ import logging from io import BytesIO +from apps.core.report_uploads import ReportUploadService from apps.core.tasks import TrackedTask from apps.form_6.services import FormF6Parser from celery import shared_task @@ -17,10 +18,20 @@ def process_form_f6_file( file_name: str, report_year: int, report_quarter: int | None = None, + report_upload_id: str | None = None, ) -> dict: logger.info(f"Начало обработки файла Ф-6: {file_name}") + if report_upload_id is not None: + ReportUploadService.mark_processing(report_upload_id) parser = FormF6Parser(report_year=report_year, report_quarter=report_quarter) - result = parser.parse(BytesIO(file_content)) + try: + result = parser.parse(BytesIO(file_content)) + if report_upload_id is not None: + ReportUploadService.finalize(report_upload_id, result) + except Exception: + if report_upload_id is not None: + ReportUploadService.mark_failed(report_upload_id) + raise logger.info( f"Обработка Ф-6 завершена: {result.loaded_count} загружено, {result.skipped_count} пропущено" ) diff --git a/src/apps/organization/management/__init__.py b/src/apps/organization/management/__init__.py new file mode 100644 index 0000000..4ee6e5a --- /dev/null +++ b/src/apps/organization/management/__init__.py @@ -0,0 +1 @@ +"""Organization management commands.""" diff --git a/src/apps/organization/management/commands/__init__.py b/src/apps/organization/management/commands/__init__.py new file mode 100644 index 0000000..0362fa3 --- /dev/null +++ b/src/apps/organization/management/commands/__init__.py @@ -0,0 +1 @@ +"""Organization command package.""" diff --git a/src/apps/organization/management/commands/delete_test_companies.py b/src/apps/organization/management/commands/delete_test_companies.py new file mode 100644 index 0000000..b98571a --- /dev/null +++ b/src/apps/organization/management/commands/delete_test_companies.py @@ -0,0 +1,104 @@ +"""Delete the fixed Mostovik demonstration companies and related data.""" + +from __future__ import annotations + +import json + +from apps.core.management.commands.base import BaseAppCommand +from apps.core.models import ReportUpload +from apps.organization.models import Organization +from django.db import transaction +from django.db.models import Q + +TEST_COMPANY_COUNT = 20 + + +def _legal_entity_inn(index: int) -> str: + base = f"770900{index:03d}" + weights = (2, 4, 10, 3, 5, 9, 4, 6, 8) + checksum = sum( + int(digit) * weight for digit, weight in zip(base, weights, strict=True) + ) + return f"{base}{checksum % 11 % 10}" + + +TEST_COMPANY_IDENTITIES = tuple( + (f"Тестовая компания {index}", _legal_entity_inn(index)) + for index in range(1, TEST_COMPANY_COUNT + 1) +) +DEMO_REPORT_FILE_NAMES = ( + "Ф-1_2025-09.xlsx", + "Ф-1_2025-12.xlsx", + "Ф-1_2026-03.xlsx", + "Ф-1_2026-06.xlsx", + "Ф-2_2025-Q3.xlsx", + "Ф-2_2025-Q4.xlsx", + "Ф-2_2026-Q1.xlsx", + "Ф-2_2026-Q2.xlsx", + "Ф-3_2022.xlsx", + "Ф-3_2023.xlsx", + "Ф-3_2024.xlsx", + "Ф-3_2025.xlsx", + "Ф-4_2024-H2.xlsx", + "Ф-4_2025-H1.xlsx", + "Ф-4_2025-H2.xlsx", + "Ф-4_2026-H1.xlsx", + "Ф-5_2022.xlsx", + "Ф-5_2023.xlsx", + "Ф-5_2024.xlsx", + "Ф-5_2025.xlsx", + "Ф-6_2022.xlsx", + "Ф-6_2023.xlsx", + "Ф-6_2024.xlsx", + "Ф-6_2025.xlsx", +) + + +class Command(BaseAppCommand): + """Delete only the deterministic companies imported from Mostovik.""" + + help = "Удаляет 20 тестовых компаний Mostovik и все связанные данные" + use_transaction = True + + def execute_command(self, *args, **options) -> str: + report_uploads = list( + ReportUpload.objects.filter(file_name__in=DEMO_REPORT_FILE_NAMES) + ) + stored_files = [ + (upload.original_file.storage, upload.original_file.name) + for upload in report_uploads + if upload.original_file and upload.original_file.name + ] + report_uploads_deleted = len(report_uploads) + ReportUpload.objects.filter( + pk__in=[item.pk for item in report_uploads] + ).delete() + for storage, file_name in stored_files: + transaction.on_commit( + lambda storage=storage, file_name=file_name: storage.delete(file_name) + ) + + identity_query = Q() + for name, inn in TEST_COMPANY_IDENTITIES: + identity_query |= Q(name=name, inn=inn) + + queryset = Organization.objects.filter(identity_query) + organizations_deleted = queryset.count() + total_deleted, deleted_by_model = queryset.delete() + organization_label = Organization._meta.label + related_objects_deleted = total_deleted - deleted_by_model.get( + organization_label, 0 + ) + + rendered = json.dumps( + { + "dry_run": self.dry_run, + "organizations_deleted": organizations_deleted, + "report_uploads_deleted": report_uploads_deleted, + "related_objects_deleted": related_objects_deleted, + }, + ensure_ascii=False, + sort_keys=True, + ) + self.log_success(rendered) + return rendered diff --git a/src/core/api_v1_urls.py b/src/core/api_v1_urls.py index 37acc04..305b0d6 100644 --- a/src/core/api_v1_urls.py +++ b/src/core/api_v1_urls.py @@ -4,6 +4,7 @@ API v1 URL configuration. Все API эндпоинты версионированы под /api/v1/ """ +from apps.core.report_uploads import ReportUploadDownloadView from apps.core.views import ( BackgroundJobListView, BackgroundJobStatusView, @@ -34,4 +35,9 @@ urlpatterns = [ path("forms/f4/", include("apps.form_4.urls")), path("forms/f5/", include("apps.form_5.urls")), path("forms/f6/", include("apps.form_6.urls")), + path( + "forms//uploads//download/", + ReportUploadDownloadView.as_view(), + name="report-upload-download", + ), ] diff --git a/tests/apps/exchange/test_api.py b/tests/apps/exchange/test_api.py index 44c48bc..10366cb 100644 --- a/tests/apps/exchange/test_api.py +++ b/tests/apps/exchange/test_api.py @@ -371,6 +371,7 @@ class ExchangePackageApiTest(APITestCase): def setUp(self): self.url = reverse("api_v1:exchange:package-upload") + self.latest_url = reverse("api_v1:exchange:package-latest") password = get_random_string(16) self.user = User.objects.create_user( username="exchange-admin", @@ -378,6 +379,55 @@ class ExchangePackageApiTest(APITestCase): password=password, ) + def test_latest_requires_authentication(self): + response = self.client.get(self.latest_url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_latest_returns_null_when_no_packages_exist(self): + self.client.force_authenticate(self.user) + + response = self.client.get(self.latest_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + {"success": True, "data": None, "errors": None, "meta": None}, + ) + + def test_latest_returns_newest_package_metadata(self): + older = ExchangePackageImport.objects.create( + package_id="older-package", + package_name="older.zip", + package_hash="a" * 64, + source_system="mostovik", + schema_version=1, + delivery_channel=ExchangeDeliveryChannel.API, + status="failed", + ) + latest = ExchangePackageImport.objects.create( + package_id="latest-package", + package_name="latest.zip", + package_hash="b" * 64, + source_system="mostovik", + schema_version=1, + delivery_channel=ExchangeDeliveryChannel.API, + status="success", + ) + ExchangePackageImport.objects.filter(pk=older.pk).update( + created_at=older.created_at.replace(year=older.created_at.year - 1) + ) + self.client.force_authenticate(self.user) + + response = self.client.get(self.latest_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["data"]["id"], str(latest.id)) + self.assertEqual(response.data["data"]["package_id"], "latest-package") + self.assertEqual(response.data["data"]["package_name"], "latest.zip") + self.assertEqual(response.data["data"]["status"], "success") + self.assertIn("created_at", response.data["data"]) + def test_upload_imports_package_and_upserts_models(self): Organization.objects.create( inn="7707083893", diff --git a/tests/apps/forms/test_report_uploads_api.py b/tests/apps/forms/test_report_uploads_api.py new file mode 100644 index 0000000..3d5f371 --- /dev/null +++ b/tests/apps/forms/test_report_uploads_api.py @@ -0,0 +1,103 @@ +"""Tests for persistent original report uploads and binary downloads.""" + +import hashlib +import tempfile + +from apps.core.models import ReportUpload, ReportUploadStatus +from apps.core.report_uploads import ReportUploadService +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.user.factories import UserFactory + + +class ReportUploadApiTest(APITestCase): + def setUp(self): + self.media_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.media_directory.cleanup) + self.override = override_settings(MEDIA_ROOT=self.media_directory.name) + self.override.enable() + self.addCleanup(self.override.disable) + self.user = UserFactory.create_user() + + def _create_upload(self, *, content=b"original excel bytes") -> ReportUpload: + return ReportUploadService.create( + uploaded_file=SimpleUploadedFile( + "Ф-1 отчет.xlsx", + content, + content_type="application/octet-stream", + ), + form="f1", + user=self.user, + ) + + def test_service_persists_original_bytes_and_finalizes_batch(self): + content = b"same bytes that arrived" + report_upload = self._create_upload(content=content) + + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + report_upload.refresh_from_db() + + self.assertEqual(report_upload.load_batch, 42) + self.assertEqual(report_upload.status, ReportUploadStatus.SUCCESS) + self.assertEqual(report_upload.file_hash, hashlib.sha256(content).hexdigest()) + self.assertEqual(report_upload.original_file.read(), content) + self.assertEqual(report_upload.file_name, "Ф-1 отчет.xlsx") + self.assertEqual(report_upload.uploaded_by, self.user) + + def test_download_requires_authentication(self): + report_upload = self._create_upload() + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + + response = self.client.get( + reverse("api_v1:report-upload-download", args=["f1", 42]) + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_download_streams_original_bytes_and_filename(self): + content = b"unchanged original xlsx bytes" + report_upload = self._create_upload(content=content) + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + self.client.force_authenticate(self.user) + + response = self.client.get( + reverse("api_v1:report-upload-download", args=["f1", 42]) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(b"".join(response.streaming_content), content) + self.assertEqual( + response["Content-Type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + self.assertIn("attachment", response["Content-Disposition"]) + self.assertIn("utf-8", response["Content-Disposition"].lower()) + + def test_download_rejects_a_different_non_staff_user(self): + report_upload = self._create_upload() + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + self.client.force_authenticate(UserFactory.create_user()) + + response = self.client.get( + reverse("api_v1:report-upload-download", args=["f1", 42]) + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data["errors"][0]["code"], "upload_forbidden") + + def test_download_returns_gone_when_storage_object_was_removed(self): + report_upload = self._create_upload() + ReportUploadService.finalize(report_upload.id, {"batch_id": 42}) + report_upload.original_file.storage.delete(report_upload.original_file.name) + self.client.force_authenticate(self.user) + + response = self.client.get( + reverse("api_v1:report-upload-download", args=["f1", 42]) + ) + + self.assertEqual(response.status_code, status.HTTP_410_GONE) + self.assertEqual(response.data["errors"][0]["code"], "source_file_gone") diff --git a/tests/apps/forms/test_upload_contracts_api.py b/tests/apps/forms/test_upload_contracts_api.py index 3a9f71b..22dc194 100644 --- a/tests/apps/forms/test_upload_contracts_api.py +++ b/tests/apps/forms/test_upload_contracts_api.py @@ -2,10 +2,12 @@ from __future__ import annotations +import tempfile from datetime import datetime from types import SimpleNamespace from unittest.mock import Mock, patch +from apps.core.models import ReportUpload, ReportUploadStatus from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from rest_framework import status @@ -89,6 +91,11 @@ class FormUploadContractsApiTest(APITestCase): } def setUp(self): + self.media_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.media_directory.cleanup) + self.media_override = override_settings(MEDIA_ROOT=self.media_directory.name) + self.media_override.enable() + self.addCleanup(self.media_override.disable) self.user = UserFactory.create_user() self.client.force_authenticate(self.user) @@ -172,6 +179,23 @@ class FormUploadContractsApiTest(APITestCase): ) parse_mock.assert_not_called() task_mock.delay.assert_called_once() + task_args, task_kwargs = task_mock.delay.call_args + self.assertIn( + response.data["upload_id"], + (*task_args, *task_kwargs.values()), + ) + report_upload = ReportUpload.objects.get( + pk=response.data["upload_id"] + ) + self.assertEqual(report_upload.form, case["form"]) + self.assertEqual( + report_upload.status, ReportUploadStatus.QUEUED + ) + self.assertIsNone(report_upload.load_batch) + self.assertEqual( + report_upload.original_file.read(), + b"0" * self.BACKGROUND_THRESHOLD_PLUS, + ) def test_upload_sync_contract(self): for _, case in self.CASES.items(): @@ -201,6 +225,13 @@ class FormUploadContractsApiTest(APITestCase): ) parse_mock.assert_called_once() task_mock.delay.assert_not_called() + report_upload = ReportUpload.objects.get( + pk=response.data["upload_id"] + ) + self.assertEqual(report_upload.form, case["form"]) + self.assertEqual(report_upload.load_batch, 101) + self.assertEqual(report_upload.status, ReportUploadStatus.SUCCESS) + self.assertEqual(report_upload.original_file.read(), b"0" * 256) def test_upload_processing_error_contract(self): for _, case in self.CASES.items(): @@ -219,3 +250,7 @@ class FormUploadContractsApiTest(APITestCase): self.assertEqual(response.data["error_message"], "parse failed") self.assertEqual(response.data["details"], []) parse_mock.assert_called_once() + report_upload = ReportUpload.objects.filter(form=case["form"]).latest( + "created_at" + ) + self.assertEqual(report_upload.status, ReportUploadStatus.FAILED) diff --git a/tests/apps/organization/test_delete_test_companies_command.py b/tests/apps/organization/test_delete_test_companies_command.py new file mode 100644 index 0000000..d8d777c --- /dev/null +++ b/tests/apps/organization/test_delete_test_companies_command.py @@ -0,0 +1,107 @@ +"""Tests for deleting the fixed Mostovik demonstration companies.""" + +from __future__ import annotations + +import json +from io import StringIO + +from apps.core.models import ReportUpload +from apps.core.report_uploads import ReportUploadService +from apps.external_data.models import IndustrialProduct +from apps.form_1.models import FormF1Record +from apps.organization.models import Organization +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.test import TestCase + + +class DeleteTestCompaniesCommandTest(TestCase): + """The cleanup command must be narrow, cascading, and idempotent.""" + + def test_deletes_only_exact_test_identities_with_related_data(self): + first = Organization.objects.create( + name="Тестовая компания 1", + inn="7709000010", + ) + second = Organization.objects.create( + name="Тестовая компания 2", + inn="7709000028", + ) + same_pattern_but_not_test_identity = Organization.objects.create( + name="Тестовая компания 99", + inn="7709000099", + ) + same_inn_pattern_but_real_name = Organization.objects.create( + name="Производственная компания", + inn="7709000035", + ) + FormF1Record.objects.create( + organization=first, + load_batch=1, + report_year=2025, + report_month=9, + ) + IndustrialProduct.objects.create( + organization=second, + product_name="Тестовый комплекс", + product_class="Оборудование", + ) + demo_upload = ReportUploadService.create( + uploaded_file=SimpleUploadedFile("Ф-1_2025-09.xlsx", b"demo"), + form="f1", + ) + demo_file_name = demo_upload.original_file.name + demo_storage = demo_upload.original_file.storage + unrelated_upload = ReportUploadService.create( + uploaded_file=SimpleUploadedFile("Ф-1.xlsx", b"unrelated"), + form="f1", + ) + + stdout = StringIO() + with self.captureOnCommitCallbacks(execute=True): + result = call_command("delete_test_companies", stdout=stdout) + payload = json.loads(result) + + self.assertEqual(payload["organizations_deleted"], 2) + self.assertEqual(payload["related_objects_deleted"], 2) + self.assertEqual(payload["report_uploads_deleted"], 1) + self.assertFalse(Organization.objects.filter(pk__in=[first.pk, second.pk])) + self.assertTrue( + Organization.objects.filter(pk=same_pattern_but_not_test_identity.pk) + ) + self.assertTrue( + Organization.objects.filter(pk=same_inn_pattern_but_real_name.pk) + ) + self.assertFalse(FormF1Record.objects.exists()) + self.assertFalse(IndustrialProduct.objects.exists()) + self.assertFalse(ReportUpload.objects.filter(pk=demo_upload.pk).exists()) + self.assertFalse(demo_storage.exists(demo_file_name)) + self.assertTrue(ReportUpload.objects.filter(pk=unrelated_upload.pk).exists()) + + repeated = json.loads( + call_command("delete_test_companies", stdout=StringIO()) + ) + self.assertEqual(repeated["organizations_deleted"], 0) + self.assertEqual(repeated["related_objects_deleted"], 0) + self.assertEqual(repeated["report_uploads_deleted"], 0) + + def test_dry_run_preserves_test_companies(self): + organization = Organization.objects.create( + name="Тестовая компания 1", + inn="7709000010", + ) + demo_upload = ReportUploadService.create( + uploaded_file=SimpleUploadedFile("Ф-1_2025-09.xlsx", b"demo"), + form="f1", + ) + + result = call_command( + "delete_test_companies", + dry_run=True, + stdout=StringIO(), + ) + + self.assertTrue(Organization.objects.filter(pk=organization.pk).exists()) + self.assertTrue(ReportUpload.objects.filter(pk=demo_upload.pk).exists()) + self.assertTrue(demo_upload.original_file.storage.exists(demo_upload.original_file.name)) + self.assertTrue(json.loads(result)["dry_run"])