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
14 changes: 10 additions & 4 deletions backend/migrations/env.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import asyncio
from logging.config import fileConfig

from alembic import context
from sqlalchemy import pool
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.core.config import get_db_url
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)
database_url = get_db_url().render_as_string(hide_password=False)

config.set_main_option(
"sqlalchemy.url",
database_url.replace("%", "%%"),
)

# Interpret the config file for Python logging.
# This line sets up loggers basically.
Expand Down
88 changes: 54 additions & 34 deletions backend/migrations/versions/0d6439d2e79f_init.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,78 @@
"""init

Revision ID: 0d6439d2e79f
Revises:
Revises:
Create Date: 2026-04-03 16:26:49.885174

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = '0d6439d2e79f'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "0d6439d2e79f"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('files',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('stored_name', sa.String(length=255), nullable=False),
sa.Column('mime_type', sa.String(length=255), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('processing_status', sa.String(length=50), nullable=False),
sa.Column('scan_status', sa.String(length=50), nullable=True),
sa.Column('scan_details', sa.String(length=500), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('requires_attention', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('stored_name')
op.create_table(
"files",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("original_name", sa.String(length=255), nullable=False),
sa.Column("stored_name", sa.String(length=255), nullable=False),
sa.Column("mime_type", sa.String(length=255), nullable=False),
sa.Column("size", sa.Integer(), nullable=False),
sa.Column("processing_status", sa.String(length=50), nullable=False),
sa.Column("scan_status", sa.String(length=50), nullable=True),
sa.Column("scan_details", sa.String(length=500), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("requires_attention", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("stored_name"),
)
op.create_table('alerts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('file_id', sa.String(length=36), nullable=False),
sa.Column('level', sa.String(length=50), nullable=False),
sa.Column('message', sa.String(length=500), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['file_id'], ['files.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"alerts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("file_id", sa.String(length=36), nullable=False),
sa.Column("level", sa.String(length=50), nullable=False),
sa.Column("message", sa.String(length=500), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["file_id"],
["files.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('alerts')
op.drop_table('files')
op.drop_table("alerts")
op.drop_table("files")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""cascade alerts when file is deleted

Revision ID: 72bd615f6afe
Revises: 0d6439d2e79f
Create Date: 2026-07-12 09:13:18.689868

"""

from collections.abc import Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "72bd615f6afe"
down_revision: str | Sequence[str] | None = "0d6439d2e79f"
branch_labels: str | Sequence[str] | None = None
depends_on: 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(
"alerts_file_id_fkey", "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("alerts_file_id_fkey", "alerts", type_="foreignkey")
op.create_foreign_key(op.f("alerts_file_id_fkey"), "alerts", "files", ["file_id"], ["id"])
# ### end Alembic commands ###
34 changes: 34 additions & 0 deletions backend/migrations/versions/daaf09d6664b_add_query_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""add query indexes

Revision ID: daaf09d6664b
Revises: 72bd615f6afe
Create Date: 2026-07-13 17:11:48.206886

"""
from collections.abc import Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = 'daaf09d6664b'
down_revision: str | Sequence[str] | None = '72bd615f6afe'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('ix_alerts_created_at', 'alerts', ['created_at'], unique=False)
op.create_index('ix_alerts_file_id', 'alerts', ['file_id'], unique=False)
op.create_index('ix_files_created_at', 'files', ['created_at'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_files_created_at', table_name='files')
op.drop_index('ix_alerts_file_id', table_name='alerts')
op.drop_index('ix_alerts_created_at', table_name='alerts')
# ### end Alembic commands ###
31 changes: 31 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,39 @@ dependencies = [
"asyncpg>=0.30.0",
"celery[redis]>=5.6.3",
"fastapi>=0.135.3",
"greenlet>=3.3.2",
"pydantic>=2.12.5",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.48",
"uvicorn>=0.42.0",
]

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

[tool.ruff]
target-version = "py314"
line-length = 100

[tool.ruff.lint]
select = [
"E", # style errors
"F", # Pyflakes
"I", # import sorting
"B", # likely bugs
"UP", # modern Python syntax
"ASYNC",
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
Empty file added backend/src/alerts/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions backend/src/alerts/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio.session import AsyncSession

from src.models import Alert


class AlertRepository:
def __init__(self, session: AsyncSession):
self.session = session

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

async def get_for_file(self, file_id: str) -> Alert | None:
result = await self.session.execute(
select(Alert).where(Alert.file_id == file_id).limit(1)
)
return result.scalar_one_or_none()

def add(self, alert: Alert) -> None:
self.session.add(alert)
Empty file added backend/src/api/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions backend/src/api/alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Annotated

from fastapi import APIRouter, Depends

from src.alerts.repository import AlertRepository
from src.api.deps import get_alert_repository
from src.schemas import AlertItem

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


@router.get("", response_model=list[AlertItem])
async def list_alerts_view(
repository: Annotated[AlertRepository, Depends(get_alert_repository)],
):
return await repository.list_ordered()
27 changes: 27 additions & 0 deletions backend/src/api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from typing import Annotated

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from src.alerts.repository import AlertRepository
from src.core.config import STORAGE_DIR
from src.core.database import get_session
from src.files.repository import FileRepository
from src.files.service import FileService
from src.files.storage import FileStorage


def get_file_service(
session: Annotated[AsyncSession, Depends(get_session)],
) -> FileService:
return FileService(
session=session,
repository=FileRepository(session),
storage=FileStorage(STORAGE_DIR),
)


def get_alert_repository(
session: Annotated[AsyncSession, Depends(get_session)],
) -> AlertRepository:
return AlertRepository(session)
68 changes: 68 additions & 0 deletions backend/src/api/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from typing import Annotated

from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import FileResponse

from src.api.deps import get_file_service
from src.files.service import FileService
from src.processing.tasks import enqueue_file_processing
from src.schemas import FileItem, FileUpdate

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


@router.get("", response_model=list[FileItem])
async def list_files_view(
service: Annotated[FileService, Depends(get_file_service)],
):
return await service.list_files()


@router.get("/{file_id}", response_model=FileItem)
async def get_file_view(
file_id: str,
service: Annotated[FileService, Depends(get_file_service)],
):
return await service.get_file(file_id)


@router.patch("/{file_id}", response_model=FileItem)
async def update_file_view(
file_id: str,
payload: FileUpdate,
service: Annotated[FileService, Depends(get_file_service)],
):
return await service.update_file(file_id=file_id, title=payload.title)


@router.post("", response_model=FileItem, status_code=201)
async def create_file_view(
title: Annotated[str, Form(...)],
file: Annotated[UploadFile, File(...)],
service: Annotated[FileService, Depends(get_file_service)],
):
file_item = await service.create_file(title=title, upload_file=file)
enqueue_file_processing(str(file_item.id))
return file_item


@router.get("/{file_id}/download")
async def download_file(
file_id: str,
service: Annotated[FileService, Depends(get_file_service)],
):
file_item, stored_path = await service.get_file_path(file_id)

return FileResponse(
path=stored_path,
media_type=file_item.mime_type,
filename=file_item.original_name,
)


@router.delete("/{file_id}", status_code=204)
async def delete_file_view(
file_id: str,
service: Annotated[FileService, Depends(get_file_service)],
):
await service.delete_file(file_id)
Loading