сделал слои для таблицы файлов проекта

This commit is contained in:
Андрей Дувакин 2025-05-31 11:15:38 +05:00
parent 1ed1731432
commit 9d1d050746
8 changed files with 279 additions and 1 deletions

View File

@ -0,0 +1,32 @@
from typing import Optional, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import ProjectFile
class ProjectFilesRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, file_id: int) -> Optional[ProjectFile]:
stmt = select(ProjectFile).filter_by(id=file_id)
result = await self.db.execute(stmt)
return result.scalars().first()
async def get_by_project_id(self, project_id: int) -> Sequence[ProjectFile]:
stmt = select(ProjectFile).filter_by(project_id=project_id)
result = await self.db.execute(stmt)
return result.scalars().all()
async def create(self, project_file: ProjectFile) -> ProjectFile:
self.db.add(project_file)
await self.db.commit()
await self.db.refresh(project_file)
return project_file
async def delete(self, project_file: ProjectFile) -> ProjectFile:
await self.db.delete(project_file)
await self.db.commit()
return project_file

View File

@ -0,0 +1,69 @@
from fastapi import Depends, File, UploadFile, APIRouter
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.database.session import get_db
from app.domain.entities.project_file import ProjectFileEntity
from app.infrastructure.dependencies import get_current_user, require_admin
from app.infrastructure.project_files_service import ProjectFilesService
router = APIRouter()
@router.get(
"/projects/{project_id}/",
response_model=list[ProjectFileEntity],
summary="Get all project files",
description="Returns metadata of all files uploaded for the specified project."
)
async def get_files_by_project_id(
project_id: int,
db: AsyncSession = Depends(get_db),
):
service = ProjectFilesService(db)
return await service.get_files_by_project_id(project_id)
@router.get(
"/{file_id}/file",
response_class=FileResponse,
summary="Download project file by ID",
description="Returns the file for the specified file ID."
)
async def download_project_file(
file_id: int,
db: AsyncSession = Depends(get_db),
):
service = ProjectFilesService(db)
return await service.get_file_by_id(file_id)
@router.post(
"/projects/{project_id}/upload",
response_model=ProjectFileEntity,
summary="Upload a new file for the project",
description="Uploads a new file and associates it with the specified project."
)
async def upload_project_file(
project_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
service = ProjectFilesService(db)
return await service.upload_file(project_id, file, user)
@router.delete(
"/{file_id}/",
response_model=ProjectFileEntity,
summary="Delete a project file by ID",
description="Deletes the file and its database entry."
)
async def delete_project_file(
file_id: int,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
service = ProjectFilesService(db)
return await service.delete_file(file_id, user)

View File

@ -34,7 +34,7 @@ async def get_all_teams(
async def create_team(
team: TeamEntity,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
user=Depends(require_admin),
):
teams_service = TeamsService(db)
return await teams_service.create_team(team)

View File

@ -0,0 +1,32 @@
"""0004_добавил_поле_filename_в_таблицу_profect_files
Revision ID: e53896c51cf8
Revises: be10a7640a29
Create Date: 2025-05-31 11:14:31.502960
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e53896c51cf8'
down_revision: Union[str, None] = 'be10a7640a29'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('project_files', sa.Column('filename', sa.String(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('project_files', 'filename')
# ### end Alembic commands ###

View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
class ProjectFileEntity(BaseModel):
id: int
filename: str
file_path: str
project_id: int

View File

@ -7,6 +7,7 @@ from app.domain.models.base import AdvancedBaseModel
class ProjectFile(AdvancedBaseModel):
__tablename__ = 'project_files'
filename = Column(String, nullable=False)
file_path = Column(String, unique=True, nullable=False)
project_id = Column(Integer, ForeignKey('projects.id'), nullable=False)

View File

@ -0,0 +1,134 @@
import os
import uuid
import aiofiles
import magic
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from werkzeug.utils import secure_filename
from app.application.project_files_repository import ProjectFilesRepository
from app.application.projects_repository import ProjectsRepository
from app.domain.entities.project_file import ProjectFileEntity
from app.domain.models import ProjectFile, User
class ProjectFilesService:
def __init__(self, db: AsyncSession):
self.project_files_repository = ProjectFilesRepository(db)
self.projects_repository = ProjectsRepository(db)
async def get_file_by_id(self, file_id: int) -> FileResponse:
project_file = await self.project_files_repository.get_by_id(file_id)
if not project_file:
raise HTTPException(404, "File not found")
if not os.path.exists(project_file.file_path):
raise HTTPException(404, "File not found on disk")
return FileResponse(
project_file.file_path,
media_type=self.get_media_type(project_file.file_path),
filename=os.path.basename(project_file.file_path),
)
async def get_files_by_project_id(self, project_id: int) -> list[ProjectFileEntity]:
files = await self.project_files_repository.get_by_project_id(project_id)
return [self.model_to_entity(file) for file in files]
async def upload_file(self, project_id: int, file: UploadFile, user: User) -> ProjectFileEntity:
project = await self.projects_repository.get_by_id(project_id)
if not project:
raise HTTPException(404, "Project not found")
self.validate_file_type(file)
file_path = await self.save_file(file)
project_file = ProjectFile(
filename=file.filename,
file_path=file_path,
project_id=project_id,
)
return self.model_to_entity(
await self.project_files_repository.create(project_file)
)
async def delete_file(self, file_id: int, user: User) -> ProjectFileEntity:
project_file = await self.project_files_repository.get_by_id(file_id)
if not project_file:
raise HTTPException(404, "File not found")
if os.path.exists(project_file.file_path):
os.remove(project_file.file_path)
return self.model_to_entity(
await self.project_files_repository.delete(project_file)
)
async def save_file(self, file: UploadFile, upload_dir: str = "uploads/project_files") -> str:
os.makedirs(upload_dir, exist_ok=True)
filename = self.generate_filename(file)
file_path = os.path.join(upload_dir, filename)
async with aiofiles.open(file_path, 'wb') as out_file:
content = await file.read()
await out_file.write(content)
return file_path
@staticmethod
def validate_file_type(file: UploadFile):
mime = magic.Magic(mime=True)
file_type = mime.from_buffer(file.file.read(1024))
file.file.seek(0)
allowed_types = [
"application/pdf",
"image/jpeg",
"image/png",
"application/zip",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
]
if file_type not in allowed_types:
raise HTTPException(400, f"Invalid file type: {file_type}")
@staticmethod
def generate_filename(file: UploadFile) -> str:
return secure_filename(f"{uuid.uuid4()}_{file.filename}")
@staticmethod
def model_to_entity(project_file_model: ProjectFile) -> ProjectFileEntity:
return ProjectFileEntity(
id=project_file_model.id,
filename=project_file_model.filename,
file_path=project_file_model.file_path,
project_id=project_file_model.project_id,
)
@staticmethod
def get_media_type(filename: str) -> str:
extension = filename.split('.')[-1].lower()
if extension in ['jpeg', 'jpg', 'png']:
return f"image/{extension}"
if extension == 'pdf':
return "application/pdf"
if extension in ['zip']:
return "application/zip"
if extension in ['doc', 'docx']:
return "application/msword"
if extension in ['xls', 'xlsx']:
return "application/vnd.ms-excel"
if extension in ['ppt', 'pptx']:
return "application/vnd.ms-powerpoint"
if extension in ['txt']:
return "text/plain"
return "application/octet-stream"

View File

@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.contollers.auth_router import router as auth_router
from app.contollers.profile_photos_router import router as profile_photos_router
from app.contollers.profiles_router import router as profiles_router
from app.contollers.project_files_router import router as project_files_router
from app.contollers.project_members_router import router as project_members_router
from app.contollers.projects_router import router as projects_router
from app.contollers.register_router import router as register_router
@ -26,6 +27,7 @@ def start_app():
api_app.include_router(auth_router, prefix=f'{settings.PREFIX}/auth', tags=['auth'])
api_app.include_router(profile_photos_router, prefix=f'{settings.PREFIX}/profile_photos', tags=['profile_photos'])
api_app.include_router(profiles_router, prefix=f'{settings.PREFIX}/profiles', tags=['profiles'])
api_app.include_router(project_files_router, prefix=f'{settings.PREFIX}/project_files', tags=['project_files'])
api_app.include_router(project_members_router, prefix=f'{settings.PREFIX}/project_members', tags=['project_members'])
api_app.include_router(projects_router, prefix=f'{settings.PREFIX}/projects', tags=['projects'])
api_app.include_router(register_router, prefix=f'{settings.PREFIX}/register', tags=['register'])