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
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
24 changes: 24 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
VENV_DIR = .venv
PYTHON = python3.14
PIP = $(VENV_DIR)/bin/pip
PYTHON_VENV = $(VENV_DIR)/bin/python
PRE_COMMIT = $(VENV_DIR)/bin/pre-commit


.PHONY: venv format run


venv: .venv/pyvenv.cfg $(PKG_DIR)
# Create virtual environment.

.venv/pyvenv.cfg: backend/pyproject.toml $(PKG_DIR)
$(PYTHON) -m venv $(VENV_DIR)
$(PIP) -q install --upgrade pip wheel pre-commit

format: venv
# Run checking and formatting sources.
$(PRE_COMMIT) run -a

run:
# Run services.
docker compose -f docker-compose.dev.yml up
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Вводные:**
1. Здесь представлен MVP проект файлообменника. Он позволяет загружать файлы, проверяет их на подозрительный контент и отправляет алерты;
2. Репозиторий содержит в себе бэкенд и фронтенд части;
3. В обоих частях присутствуют баги, неоптимизированный код, неудачные архитектурные решения.
3. В обеих частях присутствуют баги, неоптимизированный код, неудачные архитектурные решения.

**Задачи:**
1. Проведите рефакторинг бэкенда, не ломая бизнес-логики: предложите свое видение архитектуры и реализуйте его;
Expand All @@ -15,6 +15,6 @@
2. ```docker exec -it backend alembic upgrade head```


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

**Открыть бэк:** ```http://localhost:8000/docs```
2 changes: 1 addition & 1 deletion backend/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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.db import DB_URL
from src.models import Base
import src.models

Expand Down
34 changes: 34 additions & 0 deletions backend/migrations/versions/01edf57e8045_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""empty message

