Refactoring of services.py; tests

This commit is contained in:
2026-06-13 04:07:46 +02:00
parent f3db5fc5cf
commit 36e9211ee6
15 changed files with 2113 additions and 920 deletions

View File

@@ -1,180 +1,63 @@
from __future__ import annotations
import json
from io import BytesIO
from typing import Any, Literal
from typing import Literal
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import ApiPrincipal, require_scope
from app.api.v1.file_schemas import (
ArchiveRequest,
BulkDeleteRequest,
BulkDeleteResponse,
ConflictResolutionRequest,
FileAssetResponse,
FileFolderCreateRequest,
FileFolderDeleteRequest,
FileFolderDeleteResponse,
FileFolderResponse,
FileFoldersResponse,
FileListResponse,
FileShareRequest,
FileShareResponse,
FileSpaceResponse,
FileSpacesResponse,
FileUploadResponse,
PatternMatchResponse,
PatternResolveRequest,
PatternResolveResponse,
RenamePreviewItem,
RenameRequest,
RenameResponse,
TransferRequest,
TransferResponse,
_conflict_resolutions,
)
from app.db.models import Campaign, FileAsset, FileFolder, FileShare, Group
from app.db.session import get_session
from app.storage.paths import UnsafeFilePathError, filename_from_path, normalize_folder, normalize_logical_path
from app.storage.services import (
FileStorageError,
from app.storage.access import ensure_group_access, user_group_ids
from app.storage.archives import create_zip_bytes, extract_zip_upload
from app.storage.common import FileStorageError
from app.storage.files import (
asset_is_audit_relevant,
build_rename_preview,
create_file_asset,
create_folder,
create_zip_bytes,
current_version_and_blob,
extract_zip_upload,
get_asset_for_user,
list_assets_for_user,
list_folders_for_user,
rename_asset,
resolve_patterns,
read_asset_bytes,
share_file,
soft_delete_assets,
soft_delete_folder,
user_group_ids,
read_asset_bytes,
)
from app.storage.folders import create_folder, list_folders_for_user, soft_delete_folder
from app.storage.search import resolve_patterns
from app.storage.transfers import rename_selection, transfer_selection
router = APIRouter(prefix="/files", tags=["files"])
class FileSpaceResponse(BaseModel):
id: str
label: str
owner_type: Literal["user", "group"]
owner_id: str
description: str | None = None
class FileSpacesResponse(BaseModel):
spaces: list[FileSpaceResponse]
class FileShareResponse(BaseModel):
id: str
target_type: str
target_id: str
permission: str
created_at: str
revoked_at: str | None = None
class FileAssetResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
display_path: str
filename: str
description: str | None = None
size_bytes: int
content_type: str | None = None
checksum_sha256: str
version_id: str
created_at: str
updated_at: str
deleted_at: str | None = None
audit_relevant: bool = False
metadata: dict[str, Any] | None = None
shares: list[FileShareResponse] = Field(default_factory=list)
class FileFolderResponse(BaseModel):
id: str
tenant_id: str
owner_type: str
owner_id: str
path: str
created_at: str
updated_at: str
deleted_at: str | None = None
class FileFoldersResponse(BaseModel):
folders: list[FileFolderResponse]
class FileFolderCreateRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
class FileFolderDeleteRequest(BaseModel):
owner_type: Literal["user", "group"]
owner_id: str
path: str
recursive: bool = True
class FileFolderDeleteResponse(BaseModel):
deleted_folders: int
deleted_files: int
class FileListResponse(BaseModel):
files: list[FileAssetResponse]
class FileUploadResponse(BaseModel):
files: list[FileAssetResponse]
class BulkDeleteRequest(BaseModel):
file_ids: list[str]
class BulkDeleteResponse(BaseModel):
deleted_count: int
class FileShareRequest(BaseModel):
target_type: Literal["user", "group", "campaign", "tenant"]
target_id: str
permission: Literal["read", "write", "manage"] = "read"
class RenameRequest(BaseModel):
file_ids: list[str]
mode: Literal["prefix", "suffix", "replace"]
find: str | None = None
replacement: str = ""
prefix: str = ""
suffix: str = ""
dry_run: bool = True
class RenamePreviewItem(BaseModel):
file_id: str
old_path: str
new_path: str
class RenameResponse(BaseModel):
dry_run: bool
items: list[RenamePreviewItem]
class ArchiveRequest(BaseModel):
file_ids: list[str]
filename: str = "files.zip"
class PatternResolveRequest(BaseModel):
patterns: list[str]
owner_type: Literal["user", "group"] | None = None
owner_id: str | None = None
campaign_id: str | None = None
path_prefix: str | None = None
include_unmatched: bool = True
class PatternMatchResponse(BaseModel):
pattern: str
matches: list[FileAssetResponse]
class PatternResolveResponse(BaseModel):
patterns: list[PatternMatchResponse]
unmatched: list[FileAssetResponse] = Field(default_factory=list)
def _is_admin(principal: ApiPrincipal) -> bool:
@@ -251,8 +134,6 @@ def _ensure_list_owner_access(session: Session, principal: ApiPrincipal, owner_t
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this user file space")
if owner_type == "group" and owner_id:
try:
from app.storage.services import ensure_group_access
ensure_group_access(
session,
tenant_id=principal.tenant_id,
@@ -393,12 +274,16 @@ async def upload_files(
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
unpack_zip: bool = Form(default=False),
conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(default="reject"),
conflict_resolutions_json: str | None = Form(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
target_owner = owner_id or principal.user.id
uploaded_assets: list[FileAsset] = []
try:
raw_resolutions = json.loads(conflict_resolutions_json) if conflict_resolutions_json else []
upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions])
for upload in files:
data = await upload.read()
filename = upload.filename or "file"
@@ -413,6 +298,8 @@ async def upload_files(
zip_data=data,
folder=path,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
conflict_resolutions=upload_resolutions,
is_admin=_is_admin(principal),
)
uploaded_assets.extend(item.asset for item in extracted)
@@ -428,6 +315,8 @@ async def upload_files(
folder=path,
content_type=content_type,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
conflict_resolutions=upload_resolutions,
is_admin=_is_admin(principal),
)
uploaded_assets.append(stored.asset)
@@ -445,12 +334,16 @@ async def upload_zip(
owner_id: str | None = Form(default=None),
path: str = Form(default=""),
campaign_id: str | None = Form(default=None),
conflict_strategy: Literal["reject", "overwrite", "rename"] = Form(default="reject"),
conflict_resolutions_json: str | None = Form(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
data = await file.read()
target_owner = owner_id or principal.user.id
try:
raw_resolutions = json.loads(conflict_resolutions_json) if conflict_resolutions_json else []
upload_resolutions = _conflict_resolutions([ConflictResolutionRequest(**item) for item in raw_resolutions])
extracted = extract_zip_upload(
session,
tenant_id=principal.tenant_id,
@@ -460,6 +353,8 @@ async def upload_zip(
zip_data=data,
folder=path,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
conflict_resolutions=upload_resolutions,
is_admin=_is_admin(principal),
)
session.commit()
@@ -571,25 +466,70 @@ def bulk_rename(
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
assets = [
get_asset_for_user(session, tenant_id=principal.tenant_id, user_id=principal.user.id, asset_id=file_id, require_write=True, is_admin=_is_admin(principal))
for file_id in payload.file_ids
]
previews = [
RenamePreviewItem(
file_id=asset.id,
old_path=asset.display_path,
new_path=normalize_logical_path(build_rename_preview(asset, mode=payload.mode, find=payload.find, replacement=payload.replacement, prefix=payload.prefix, suffix=payload.suffix)),
)
for asset in assets
]
plan = rename_selection(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
file_ids=payload.file_ids,
folder_paths=payload.folder_paths,
owner_type=payload.owner_type,
owner_id=payload.owner_id,
mode=payload.mode,
new_name=payload.new_name,
find=payload.find,
replacement=payload.replacement,
prefix=payload.prefix,
suffix=payload.suffix,
recursive=payload.recursive,
dry_run=payload.dry_run,
is_admin=_is_admin(principal),
)
if not payload.dry_run:
by_id = {asset.id: asset for asset in assets}
for item in previews:
rename_asset(by_id[item.file_id], new_path=item.new_path)
session.add(by_id[item.file_id])
session.commit()
return RenameResponse(dry_run=payload.dry_run, items=previews)
return RenameResponse(
dry_run=payload.dry_run,
items=[
RenamePreviewItem(
kind=item.kind,
id=item.id,
file_id=item.id if item.kind == "file" else None,
folder_path=item.old_path if item.kind == "folder" else None,
old_path=item.old_path,
new_path=item.new_path,
)
for item in plan
],
)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post("/transfer", response_model=TransferResponse)
def transfer_files(
payload: TransferRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("attachments:write")),
):
try:
files, folders = transfer_selection(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
operation=payload.operation,
file_ids=payload.file_ids,
folder_paths=payload.folder_paths,
source_owner_type=payload.source_owner_type,
source_owner_id=payload.source_owner_id,
target_owner_type=payload.target_owner_type,
target_owner_id=payload.target_owner_id,
target_folder=payload.target_folder,
conflict_strategy=payload.conflict_strategy,
conflict_resolutions=_conflict_resolutions(payload.conflict_resolutions),
is_admin=_is_admin(principal),
)
session.commit()
return TransferResponse(operation=payload.operation, files=files, folders=folders)
except (FileStorageError, UnsafeFilePathError, ValueError) as exc:
session.rollback()
raise _http_error(exc) from exc
@@ -632,7 +572,7 @@ def resolve_file_patterns(
path_prefix=payload.path_prefix,
is_admin=_is_admin(principal),
)
resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix)
resolved, unmatched = resolve_patterns(assets, payload.patterns, base_path=payload.path_prefix, case_sensitive=payload.case_sensitive)
return PatternResolveResponse(
patterns=[PatternMatchResponse(pattern=item.pattern, matches=[_asset_response(session, asset) for asset in item.matches]) for item in resolved],
unmatched=[_asset_response(session, asset) for asset in unmatched] if payload.include_unmatched else [],