feat: expand platform APIs, sources, and test coverage
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Telegram Notify Success (push) Has been cancelled
CI/CD Pipeline / Run Tests (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 2m54s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-17 12:56:48 +01:00
parent b505c67968
commit 3d298ce352
101 changed files with 8387 additions and 292 deletions

View File

@@ -1,2 +1 @@
"""Приложение экспорта защищённых резервных архивов."""

View File

@@ -36,4 +36,3 @@ class BackupExportJobAdmin(admin.ModelAdmin):
"updated_at",
]
ordering = ["-actual_date", "-created_at"]

View File

@@ -10,4 +10,3 @@ class BackupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.backups"
verbose_name = _("Резервные копии")

View File

@@ -92,4 +92,3 @@ class BackupExportJob(TimestampMixin, models.Model):
def __str__(self) -> str:
return f"Backup {self.actual_date} [{self.status}]"

View File

@@ -13,4 +13,3 @@ class BackupExportRequestSerializer(serializers.Serializer):
"Если не передана, используется текущая дата."
),
)

View File

@@ -7,6 +7,7 @@ import hashlib
import json
import os
import struct
import uuid
import zlib
from collections.abc import Iterable
from contextlib import suppress
@@ -144,7 +145,9 @@ class BackupExportService:
started_at__lte=actual_date,
).filter(Q(ended_at__isnull=True) | Q(ended_at__gt=actual_date))
register_ids = list(active_periods.values_list("registry_id", flat=True).distinct())
register_ids = list(
active_periods.values_list("registry_id", flat=True).distinct()
)
upload_ids = list(
active_periods.values_list("started_by_upload_id", flat=True).distinct()
)
@@ -155,9 +158,13 @@ class BackupExportService:
report_ids = list(reports_qs.values_list("id", flat=True))
export_map: dict[type[Model], Iterable] = {
Organization: Organization.objects.filter(id__in=active_org_ids).order_by("id"),
Organization: Organization.objects.filter(id__in=active_org_ids).order_by(
"id"
),
Register: Register.objects.filter(id__in=register_ids).order_by("name"),
RegisterUpload: RegisterUpload.objects.filter(id__in=upload_ids).order_by("id"),
RegisterUpload: RegisterUpload.objects.filter(id__in=upload_ids).order_by(
"id"
),
RegistryMembershipPeriod: active_periods.order_by(
"registry_id",
"organization_id",
@@ -216,7 +223,9 @@ class BackupExportService:
}
if field.is_relation and field.related_model is not None:
on_delete_handler = getattr(field.remote_field.on_delete, "__name__", "unknown")
on_delete_handler = getattr(
field.remote_field.on_delete, "__name__", "unknown"
)
field_meta["related_model"] = field.related_model._meta.label
field_meta["on_delete"] = on_delete_handler
@@ -249,15 +258,14 @@ class BackupExportService:
}
@classmethod
def _serialize_queryset(cls, *, model: type[Model], queryset: Iterable) -> list[dict]:
def _serialize_queryset(
cls, *, model: type[Model], queryset: Iterable
) -> list[dict]:
field_names = [field.attname for field in model._meta.local_fields]
serialized = []
for row in queryset.values(*field_names).iterator(chunk_size=1000):
serialized.append(
{
key: cls._normalize_value(value)
for key, value in row.items()
}
{key: cls._normalize_value(value) for key, value in row.items()}
)
return serialized
@@ -320,7 +328,9 @@ class BackupExportService:
return decoded_key
@classmethod
def _build_bin_container(cls, *, encrypted_payload: bytes, header_payload: dict) -> bytes:
def _build_bin_container(
cls, *, encrypted_payload: bytes, header_payload: dict
) -> bytes:
header = {
"format": "mostovik-backup-bin",
"version": cls.BIN_FORMAT_VERSION,
@@ -375,7 +385,9 @@ class BackupExportJobService:
requested_by_id: int | None,
) -> BackupRequestResult:
job = cls._get_job_for_update(actual_date)
existing_job_result = cls._result_for_existing_job(actual_date=actual_date, job=job)
existing_job_result = cls._result_for_existing_job(
actual_date=actual_date, job=job
)
if existing_job_result is not None:
return existing_job_result
@@ -408,17 +420,27 @@ class BackupExportJobService:
status=BackupExportJob.Status.PENDING,
)
from apps.backups.tasks import generate_backup_for_date
task = generate_backup_for_date.delay(job_id=new_job.id)
new_job.task_id = task.id or ""
task_id = str(uuid.uuid4())
new_job.task_id = task_id
new_job.save(update_fields=["task_id", "updated_at"])
transaction.on_commit(
lambda: cls._enqueue_backup_task(job_id=new_job.id, task_id=task_id)
)
return BackupRequestResult(
action="started",
message="Формирование бэкапа запущено.",
actual_date=actual_date,
task_id=new_job.task_id,
task_id=task_id,
)
@staticmethod
def _enqueue_backup_task(*, job_id: int, task_id: str) -> None:
from apps.backups.tasks import generate_backup_for_date
generate_backup_for_date.apply_async(
kwargs={"job_id": job_id},
task_id=task_id,
)
@classmethod
@@ -431,7 +453,10 @@ class BackupExportJobService:
if job is None:
return None
if job.status in (BackupExportJob.Status.PENDING, BackupExportJob.Status.STARTED):
if job.status in (
BackupExportJob.Status.PENDING,
BackupExportJob.Status.STARTED,
):
return BackupRequestResult(
action="wait",
message="Бэкап формируется, пожалуйста подождите.",
@@ -461,7 +486,9 @@ class BackupExportJobService:
if not cls._archive_exists(job):
job.delete()
raise BackupExportError("Файл бэкапа отсутствует, запустите формирование снова")
raise BackupExportError(
"Файл бэкапа отсутствует, запустите формирование снова"
)
archive_path = Path(job.archive_path)
archive_bytes = archive_path.read_bytes()
@@ -475,7 +502,8 @@ class BackupExportJobService:
archive_filename=archive_filename,
bin_filename="",
checksum_filename=job.checksum_filename,
checksum_sha256=job.checksum_sha256 or hashlib.sha256(archive_bytes).hexdigest(),
checksum_sha256=job.checksum_sha256
or hashlib.sha256(archive_bytes).hexdigest(),
organizations_count=job.organizations_count,
actual_date=job.actual_date,
)

View File

@@ -10,4 +10,3 @@ backups_urlpatterns = [
]
urlpatterns = []

View File

@@ -64,12 +64,16 @@ class BackupExportView(APIView):
def post(self, request):
serializer = BackupExportRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
actual_date = serializer.validated_data.get("actual_date") or timezone.localdate()
actual_date = (
serializer.validated_data.get("actual_date") or timezone.localdate()
)
try:
result = BackupExportJobService.check_or_start_job(
actual_date=actual_date,
requested_by_id=request.user.id if request.user.is_authenticated else None,
requested_by_id=request.user.id
if request.user.is_authenticated
else None,
)
except BackupExportError as exc:
raise ValidationError({"backup": str(exc)}) from exc
@@ -94,9 +98,9 @@ class BackupExportView(APIView):
response = HttpResponse(artifact.archive_bytes, content_type="application/zip")
response.status_code = status.HTTP_200_OK
response["Content-Disposition"] = (
f'attachment; filename="{artifact.archive_filename}"'
)
response[
"Content-Disposition"
] = f'attachment; filename="{artifact.archive_filename}"'
response["X-Backup-SHA256"] = artifact.checksum_sha256
response["X-Backup-Checksum-File"] = artifact.checksum_filename
response["X-Backup-Organizations"] = str(artifact.organizations_count)