Revision ID: 01edf57e8045
Revises: 0d6439d2e79f
Create Date: 2026-08-28 13:35:20.679493

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '01edf57e8045'
down_revision: Union[str, Sequence[str], None] = '0d6439d2e79f'
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.drop_constraint(op.f('alerts_file_id_fkey'), 'alerts', type_='foreignkey')
op.create_foreign_key(None, 'alerts', 'files', ['file_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'alerts', type_='foreignkey')
op.create_foreign_key(op.f('alerts_file_id_fkey'), 'alerts', 'files', ['file_id'], ['id'])
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requires-python = ">=3.14"
dependencies = [
"alembic>=1.18.4",
"asyncpg>=0.30.0",
"celery-types>=0.26.0",
"celery[redis]>=5.6.3",
"fastapi>=0.135.3",
"pydantic>=2.12.5",
Expand Down
11 changes: 11 additions & 0 deletions backend/src/alerts/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import APIRouter

from src.alerts.schemas import AlertItem
from src.alerts.service import list_alerts

alerts_router = APIRouter(prefix="/alerts", tags=["alerts"])


@alerts_router.get("/", response_model=list[AlertItem])
async def list_alerts_view():
return await list_alerts()
13 changes: 13 additions & 0 deletions backend/src/alerts/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from datetime import datetime

from pydantic import BaseModel, ConfigDict


class AlertItem(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
file_id: str
level: str
message: str
created_at: datetime
10 changes: 10 additions & 0 deletions backend/src/alerts/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from sqlalchemy import select

from src.db import async_session_maker
from src.models import Alert


async def list_alerts() -> list[Alert]:
async with async_session_maker() as session:
result = await session.execute(select(Alert).order_by(Alert.created_at.desc()))
return result.scalars().all()
63 changes: 6 additions & 57 deletions backend/src/app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from fastapi import FastAPI, HTTPException
from fastapi import File, Form, UploadFile
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
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.tasks import scan_file_for_threats

from src.alerts.routes import alerts_router
from src.files.routes import files_router

app = FastAPI()
app.add_middleware(
Expand All @@ -19,53 +16,5 @@
allow_headers=["*"],
)


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


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


@app.post("/files", response_model=FileItem, status_code=201)
async def create_file_view(
title: str = Form(...),
file: UploadFile = File(...),
):
file_item = await create_file(title=title, upload_file=file)
scan_file_for_threats.delay(file_item.id)
return file_item


@app.get("/files/{file_id}", response_model=FileItem)
async def get_file_view(file_id: str):
return await get_file(file_id)


@app.patch("/files/{file_id}", response_model=FileItem)
async def update_file_view(
file_id: str,
payload: FileUpdate,
):
return await update_file(file_id=file_id, title=payload.title)


@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")
return FileResponse(
path=stored_path,
media_type=file_item.mime_type,
filename=file_item.original_name,
)


@app.delete("/files/{file_id}", status_code=204)
async def delete_file_view(file_id: str):
await delete_file(file_id)
app.include_router(alerts_router)
app.include_router(files_router)
15 changes: 15 additions & 0 deletions backend/src/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os
from pathlib import Path

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

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)
56 changes: 56 additions & 0 deletions backend/src/files/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from fastapi import APIRouter, HTTPException
from fastapi import File, Form, UploadFile
from fastapi.responses import FileResponse
from starlette import status

from src.files.schemas import FileItem, FileUpdate
from src.files.service import create_file, delete_file, get_file, list_files, update_file, STORAGE_DIR
from src.tasks import scan_file_for_threats

files_router = APIRouter(prefix="/files", tags=["files"])


@files_router.get("/", response_model=list[FileItem])
async def list_files_view():
return await list_files()


@files_router.post("/", response_model=FileItem, status_code=201)
async def create_file_view(
title: str = Form(...),
file: UploadFile = File(...),
):
file_item = await create_file(title=title, upload_file=file)
scan_file_for_threats.delay(file_item.id)
return file_item


@files_router.get("/{file_id}", response_model=FileItem)
async def get_file_view(file_id: str):
return await get_file(file_id)


@files_router.patch("/{file_id}", response_model=FileItem)
async def update_file_view(
file_id: str,
payload: FileUpdate,
):
return await update_file(file_id=file_id, title=payload.title)


@files_router.get("/{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")
return FileResponse(
path=stored_path,
media_type=file_item.mime_type,
filename=file_item.original_name,
)


@files_router.delete("/{file_id}", status_code=204)
async def delete_file_view(file_id: str):
await delete_file(file_id)
10 changes: 0 additions & 10 deletions backend/src/schemas.py → backend/src/files/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,3 @@ class FileItem(BaseModel):

class FileUpdate(BaseModel):
title: str


class AlertItem(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
file_id: str
level: str
message: str
created_at: datetime
35 changes: 3 additions & 32 deletions backend/src/service.py → backend/src/files/service.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,18 @@
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.models import Alert, StoredFile


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)
from src.db import async_session_maker, STORAGE_DIR
from src.models import StoredFile


async def list_files() -> list[StoredFile]:
async with async_session_maker() as session:
result = await session.execute(select(StoredFile).order_by(StoredFile.created_at.desc()))
return list(result.scalars().all())


async def list_alerts() -> list[Alert]:
async with async_session_maker() as session:
result = await session.execute(select(Alert).order_by(Alert.created_at.desc()))
return list(result.scalars().all())
return result.scalars().all()


async def get_file(file_id: str) -> StoredFile:
Expand Down Expand Up @@ -60,7 +41,6 @@ async def create_file(title: str, upload_file: UploadFile) -> StoredFile:
stored_name=stored_name,
mime_type=upload_file.content_type or mimetypes.guess_type(stored_name)[0] or "application/octet-stream",
size=len(content),
processing_status="uploaded",
)
async with async_session_maker() as session:
session.add(file_item)
Expand Down Expand Up @@ -98,12 +78,3 @@ async def get_file_path(file_id: str) -> tuple[StoredFile, Path]:
if not stored_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found")
return file_item, stored_path


async def create_alert(file_id: str, level: str, message: str) -> Alert:
alert = Alert(file_id=file_id, level=level, message=message)
async with async_session_maker() as session:
session.add(alert)
await session.commit()
await session.refresh(alert)
return alert
Loading