298 lines
9.9 KiB
Python
298 lines
9.9 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
|
from govoplan_core.audit.logging import audit_from_principal
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_files.backend.route_support import (
|
|
_asset_response,
|
|
_connector_space_response,
|
|
_http_error,
|
|
_is_admin,
|
|
)
|
|
from govoplan_files.backend.schemas import (
|
|
FileAssetResponse,
|
|
FileBlobGcRequest,
|
|
FileBlobGcResponse,
|
|
FileConnectorSpaceResponse,
|
|
FileFolderDeleteRequest,
|
|
FileFolderRestoreResponse,
|
|
FileLifecycleUpdateRequest,
|
|
FilePurgeExecuteRequest,
|
|
FilePurgePreviewItem,
|
|
FilePurgePreviewRequest,
|
|
FilePurgePreviewResponse,
|
|
FilePurgeResponse,
|
|
FileRestoreResponse,
|
|
)
|
|
from govoplan_files.backend.storage.common import FileStorageError
|
|
from govoplan_files.backend.storage.lifecycle import (
|
|
execute_asset_purge,
|
|
garbage_collect_unreferenced_blobs,
|
|
get_asset_for_lifecycle,
|
|
preview_asset_purge,
|
|
restore_asset,
|
|
restore_connector_space,
|
|
restore_folder,
|
|
set_asset_lifecycle,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/files", tags=["files-lifecycle"])
|
|
|
|
|
|
@router.patch("/{file_id}/lifecycle", response_model=FileAssetResponse)
|
|
def update_file_lifecycle(
|
|
file_id: str,
|
|
payload: FileLifecycleUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:retention")),
|
|
):
|
|
try:
|
|
asset = get_asset_for_lifecycle(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
set_asset_lifecycle(
|
|
session,
|
|
asset,
|
|
retained_until=payload.retained_until,
|
|
legal_hold=payload.legal_hold,
|
|
reason=payload.reason,
|
|
expected_revision=payload.expected_revision,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.lifecycle.updated",
|
|
object_type="file",
|
|
object_id=asset.id,
|
|
details={
|
|
"retained_until": (
|
|
asset.retained_until.isoformat() if asset.retained_until else None
|
|
),
|
|
"legal_hold": asset.legal_hold,
|
|
"lifecycle_revision": asset.lifecycle_revision,
|
|
"reason": asset.lifecycle_reason,
|
|
},
|
|
)
|
|
session.commit()
|
|
return _asset_response(session, asset, include_shares=True)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc, not_found=True) from exc
|
|
|
|
|
|
@router.post("/assets/{file_id}/restore", response_model=FileRestoreResponse)
|
|
def restore_file(
|
|
file_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:restore")),
|
|
):
|
|
try:
|
|
asset = get_asset_for_lifecycle(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
asset_id=file_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
changed = restore_asset(session, asset)
|
|
if changed:
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.file.restored",
|
|
object_type="file",
|
|
object_id=asset.id,
|
|
details={"display_path": asset.display_path},
|
|
)
|
|
session.commit()
|
|
return FileRestoreResponse(restored_count=1 if changed else 0)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc, not_found=True) from exc
|
|
|
|
|
|
@router.post("/folders/restore", response_model=FileFolderRestoreResponse)
|
|
def restore_file_folder(
|
|
payload: FileFolderDeleteRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:restore")),
|
|
):
|
|
try:
|
|
restored_folders, restored_files = restore_folder(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
owner_type=payload.owner_type,
|
|
owner_id=payload.owner_id,
|
|
user_id=principal.user.id,
|
|
path=payload.path,
|
|
recursive=payload.recursive,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.folder.restored",
|
|
object_type="file_folder",
|
|
object_id=payload.path,
|
|
details={
|
|
"restored_folders": restored_folders,
|
|
"restored_files": restored_files,
|
|
},
|
|
)
|
|
session.commit()
|
|
return FileFolderRestoreResponse(
|
|
restored_folders=restored_folders, restored_files=restored_files
|
|
)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc, not_found=True) from exc
|
|
|
|
|
|
@router.post(
|
|
"/connector-spaces/{space_id}/restore",
|
|
response_model=FileConnectorSpaceResponse,
|
|
)
|
|
def restore_file_connector_space(
|
|
space_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:restore")),
|
|
):
|
|
try:
|
|
space = restore_connector_space(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
space_id=space_id,
|
|
is_admin=_is_admin(principal),
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.connector_space.restored",
|
|
object_type="file_connector_space",
|
|
object_id=space.id,
|
|
details={"label": space.label},
|
|
)
|
|
session.commit()
|
|
return _connector_space_response(space)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc, not_found=True) from exc
|
|
|
|
|
|
@router.post("/purge/preview", response_model=FilePurgePreviewResponse)
|
|
def preview_file_purge(
|
|
payload: FilePurgePreviewRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:purge")),
|
|
):
|
|
try:
|
|
preview = preview_asset_purge(
|
|
session, tenant_id=principal.tenant_id, file_ids=payload.file_ids
|
|
)
|
|
return FilePurgePreviewResponse(
|
|
preview_sha256=preview.preview_sha256,
|
|
eligible=preview.eligible,
|
|
items=[
|
|
FilePurgePreviewItem(
|
|
file_id=item.file_id,
|
|
filename=item.filename,
|
|
lifecycle_revision=item.lifecycle_revision,
|
|
deleted_at=(item.deleted_at.isoformat() if item.deleted_at else None),
|
|
retained_until=(
|
|
item.retained_until.isoformat() if item.retained_until else None
|
|
),
|
|
legal_hold=item.legal_hold,
|
|
blockers=list(item.blockers),
|
|
blob_ids=list(item.blob_ids),
|
|
)
|
|
for item in preview.items
|
|
],
|
|
)
|
|
except FileStorageError as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post("/purge/execute", response_model=FilePurgeResponse)
|
|
def execute_file_purge(
|
|
payload: FilePurgeExecuteRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:purge")),
|
|
):
|
|
try:
|
|
result = execute_asset_purge(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
file_ids=payload.file_ids,
|
|
preview_sha256=payload.preview_sha256,
|
|
idempotency_key=payload.idempotency_key,
|
|
approval_reference=payload.approval_reference,
|
|
before_commit=lambda assets: audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.files.purged",
|
|
object_type="file_batch",
|
|
object_id=payload.preview_sha256,
|
|
details={
|
|
"file_ids": [asset.id for asset in assets],
|
|
"approval_reference": payload.approval_reference,
|
|
"preview_sha256": payload.preview_sha256,
|
|
},
|
|
),
|
|
)
|
|
return FilePurgeResponse(
|
|
recovery_operation_id=result.recovery_operation_id,
|
|
status=result.status,
|
|
replayed=result.replayed,
|
|
purged_files=result.purged_files,
|
|
released_blobs=result.released_blobs,
|
|
)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post("/purge/blobs", response_model=FileBlobGcResponse)
|
|
def garbage_collect_file_blobs(
|
|
payload: FileBlobGcRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("files:file:purge")),
|
|
):
|
|
try:
|
|
result = garbage_collect_unreferenced_blobs(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
limit=payload.limit,
|
|
approval_reference=payload.approval_reference,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="files.blobs.garbage_collected",
|
|
object_type="file_blob_batch",
|
|
object_id=principal.tenant_id,
|
|
details={
|
|
"inspected_blobs": result.inspected_blobs,
|
|
"deleted_blobs": result.deleted_blobs,
|
|
"unresolved_operation_ids": list(result.unresolved_operation_ids),
|
|
"approval_reference": payload.approval_reference,
|
|
},
|
|
commit=True,
|
|
)
|
|
return FileBlobGcResponse(
|
|
inspected_blobs=result.inspected_blobs,
|
|
deleted_blobs=result.deleted_blobs,
|
|
unresolved_operation_ids=list(result.unresolved_operation_ids),
|
|
)
|
|
except FileStorageError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|