feat: add demo report import workflow
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 2m44s
CI/CD Pipeline / Run Tests (push) Successful in 2m50s
CI/CD Pipeline / Build and Push Dev Images (push) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (push) Has been skipped

This commit is contained in:
2026-07-16 12:59:23 +02:00
parent 46c023f75a
commit 0c052c5a53
58 changed files with 968 additions and 10 deletions

View File

@@ -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",

View File

@@ -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")

View File

@@ -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)

View File

@@ -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"])