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
5 changes: 3 additions & 2 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=test
POSTGRES_HOST=backend-db
PGPORT=5433
PGPORT=5432
POSTGRES_PORT=5432

# Celery / Redis
CELERY_BROKER_URL=redis://backend-redis:6379/0
CELERY_BROKER_URL=redis://backend-redis:6379/0
53 changes: 39 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
## Тестовое задание на позицию Fullstack разработчика (Python + React)
# Тестовое задание на позицию Fullstack разработчика (Python + React)

**Вводные:**
1. Здесь представлен MVP проект файлообменника. Он позволяет загружать файлы, проверяет их на подозрительный контент и отправляет алерты;
2. Репозиторий содержит в себе бэкенд и фронтенд части;
3. В обоих частях присутствуют баги, неоптимизированный код, неудачные архитектурные решения.
MVP файлообменника: пользователь загружает файлы, backend сохраняет их, запускает фоновую проверку содержимого и показывает алерты по результатам обработки.

**Задачи:**
1. Проведите рефакторинг бэкенда, не ломая бизнес-логики: предложите свое видение архитектуры и реализуйте его;
2. (Дополнительно) На бэкенде есть возможность неочевидной оптимизации - выполните ее;
3. (Дополнительно) Разбейте логику фронтенда на слои;
## Что изменено

**Запуск:**
1. ```docker compose -f docker-compose.dev.yml up```
2. ```docker exec -it backend alembic upgrade head```
- Backend разделен на слои: настройки, подключение к БД, файловое хранилище и бизнес-операции.
- Загрузка файлов переписана на потоковую запись чанками, без чтения всего файла в память.
- Celery использует единый `CELERY_BROKER_URL` из настроек.
- Docker-сборка backend стала многослойной и использует cache mount для `uv`.
- Добавлен `backend/.dockerignore`.
- `docker-compose.dev.yml` использует pinned образы Postgres/Redis, healthcheck для БД/Redis и корректный проброс Postgres `127.0.0.1:5433 -> 5432`.
- Frontend частично разнесен по слоям: доменные типы, API-клиент и форматирование вынесены из `page.tsx`.
- Исправлены сломанные русские строки в интерфейсе.

## Архитектура backend

**Открыть фронт:** ```http://localhost:3000/test```
- `src/config.py` читает переменные окружения и собирает URL БД/Redis.
- `src/database.py` создает async SQLAlchemy engine и session maker.
- `src/storage.py` отвечает за директорию хранения и потоковую запись upload-файлов.
- `src/service.py` содержит бизнес-операции над файлами и алертами.
- `src/tasks.py` содержит Celery-задачи сканирования, извлечения метаданных и отправки алертов.

**Открыть бэк:** ```http://localhost:8000/docs```
## Запуск

```bash
docker compose -f docker-compose.dev.yml up -d
docker exec -it backend alembic upgrade head
```

Frontend: http://localhost:3000/test

Backend Swagger: http://localhost:8000/docs

## Тесты

```bash
cd backend
uv run pytest
```

```bash
cd frontend
npm test
```
29 changes: 29 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.git
.idea
.vscode

__pycache__/
*.py[cod]
*$py.class

.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/

.venv/
venv/
.uv-cache/

.env
.env.*
!.env.example

storage/
*.sqlite3
*.db
*.log

Dockerfile
.dockerignore
25 changes: 23 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
FROM python:3.14-slim
# syntax=docker/dockerfile:1.7

FROM python:3.14-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_NO_DEV=1 \
UV_PROJECT_ENVIRONMENT=/usr/local

WORKDIR /backend

COPY pyproject.toml uv.lock ./
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN uv sync --locked
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project

FROM python:3.14-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /backend

COPY --from=builder /usr/local /usr/local

COPY alembic.ini ./
COPY migrations ./migrations
COPY src ./src

RUN mkdir -p /backend/storage/files

EXPOSE 8000

CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]
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 get_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", get_settings().database_url)

# Interpret the config file for Python logging.
# This line sets up loggers basically.
Expand Down
12 changes: 12 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,15 @@ dependencies = [
"sqlalchemy>=2.0.48",
"uvicorn>=0.42.0",
]

