init: new structure + fix lint errors
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Files module - file management and storage operations.
|
||||
"""
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from cpv3.db.base import Base, BaseModelMixin
|
||||
|
||||
|
||||
class File(Base, BaseModelMixin):
|
||||
__tablename__ = "files"
|
||||
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
original_filename: Mapped[str] = mapped_column(String(255), default="")
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
|
||||
storage_backend: Mapped[str] = mapped_column(String(16), default="S3")
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
file_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
is_uploaded: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.files.models import File
|
||||
from cpv3.modules.files.schemas import FileCreate, FileUpdate
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
class FileRepository:
|
||||
"""Repository for File database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all(self, *, requester: User) -> list[File]:
|
||||
stmt: Select[tuple[File]] = select(File).where(File.is_deleted.is_(False))
|
||||
if not requester.is_staff:
|
||||
stmt = stmt.where(File.owner_id == requester.id)
|
||||
|
||||
result = await self._session.execute(stmt.order_by(File.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_id(self, file_id: uuid.UUID) -> File | None:
|
||||
result = await self._session.execute(select(File).where(File.id == file_id))
|
||||
file = result.scalar_one_or_none()
|
||||
if file is None:
|
||||
return None
|
||||
if file.is_deleted:
|
||||
return None
|
||||
return file
|
||||
|
||||
async def create(self, *, requester: User, data: FileCreate) -> File:
|
||||
file = File(
|
||||
owner_id=requester.id,
|
||||
project_id=data.project_id,
|
||||
original_filename=data.original_filename,
|
||||
path=data.path,
|
||||
storage_backend=data.storage_backend,
|
||||
mime_type=data.mime_type,
|
||||
size_bytes=data.size_bytes,
|
||||
checksum=data.checksum,
|
||||
file_format=data.file_format,
|
||||
is_uploaded=data.is_uploaded,
|
||||
)
|
||||
|
||||
self._session.add(file)
|
||||
await self._session.commit()
|
||||
await self._session.refresh(file)
|
||||
return file
|
||||
|
||||
async def update(self, file: File, data: FileUpdate) -> File:
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
if value is not None:
|
||||
setattr(file, key, value)
|
||||
|
||||
await self._session.commit()
|
||||
await self._session.refresh(file)
|
||||
return file
|
||||
|
||||
async def mark_deleted(self, file: File) -> None:
|
||||
file.is_deleted = True
|
||||
await self._session.commit()
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File as FastAPIFile,
|
||||
Form,
|
||||
HTTPException,
|
||||
Query,
|
||||
Response,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.infrastructure.auth import get_current_user
|
||||
from cpv3.infrastructure.deps import get_storage
|
||||
from cpv3.infrastructure.storage.base import StorageService
|
||||
from cpv3.infrastructure.settings import get_settings
|
||||
from cpv3.db.session import get_db
|
||||
from cpv3.modules.files.schemas import (
|
||||
FileCreate,
|
||||
FileInfoResponse,
|
||||
FileRead,
|
||||
FileUpdate,
|
||||
)
|
||||
from cpv3.modules.files.service import FileService
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["Files"])
|
||||
|
||||
MAX_MB_SIZE = 100
|
||||
|
||||
|
||||
@router.post(
|
||||
"/upload/", response_model=FileInfoResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def upload_file(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
folder: str = Form(default=""),
|
||||
current_user: User = Depends(get_current_user),
|
||||
storage: StorageService = Depends(get_storage),
|
||||
) -> FileInfoResponse:
|
||||
_ = current_user
|
||||
|
||||
# Validate max file size (matches old behavior).
|
||||
file.file.seek(0, 2)
|
||||
size_bytes = file.file.tell()
|
||||
file.file.seek(0)
|
||||
|
||||
max_size = MAX_MB_SIZE * 1024 * 1024
|
||||
if size_bytes > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds the maximum limit of {MAX_MB_SIZE} MB.",
|
||||
)
|
||||
|
||||
key = await storage.upload_fileobj(
|
||||
fileobj=file.file,
|
||||
file_name=file.filename or "upload.bin",
|
||||
folder=folder,
|
||||
gen_name=True,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
|
||||
info = await storage.get_file_info(key)
|
||||
return FileInfoResponse(
|
||||
file_path=info.file_path,
|
||||
file_url=info.file_url,
|
||||
file_size=info.file_size,
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/get_file/", response_model=FileInfoResponse)
|
||||
async def get_file_info(
|
||||
file_path: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
storage: StorageService = Depends(get_storage),
|
||||
) -> FileInfoResponse:
|
||||
_ = current_user
|
||||
|
||||
if not await storage.exists(file_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
info = await storage.get_file_info(file_path)
|
||||
return FileInfoResponse(
|
||||
file_path=info.file_path,
|
||||
file_url=info.file_url,
|
||||
file_size=info.file_size,
|
||||
filename=info.filename,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/local/{file_path:path}")
|
||||
async def get_local_file(
|
||||
file_path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
_ = current_user
|
||||
|
||||
settings = get_settings()
|
||||
full_path = (settings.local_storage_dir / file_path).resolve()
|
||||
if not full_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
@router.get("/files/", response_model=list[FileRead])
|
||||
async def list_file_entries(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[FileRead]:
|
||||
service = FileService(db)
|
||||
files = await service.list_files(requester=current_user)
|
||||
return [FileRead.model_validate(f) for f in files]
|
||||
|
||||
|
||||
@router.post("/files/", response_model=FileRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_file_entry(
|
||||
body: FileCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileRead:
|
||||
service = FileService(db)
|
||||
file = await service.create_file(requester=current_user, data=body)
|
||||
return FileRead.model_validate(file)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/", response_model=FileRead)
|
||||
async def retrieve_file_entry(
|
||||
file_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileRead:
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
return FileRead.model_validate(file)
|
||||
|
||||
|
||||
@router.patch("/files/{file_id}/", response_model=FileRead)
|
||||
async def patch_file_entry(
|
||||
file_id: uuid.UUID,
|
||||
body: FileUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileRead:
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
file = await service.update_file(file, body)
|
||||
return FileRead.model_validate(file)
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_file_entry(
|
||||
file_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
await service.delete_file(file)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from cpv3.common.schemas import Schema
|
||||
|
||||
|
||||
StorageBackendEnum = Literal["LOCAL", "S3"]
|
||||
|
||||
|
||||
class FileRead(Schema):
|
||||
id: UUID
|
||||
project_id: UUID | None
|
||||
owner_id: UUID | None
|
||||
|
||||
original_filename: str
|
||||
path: str
|
||||
storage_backend: StorageBackendEnum
|
||||
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
checksum: str | None
|
||||
file_format: str | None
|
||||
|
||||
is_uploaded: bool
|
||||
is_deleted: bool
|
||||
is_active: bool
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FileCreate(Schema):
|
||||
project_id: UUID | None = None
|
||||
original_filename: str
|
||||
path: str
|
||||
storage_backend: StorageBackendEnum = "S3"
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
checksum: str | None = None
|
||||
file_format: str | None = None
|
||||
is_uploaded: bool = False
|
||||
|
||||
|
||||
class FileUpdate(Schema):
|
||||
original_filename: str | None = None
|
||||
is_uploaded: bool | None = None
|
||||
is_deleted: bool | None = None
|
||||
|
||||
|
||||
class FileInfoResponse(Schema):
|
||||
file_path: str
|
||||
file_url: str
|
||||
file_size: int | None = None
|
||||
filename: str | None = None
|
||||
|
||||
|
||||
class FileParam(Schema):
|
||||
file_path: str
|
||||
|
||||
|
||||
class FileUploadForm(Schema):
|
||||
folder: str = ""
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.files.models import File
|
||||
from cpv3.modules.files.repository import FileRepository
|
||||
from cpv3.modules.files.schemas import FileCreate, FileUpdate
|
||||
from cpv3.modules.users.models import User
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for file business logic and orchestration."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._repo = FileRepository(session)
|
||||
|
||||
async def list_files(self, *, requester: User) -> list[File]:
|
||||
return await self._repo.list_all(requester=requester)
|
||||
|
||||
async def get_file(self, file_id: uuid.UUID) -> File | None:
|
||||
return await self._repo.get_by_id(file_id)
|
||||
|
||||
async def create_file(self, *, requester: User, data: FileCreate) -> File:
|
||||
return await self._repo.create(requester=requester, data=data)
|
||||
|
||||
async def update_file(self, file: File, data: FileUpdate) -> File:
|
||||
return await self._repo.update(file, data)
|
||||
|
||||
async def delete_file(self, file: File) -> None:
|
||||
await self._repo.mark_deleted(file)
|
||||
Reference in New Issue
Block a user