Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,37 @@
1. ```docker compose -f docker-compose.dev.yml up```
2. ```docker exec -it backend alembic upgrade head```

**Открыть фронт:** ```http://localhost:3000/test```
**Открыть бэк:** ```http://localhost:8000/docs```

**Открыть фронт:** ```http://localhost:3000/test```
---

**Открыть бэк:** ```http://localhost:8000/docs```
# Что было сделано

## Backend

### Архитектура
- Вынес конфигурацию в отдельный модуль `config.py` с использованием Pydantic Settings
- Создал общий модуль для работы с БД `database.py` с настройкой connection pooling
- Внедрил паттерн Repository для работы с данными (`repositories.py`)
- Разделил логику на сервисы: `FileStorageService` и `FileScannerService`
- Добавил структурированное логирование через `logger.py`
- Почистил API слой в `app.py` - теперь он работает только через сервисы

### Оптимизации
- Настроил connection pooling для БД (pool_size=10, max_overflow=20)
- Перевел все операции с файлами на async I/O с помощью `aiofiles` - теперь чтение и запись файлов не блокируют event loop

## Frontend

### Разделение на слои
- Вынес API calls в отдельный клиент `api.ts`
- Создал custom hooks: `useFilesAndAlerts` и `useFileUpload` для работы с данными
- Разбил один большой компонент на несколько: `FileTable`, `AlertTable`, `UploadModal`
- Вынес утилитные функции в `utils.ts`
- Главная страница теперь просто оркестрирует компоненты

## Зависимости
Добавил в backend:
- `pydantic-settings>=2.0.0` - для конфигурации
- `aiofiles>=24.1.0` - для async работы с файлами
4 changes: 2 additions & 2 deletions backend/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from src.service import DB_URL
from src.config import settings
from src.models import Base
import src.models

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option('sqlalchemy.url', DB_URL)
config.set_main_option('sqlalchemy.url', settings.database_url)

# Interpret the config file for Python logging.
# This line sets up loggers basically.
Expand Down
2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"aiofiles>=24.1.0",
"alembic>=1.18.4",
"asyncpg>=0.30.0",
"celery[redis]>=5.6.3",
"fastapi>=0.135.3",
"pydantic>=2.12.5",
"pydantic-settings>=2.0.0",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.48",
"uvicorn>=0.42.0",
Expand Down
14 changes: 9 additions & 5 deletions backend/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from starlette import status
from src.logger import logger
from src.schemas import AlertItem, FileItem, FileUpdate
from src.service import create_file, delete_file, get_file, list_alerts, list_files, update_file, STORAGE_DIR
from src.service import create_file, delete_file, get_file, get_file_path, list_alerts, list_files, update_file
from src.tasks import scan_file_for_threats

app = FastAPI()
Expand All @@ -22,11 +23,13 @@

@app.get("/files", response_model=list[FileItem])
async def list_files_view():
logger.info("Listing all files")
return await list_files()


@app.get("/alerts", response_model=list[AlertItem])
async def list_alerts_view():
logger.info("Listing all alerts")
return await list_alerts()


Expand All @@ -35,8 +38,10 @@ async def create_file_view(
title: str = Form(...),
file: UploadFile = File(...),
):
logger.info(f"Creating file with title: {title}")
file_item = await create_file(title=title, upload_file=file)
scan_file_for_threats.delay(file_item.id)
logger.info(f"File created with ID: {file_item.id}")
return file_item


Expand All @@ -55,10 +60,7 @@ async def update_file_view(

@app.get("/files/{file_id}/download")
async def download_file(file_id: str):
file_item = await get_file(file_id)
stored_path = STORAGE_DIR / file_item.stored_name
if not stored_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found")
file_item, stored_path = await get_file_path(file_id)
return FileResponse(
path=stored_path,
media_type=file_item.mime_type,
Expand All @@ -68,4 +70,6 @@ async def download_file(file_id: str):

@app.delete("/files/{file_id}", status_code=204)
async def delete_file_view(file_id: str):
logger.info(f"Deleting file with ID: {file_id}")
await delete_file(file_id)
logger.info(f"File deleted: {file_id}")
35 changes: 35 additions & 0 deletions backend/src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
# Database
postgres_user: str
postgres_password: str
postgres_host: str
postgres_db: str
pgport: str = "5432"

# Redis
redis_url: str = "redis://backend-redis:6379/0"

# Storage
base_dir: Path = Path(__file__).resolve().parent.parent
storage_dir: Path = base_dir / "storage" / "files"

@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.postgres_user}:"
f"{self.postgres_password}@{self.postgres_host}:"
f"{self.pgport}/{self.postgres_db}"
)

class Config:
env_file = ".env.dev"
extra = "ignore"


settings = Settings()
settings.storage_dir.mkdir(parents=True, exist_ok=True)
25 changes: 25 additions & 0 deletions backend/src/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.config import settings

# Create async engine with optimized connection pooling
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)

# Create session factory
async_session_maker = async_sessionmaker(
engine,
expire_on_commit=False,
class_=AsyncSession,
)


async def get_session() -> AsyncSession:
"""Dependency for FastAPI to get database session."""
async with async_session_maker() as session:
yield session
23 changes: 23 additions & 0 deletions backend/src/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging
import sys

from src.config import settings


def setup_logger(name: str) -> logging.Logger:
"""Setup and configure a logger."""
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)

if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)

return logger


logger = setup_logger("backend")
60 changes: 60 additions & 0 deletions backend/src/repositories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import Alert, StoredFile


class FileRepository:
"""Repository for StoredFile data access operations."""

def __init__(self, session: AsyncSession) -> None:
self.session = session

async def get_all(self) -> list[StoredFile]:
"""Get all files ordered by creation date descending."""
result = await self.session.execute(
select(StoredFile).order_by(StoredFile.created_at.desc())
)
return list(result.scalars().all())

async def get_by_id(self, file_id: str) -> StoredFile | None:
"""Get a file by ID."""
return await self.session.get(StoredFile, file_id)

async def create(self, file_item: StoredFile) -> StoredFile:
"""Create a new file record."""
self.session.add(file_item)
await self.session.commit()
await self.session.refresh(file_item)
return file_item

async def update(self, file_item: StoredFile) -> StoredFile:
"""Update an existing file record."""
await self.session.commit()
await self.session.refresh(file_item)
return file_item

async def delete(self, file_item: StoredFile) -> None:
"""Delete a file record."""
await self.session.delete(file_item)
await self.session.commit()


class AlertRepository:
"""Repository for Alert data access operations."""

def __init__(self, session: AsyncSession) -> None:
self.session = session

async def get_all(self) -> list[Alert]:
"""Get all alerts ordered by creation date descending."""
result = await self.session.execute(
select(Alert).order_by(Alert.created_at.desc())
)
return list(result.scalars().all())

async def create(self, alert: Alert) -> Alert:
"""Create a new alert record."""
self.session.add(alert)
await self.session.commit()
await self.session.refresh(alert)
return alert
Loading