[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
testpaths = ["tests"]
3 changes: 2 additions & 1 deletion backend/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from fastapi.responses import FileResponse
from starlette import status
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, list_alerts, list_files, update_file
from src.storage import STORAGE_DIR
from src.tasks import scan_file_for_threats

app = FastAPI()
Expand Down
43 changes: 43 additions & 0 deletions backend/src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path


BASE_DIR = Path(__file__).resolve().parent.parent


@dataclass(frozen=True)
class Settings:
postgres_user: str
postgres_password: str
postgres_db: str
postgres_host: str
postgres_port: int
celery_broker_url: str
storage_dir: Path
max_upload_size: int

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


@lru_cache
def get_settings() -> Settings:
return Settings(
postgres_user=os.getenv("POSTGRES_USER", "postgres"),
postgres_password=os.getenv("POSTGRES_PASSWORD", "postgres"),
postgres_db=os.getenv("POSTGRES_DB", "test"),
postgres_host=os.getenv("POSTGRES_HOST", "localhost"),
postgres_port=int(os.getenv("POSTGRES_PORT") or os.getenv("PGPORT", "5432")),
celery_broker_url=os.getenv(
"CELERY_BROKER_URL",
os.getenv("REDIS_URL", "redis://backend-redis:6379/0"),
),
storage_dir=Path(os.getenv("STORAGE_DIR", BASE_DIR / "storage" / "files")),
max_upload_size=int(os.getenv("MAX_UPLOAD_SIZE", str(50 * 1024 * 1024))),
)
17 changes: 17 additions & 0 deletions backend/src/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from src.config import get_settings


settings = get_settings()
DB_URL = settings.database_url

engine = create_async_engine(DB_URL, pool_pre_ping=True)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


async def get_session() -> AsyncIterator[AsyncSession]:
async with async_session_maker() as session:
yield session
41 changes: 19 additions & 22 deletions backend/src/service.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import mimetypes
import os
from pathlib import Path
from uuid import uuid4

from fastapi import HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

from src.config import get_settings
from src.database import async_session_maker
from src.models import Alert, StoredFile
from src.storage import STORAGE_DIR, save_upload_file


BASE_DIR = Path(__file__).resolve().parent.parent
STORAGE_DIR = BASE_DIR / "storage" / "files"
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
DB_URL = (
f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER')}:"
f"{os.environ.get('POSTGRES_PASSWORD')}@{os.environ.get('POSTGRES_HOST')}:"
f"{os.environ.get('PGPORT')}/{os.environ.get('POSTGRES_DB')}"
)
engine = create_async_engine(DB_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
settings = get_settings()


async def list_files() -> list[StoredFile]:
Expand All @@ -43,29 +35,34 @@ async def get_file(file_id: str) -> StoredFile:


async def create_file(title: str, upload_file: UploadFile) -> StoredFile:
content = await upload_file.read()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")

file_id = str(uuid4())
suffix = Path(upload_file.filename or "").suffix
stored_name = f"{file_id}{suffix}"
stored_path = STORAGE_DIR / stored_name
stored_path.write_bytes(content)
size = await save_upload_file(upload_file, stored_path, settings.max_upload_size)

if size == 0:
stored_path.unlink(missing_ok=True)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")

file_item = StoredFile(
id=file_id,
title=title,
original_name=upload_file.filename or stored_name,
stored_name=stored_name,
mime_type=upload_file.content_type or mimetypes.guess_type(stored_name)[0] or "application/octet-stream",
size=len(content),
size=size,
processing_status="uploaded",
)
async with async_session_maker() as session:
session.add(file_item)
await session.commit()
await session.refresh(file_item)
try:
async with async_session_maker() as session:
session.add(file_item)
await session.commit()
await session.refresh(file_item)
except Exception:
stored_path.unlink(missing_ok=True)
raise

return file_item


Expand Down
31 changes: 31 additions & 0 deletions backend/src/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from pathlib import Path

from fastapi import HTTPException, UploadFile, status

from src.config import get_settings


CHUNK_SIZE = 1024 * 1024
STORAGE_DIR = get_settings().storage_dir
STORAGE_DIR.mkdir(parents=True, exist_ok=True)


async def save_upload_file(upload_file: UploadFile, destination: Path, max_size: int) -> int:
size = 0
destination.parent.mkdir(parents=True, exist_ok=True)

try:
with destination.open("wb") as output:
while chunk := await upload_file.read(CHUNK_SIZE):
size += len(chunk)
if size > max_size:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail="File is too large",
)
output.write(chunk)
except Exception:
destination.unlink(missing_ok=True)
raise

return size
16 changes: 9 additions & 7 deletions backend/src/tasks.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import asyncio
import os
from pathlib import Path
from celery import Celery
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.config import get_settings
from src.database import async_session_maker
from src.models import Alert, StoredFile
from src.service import STORAGE_DIR, DB_URL
from src.storage import STORAGE_DIR

REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0")
settings = get_settings()
_worker_loop: asyncio.AbstractEventLoop | None = None


Expand All @@ -18,9 +18,11 @@ def run_in_worker_loop(coroutine):
return _worker_loop.run_until_complete(coroutine)


celery_app = Celery("file_tasks", broker=REDIS_URL, backend=REDIS_URL)
engine = create_async_engine(DB_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
celery_app = Celery(
"file_tasks",
broker=settings.celery_broker_url,
backend=settings.celery_broker_url,
)


async def _scan_file_for_threats(file_id: str) -> None:
Expand Down
Loading