This commit is contained in:
jze9
2026-07-21 16:11:09 +05:00
commit 78862296c4
94 changed files with 6090 additions and 0 deletions

5
backend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache

1
backend/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

20
backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS base
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY . .
FROM base AS dev
RUN uv sync --frozen
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
FROM base AS prod
RUN uv sync --frozen --no-dev
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

3
backend/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Inventory backend
FastAPI service for the inventory tracking system. See the repo root README for how to run the full stack via Docker Compose.

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

55
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,55 @@
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 app.core.config import get_settings
from app.db.base import Base
from app.models import Box, InventoryObject, ObjectPhoto, User # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,80 @@
"""initial schema
Revision ID: cdcadb640107
Revises:
Create Date: 2026-07-21 07:34:21.610198
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cdcadb640107'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('boxes',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('parent_box_id', sa.UUID(), nullable=True),
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.ForeignKeyConstraint(['parent_box_id'], ['boxes.id'], name=op.f('fk_boxes_parent_box_id_boxes'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_boxes'))
)
op.create_index(op.f('ix_boxes_parent_box_id'), 'boxes', ['parent_box_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('objects',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('inventory_number', sa.String(length=128), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('box_id', sa.UUID(), nullable=True),
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.ForeignKeyConstraint(['box_id'], ['boxes.id'], name=op.f('fk_objects_box_id_boxes'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_objects'))
)
op.create_index(op.f('ix_objects_box_id'), 'objects', ['box_id'], unique=False)
op.create_index(op.f('ix_objects_inventory_number'), 'objects', ['inventory_number'], unique=True)
op.create_table('object_photos',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('object_id', sa.UUID(), nullable=False),
sa.Column('storage_key', sa.String(length=512), nullable=False),
sa.Column('content_type', sa.String(length=128), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['object_id'], ['objects.id'], name=op.f('fk_object_photos_object_id_objects'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_object_photos'))
)
op.create_index(op.f('ix_object_photos_object_id'), 'object_photos', ['object_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_object_photos_object_id'), table_name='object_photos')
op.drop_table('object_photos')
op.drop_index(op.f('ix_objects_inventory_number'), table_name='objects')
op.drop_index(op.f('ix_objects_box_id'), table_name='objects')
op.drop_table('objects')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_boxes_parent_box_id'), table_name='boxes')
op.drop_table('boxes')
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

41
backend/app/cli.py Normal file
View File

@@ -0,0 +1,41 @@
import asyncio
import getpass
import typer
from sqlalchemy import select
from app.core.security import hash_password
from app.db.session import async_session_maker
from app.models.user import User
app = typer.Typer()
@app.command()
def create_admin(username: str = typer.Option(..., prompt=True)) -> None:
"""Create an admin user for the inventory backend."""
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
typer.echo("Passwords do not match", err=True)
raise typer.Exit(code=1)
if len(password) < 8:
typer.echo("Password must be at least 8 characters", err=True)
raise typer.Exit(code=1)
asyncio.run(_create_admin(username, password))
typer.echo(f"Created user '{username}'")
async def _create_admin(username: str, password: str) -> None:
async with async_session_maker() as db:
existing = await db.execute(select(User).where(User.username == username))
if existing.scalar_one_or_none() is not None:
typer.echo(f"User '{username}' already exists", err=True)
raise typer.Exit(code=1)
db.add(User(username=username, hashed_password=hash_password(password)))
await db.commit()
if __name__ == "__main__":
app()

View File

View File

@@ -0,0 +1,28 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
minio_endpoint: str
minio_access_key: str
minio_secret_key: str
minio_bucket: str
minio_use_ssl: bool = False
minio_public_url: str
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 600
public_base_url: str
allowed_origins: list[str] = ["http://localhost:5173"]
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,47 @@
from typing import Annotated
import boto3
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.user import User
DbSession = Annotated[AsyncSession, Depends(get_db)]
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=True)
async def get_current_user(
db: DbSession, token: Annotated[str, Depends(_oauth2_scheme)]
) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = decode_access_token(token)
if user_id is None:
raise credentials_error
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_error
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_minio_client():
settings = get_settings()
return boto3.client(
"s3",
endpoint_url=("https://" if settings.minio_use_ssl else "http://") + settings.minio_endpoint,
aws_access_key_id=settings.minio_access_key,
aws_secret_access_key=settings.minio_secret_key,
)

View File

@@ -0,0 +1,40 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.core.config import get_settings
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return _hasher.verify(hashed, password)
except VerifyMismatchError:
return False
def create_access_token(user_id: UUID) -> str:
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": str(user_id), "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> UUID | None:
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except jwt.InvalidTokenError:
return None
subject = payload.get("sub")
if subject is None:
return None
return UUID(subject)

View File

14
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)

13
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,13 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import get_settings
engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with async_session_maker() as session:
yield session

28
backend/app/main.py Normal file
View File

@@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import get_settings
from app.routers import auth, boxes, objects, photos, qrcodes
settings = get_settings()
app = FastAPI(title="Inventory API")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(objects.router)
app.include_router(boxes.router)
app.include_router(photos.router)
app.include_router(qrcodes.router)
@app.get("/api/v1/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,6 @@
from app.models.box import Box
from app.models.object import InventoryObject
from app.models.object_photo import ObjectPhoto
from app.models.user import User
__all__ = ["Box", "InventoryObject", "ObjectPhoto", "User"]

31
backend/app/models/box.py Normal file
View File

@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.models.object import InventoryObject
class Box(Base):
__tablename__ = "boxes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
parent_box_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("boxes.id", ondelete="RESTRICT"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
parent: Mapped["Box | None"] = relationship(remote_side=[id], back_populates="children")
children: Mapped[list["Box"]] = relationship(back_populates="parent")
objects: Mapped[list["InventoryObject"]] = relationship(back_populates="box")

View File

@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.models.box import Box
from app.models.object_photo import ObjectPhoto
class InventoryObject(Base):
__tablename__ = "objects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
inventory_number: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
box_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("boxes.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
box: Mapped["Box | None"] = relationship(back_populates="objects")
photos: Mapped[list["ObjectPhoto"]] = relationship(
back_populates="object", cascade="all, delete-orphan", order_by="ObjectPhoto.position"
)

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.models.object import InventoryObject
class ObjectPhoto(Base):
__tablename__ = "object_photos"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
object_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("objects.id", ondelete="CASCADE"), nullable=False, index=True
)
storage_key: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
object: Mapped["InventoryObject"] = relationship(back_populates="photos")

View File

@@ -0,0 +1,17 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

View File

@@ -0,0 +1,23 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.core.dependencies import DbSession
from app.core.security import create_access_token
from app.schemas.auth import Token
from app.services.auth import authenticate_user
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/token", response_model=Token)
async def login(db: DbSession, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
user = await authenticate_user(db, form_data.username, form_data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=create_access_token(user.id))

View File

@@ -0,0 +1,54 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from app.core.dependencies import CurrentUser, DbSession
from app.schemas.box import BoxCreate, BoxRead, BoxSummary, BoxUpdate
from app.services import boxes as boxes_service
router = APIRouter(prefix="/api/v1/boxes", tags=["boxes"])
@router.get("", response_model=list[BoxSummary])
async def list_boxes(
db: DbSession, search: str | None = None, root_only: bool = False
) -> list[BoxSummary]:
items = await boxes_service.list_boxes(db, search=search, root_only=root_only)
return [BoxSummary.model_validate(b) for b in items]
@router.post("", response_model=BoxRead, status_code=status.HTTP_201_CREATED)
async def create_box(db: DbSession, _: CurrentUser, data: BoxCreate) -> BoxRead:
box = await boxes_service.create_box(db, data)
return await boxes_service.build_box_read(db, box)
@router.get("/{box_id}", response_model=BoxRead)
async def get_box(db: DbSession, box_id: uuid.UUID) -> BoxRead:
box = await boxes_service.get_box(db, box_id)
if box is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
return await boxes_service.build_box_read(db, box)
@router.put("/{box_id}", response_model=BoxRead)
async def update_box(db: DbSession, _: CurrentUser, box_id: uuid.UUID, data: BoxUpdate) -> BoxRead:
box = await boxes_service.get_box(db, box_id)
if box is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
try:
box = await boxes_service.update_box(db, box, data)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return await boxes_service.build_box_read(db, box)
@router.delete("/{box_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_box(db: DbSession, _: CurrentUser, box_id: uuid.UUID) -> None:
box = await boxes_service.get_box(db, box_id)
if box is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
try:
await boxes_service.delete_box(db, box)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))

View File

@@ -0,0 +1,61 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy.exc import IntegrityError
from app.core.dependencies import CurrentUser, DbSession, get_minio_client
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate
from app.services import objects as objects_service
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
@router.get("", response_model=list[ObjectRead])
async def list_objects(db: DbSession, search: str | None = None) -> list[ObjectRead]:
items = await objects_service.list_objects(db, search=search)
return [objects_service.build_object_read(o) for o in items]
@router.post("", response_model=ObjectRead, status_code=status.HTTP_201_CREATED)
async def create_object(db: DbSession, _: CurrentUser, data: ObjectCreate) -> ObjectRead:
try:
obj = await objects_service.create_object(db, data)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Inventory number already in use"
)
return objects_service.build_object_read(obj)
@router.get("/{object_id}", response_model=ObjectRead)
async def get_object(db: DbSession, object_id: uuid.UUID) -> ObjectRead:
obj = await objects_service.get_object(db, object_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return objects_service.build_object_read(obj)
@router.put("/{object_id}", response_model=ObjectRead)
async def update_object(
db: DbSession, _: CurrentUser, object_id: uuid.UUID, data: ObjectUpdate
) -> ObjectRead:
obj = await objects_service.get_object(db, object_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
try:
obj = await objects_service.update_object(db, obj, data)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Inventory number already in use"
)
return objects_service.build_object_read(obj)
@router.delete("/{object_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID) -> None:
obj = await objects_service.get_object(db, object_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
await objects_service.delete_object(db, get_minio_client(), obj)

View File

@@ -0,0 +1,35 @@
import uuid
from fastapi import APIRouter, HTTPException, UploadFile, status
from app.core.dependencies import CurrentUser, DbSession, get_minio_client
from app.models.object_photo import ObjectPhoto
from app.schemas.photo import PhotoRead
from app.services import objects as objects_service
from app.services.photos import build_photo_read, delete_photo, upload_photo
router = APIRouter(prefix="/api/v1", tags=["photos"])
@router.post(
"/objects/{object_id}/photos", response_model=PhotoRead, status_code=status.HTTP_201_CREATED
)
async def upload_object_photo(
db: DbSession, _: CurrentUser, object_id: uuid.UUID, file: UploadFile
) -> PhotoRead:
obj = await objects_service.get_object(db, object_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
try:
photo = await upload_photo(db, get_minio_client(), object_id, file)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return build_photo_read(photo)
@router.delete("/photos/{photo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_object_photo(db: DbSession, _: CurrentUser, photo_id: uuid.UUID) -> None:
photo = await db.get(ObjectPhoto, photo_id)
if photo is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found")
await delete_photo(db, get_minio_client(), photo)

View File

@@ -0,0 +1,30 @@
import uuid
from fastapi import APIRouter, HTTPException, Response, status
from app.core.dependencies import DbSession
from app.services import boxes as boxes_service
from app.services import objects as objects_service
from app.services.qrcodes import box_public_url, generate_qr_png, object_public_url
router = APIRouter(prefix="/api/v1", tags=["qrcodes"])
_PNG_HEADERS = {"Cache-Control": "public, max-age=86400"}
@router.get("/objects/{object_id}/qrcode.png")
async def object_qrcode(db: DbSession, object_id: uuid.UUID) -> Response:
obj = await objects_service.get_object(db, object_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
png = generate_qr_png(object_public_url(obj.id))
return Response(content=png, media_type="image/png", headers=_PNG_HEADERS)
@router.get("/boxes/{box_id}/qrcode.png")
async def box_qrcode(db: DbSession, box_id: uuid.UUID) -> Response:
box = await boxes_service.get_box(db, box_id)
if box is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
png = generate_qr_png(box_public_url(box.id))
return Response(content=png, media_type="image/png", headers=_PNG_HEADERS)

View File

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.schemas.object import ObjectSummary
class BoxSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
class BoxCreate(BaseModel):
name: str
description: str | None = None
parent_box_id: uuid.UUID | None = None
class BoxUpdate(BaseModel):
name: str | None = None
description: str | None = None
parent_box_id: uuid.UUID | None = None
class BoxRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
parent_box_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
qr_code_url: str
ancestors: list[BoxSummary]
children: list[BoxSummary]
objects: list[ObjectSummary]

View File

@@ -0,0 +1,50 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.schemas.photo import PhotoRead
class ObjectSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
inventory_number: str
name: str
class BoxRef(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
class ObjectCreate(BaseModel):
inventory_number: str
name: str
description: str | None = None
box_id: uuid.UUID | None = None
class ObjectUpdate(BaseModel):
inventory_number: str | None = None
name: str | None = None
description: str | None = None
box_id: uuid.UUID | None = None
class ObjectRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
inventory_number: str
name: str
description: str | None
box_id: uuid.UUID | None
box: BoxRef | None
created_at: datetime
updated_at: datetime
qr_code_url: str
photos: list[PhotoRead]

View File

@@ -0,0 +1,12 @@
import uuid
from pydantic import BaseModel, ConfigDict
class PhotoRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
url: str
content_type: str
position: int

View File

View File

@@ -0,0 +1,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import verify_password
from app.models.user import User
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.hashed_password):
return None
return user

View File

@@ -0,0 +1,97 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.box import Box
from app.schemas.box import BoxCreate, BoxRead, BoxSummary, BoxUpdate
from app.schemas.object import ObjectSummary
from app.services.qrcodes import box_public_url
def _select_with_relations():
return select(Box).options(selectinload(Box.children), selectinload(Box.objects))
async def list_boxes(
db: AsyncSession, search: str | None = None, root_only: bool = False
) -> list[Box]:
stmt = select(Box).order_by(Box.name)
if root_only:
stmt = stmt.where(Box.parent_box_id.is_(None))
if search:
stmt = stmt.where(Box.name.ilike(f"%{search}%"))
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_box(db: AsyncSession, box_id: uuid.UUID) -> Box | None:
stmt = _select_with_relations().where(Box.id == box_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def build_box_read(db: AsyncSession, box: Box) -> BoxRead:
ancestors: list[BoxSummary] = []
current_parent_id = box.parent_box_id
depth = 0
while current_parent_id is not None and depth < 100:
parent = await db.get(Box, current_parent_id)
if parent is None:
break
ancestors.append(BoxSummary(id=parent.id, name=parent.name))
current_parent_id = parent.parent_box_id
depth += 1
ancestors.reverse()
return BoxRead(
id=box.id,
name=box.name,
description=box.description,
parent_box_id=box.parent_box_id,
created_at=box.created_at,
updated_at=box.updated_at,
qr_code_url=box_public_url(box.id),
ancestors=ancestors,
children=[BoxSummary.model_validate(c) for c in box.children],
objects=[ObjectSummary.model_validate(o) for o in box.objects],
)
async def _is_descendant(db: AsyncSession, box_id: uuid.UUID, candidate_ancestor_id: uuid.UUID) -> bool:
"""Whether candidate_ancestor_id is box_id itself or one of its descendants."""
if box_id == candidate_ancestor_id:
return True
stmt = select(Box.id).where(Box.parent_box_id == box_id)
result = await db.execute(stmt)
for child_id in result.scalars().all():
if await _is_descendant(db, child_id, candidate_ancestor_id):
return True
return False
async def create_box(db: AsyncSession, data: BoxCreate) -> Box:
box = Box(**data.model_dump())
db.add(box)
await db.commit()
return await get_box(db, box.id)
async def update_box(db: AsyncSession, box: Box, data: BoxUpdate) -> Box:
updates = data.model_dump(exclude_unset=True)
new_parent_id = updates.get("parent_box_id", box.parent_box_id)
if new_parent_id is not None and await _is_descendant(db, box.id, new_parent_id):
raise ValueError("A box cannot be moved into itself or one of its own descendants")
for field, value in updates.items():
setattr(box, field, value)
await db.commit()
return await get_box(db, box.id)
async def delete_box(db: AsyncSession, box: Box) -> None:
child_count = await db.scalar(select(Box.id).where(Box.parent_box_id == box.id).limit(1))
if child_count is not None:
raise ValueError("Cannot delete a box that still contains nested boxes")
await db.delete(box)
await db.commit()

View File

@@ -0,0 +1,69 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.object import InventoryObject
from app.schemas.object import BoxRef, ObjectCreate, ObjectRead, ObjectUpdate
from app.services.photos import build_photo_read, delete_photo
from app.services.qrcodes import object_public_url
def build_object_read(obj: InventoryObject) -> ObjectRead:
return ObjectRead(
id=obj.id,
inventory_number=obj.inventory_number,
name=obj.name,
description=obj.description,
box_id=obj.box_id,
box=BoxRef.model_validate(obj.box) if obj.box else None,
created_at=obj.created_at,
updated_at=obj.updated_at,
qr_code_url=object_public_url(obj.id),
photos=[build_photo_read(p) for p in obj.photos],
)
def _select_with_relations():
return select(InventoryObject).options(
selectinload(InventoryObject.photos), selectinload(InventoryObject.box)
)
async def list_objects(db: AsyncSession, search: str | None = None) -> list[InventoryObject]:
stmt = _select_with_relations().order_by(InventoryObject.created_at.desc())
if search:
like = f"%{search}%"
stmt = stmt.where(
(InventoryObject.inventory_number.ilike(like)) | (InventoryObject.name.ilike(like))
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_object(db: AsyncSession, object_id: uuid.UUID) -> InventoryObject | None:
stmt = _select_with_relations().where(InventoryObject.id == object_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_object(db: AsyncSession, data: ObjectCreate) -> InventoryObject:
obj = InventoryObject(**data.model_dump())
db.add(obj)
await db.commit()
return await get_object(db, obj.id)
async def update_object(db: AsyncSession, obj: InventoryObject, data: ObjectUpdate) -> InventoryObject:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
await db.commit()
return await get_object(db, obj.id)
async def delete_object(db: AsyncSession, minio_client, obj: InventoryObject) -> None:
for photo in list(obj.photos):
await delete_photo(db, minio_client, photo)
await db.delete(obj)
await db.commit()

View File

@@ -0,0 +1,62 @@
import uuid
from mimetypes import guess_extension
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from app.core.config import get_settings
from app.models.object_photo import ObjectPhoto
from app.schemas.photo import PhotoRead
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
def build_photo_read(photo: ObjectPhoto) -> PhotoRead:
settings = get_settings()
url = f"{settings.minio_public_url}/{settings.minio_bucket}/{photo.storage_key}"
return PhotoRead(id=photo.id, url=url, content_type=photo.content_type, position=photo.position)
async def upload_photo(
db: AsyncSession, minio_client, object_id: uuid.UUID, upload_file: UploadFile
) -> ObjectPhoto:
if upload_file.content_type not in ALLOWED_CONTENT_TYPES:
raise ValueError(f"Unsupported content type: {upload_file.content_type}")
settings = get_settings()
extension = guess_extension(upload_file.content_type) or ""
photo_id = uuid.uuid4()
storage_key = f"objects/{object_id}/{photo_id}{extension}"
body = await upload_file.read()
await run_in_threadpool(
minio_client.put_object,
Bucket=settings.minio_bucket,
Key=storage_key,
Body=body,
ContentType=upload_file.content_type,
)
max_position = await db.scalar(
select(func.coalesce(func.max(ObjectPhoto.position), -1)).where(ObjectPhoto.object_id == object_id)
)
photo = ObjectPhoto(
id=photo_id,
object_id=object_id,
storage_key=storage_key,
content_type=upload_file.content_type,
position=max_position + 1,
)
db.add(photo)
await db.commit()
await db.refresh(photo)
return photo
async def delete_photo(db: AsyncSession, minio_client, photo: ObjectPhoto) -> None:
settings = get_settings()
await run_in_threadpool(minio_client.delete_object, Bucket=settings.minio_bucket, Key=photo.storage_key)
await db.delete(photo)
await db.commit()

View File

@@ -0,0 +1,20 @@
import io
import qrcode
from app.core.config import get_settings
def object_public_url(object_id) -> str:
return f"{get_settings().public_base_url}/objects/{object_id}"
def box_public_url(box_id) -> str:
return f"{get_settings().public_base_url}/boxes/{box_id}"
def generate_qr_png(data: str) -> bytes:
img = qrcode.make(data, box_size=10, border=2)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()

31
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[project]
name = "inventory-backend"
version = "0.1.0"
description = "Inventory tracking service API"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.139.2",
"uvicorn[standard]>=0.34.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
"pydantic-settings>=2.7.0",
"argon2-cffi>=23.1.0",
"pyjwt>=2.10.0",
"boto3>=1.35.0",
"qrcode[pil]>=8.0",
"python-multipart>=0.0.20",
"typer>=0.15.0",
]
[dependency-groups]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.25.0",
"httpx>=0.28.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]

52
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,52 @@
import uuid
from collections.abc import AsyncIterator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.db.session import async_session_maker, engine
from app.main import app
from app.models.user import User
@pytest.fixture(autouse=True)
async def _dispose_engine_after_test() -> AsyncIterator[None]:
# Each test runs in its own event loop; pooled asyncpg connections from a
# previous test's loop are unusable in the next one, so drop the pool.
yield
await engine.dispose()
@pytest.fixture
async def db() -> AsyncIterator[AsyncSession]:
async with async_session_maker() as session:
yield session
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def admin_user(db: AsyncSession) -> AsyncIterator[User]:
user = User(username=f"admin-{uuid.uuid4().hex[:8]}", hashed_password=hash_password("adminpass123"))
db.add(user)
await db.commit()
await db.refresh(user)
yield user
await db.delete(user)
await db.commit()
@pytest.fixture
async def auth_headers(client: AsyncClient, admin_user: User) -> dict[str, str]:
res = await client.post(
"/api/v1/auth/token", data={"username": admin_user.username, "password": "adminpass123"}
)
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}

View File

@@ -0,0 +1,23 @@
from httpx import AsyncClient
from app.models.user import User
async def test_login_success(client: AsyncClient, admin_user: User) -> None:
res = await client.post(
"/api/v1/auth/token", data={"username": admin_user.username, "password": "adminpass123"}
)
assert res.status_code == 200
assert res.json()["token_type"] == "bearer"
async def test_login_wrong_password(client: AsyncClient, admin_user: User) -> None:
res = await client.post(
"/api/v1/auth/token", data={"username": admin_user.username, "password": "wrong"}
)
assert res.status_code == 401
async def test_mutation_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/objects", json={"inventory_number": "X", "name": "X"})
assert res.status_code == 401

View File

@@ -0,0 +1,49 @@
from httpx import AsyncClient
async def test_nested_box_restrict_delete_and_cycle_prevention(
client: AsyncClient, auth_headers: dict[str, str]
) -> None:
parent_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Parent"})
parent_id = parent_res.json()["id"]
child_res = await client.post(
"/api/v1/boxes", headers=auth_headers, json={"name": "Child", "parent_box_id": parent_id}
)
child_id = child_res.json()["id"]
# Cannot delete a box that still has children.
delete_res = await client.delete(f"/api/v1/boxes/{parent_id}", headers=auth_headers)
assert delete_res.status_code == 409
# Cannot move a box into its own descendant.
cycle_res = await client.put(
f"/api/v1/boxes/{parent_id}", headers=auth_headers, json={"parent_box_id": child_id}
)
assert cycle_res.status_code == 409
box_detail = await client.get(f"/api/v1/boxes/{child_id}")
assert box_detail.json()["ancestors"] == [{"id": parent_id, "name": "Parent"}]
await client.delete(f"/api/v1/boxes/{child_id}", headers=auth_headers)
await client.delete(f"/api/v1/boxes/{parent_id}", headers=auth_headers)
async def test_deleting_box_unassigns_objects(client: AsyncClient, auth_headers: dict[str, str]) -> None:
box_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Temp box"})
box_id = box_res.json()["id"]
obj_res = await client.post(
"/api/v1/objects",
headers=auth_headers,
json={"inventory_number": "BOX-TEST-1", "name": "In a box", "box_id": box_id},
)
object_id = obj_res.json()["id"]
assert obj_res.json()["box_id"] == box_id
await client.delete(f"/api/v1/boxes/{box_id}", headers=auth_headers)
obj_after = await client.get(f"/api/v1/objects/{object_id}")
assert obj_after.json()["box_id"] is None
await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)

View File

@@ -0,0 +1,62 @@
import uuid
from httpx import AsyncClient
async def test_create_read_update_delete_object(client: AsyncClient, auth_headers: dict[str, str]) -> None:
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
create_res = await client.post(
"/api/v1/objects",
headers=auth_headers,
json={"inventory_number": inventory_number, "name": "Test drill", "description": "for tests"},
)
assert create_res.status_code == 201
object_id = create_res.json()["id"]
public_res = await client.get(f"/api/v1/objects/{object_id}")
assert public_res.status_code == 200
assert public_res.json()["inventory_number"] == inventory_number
assert public_res.json()["photos"] == []
update_res = await client.put(
f"/api/v1/objects/{object_id}", headers=auth_headers, json={"name": "Renamed drill"}
)
assert update_res.status_code == 200
assert update_res.json()["name"] == "Renamed drill"
delete_res = await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)
assert delete_res.status_code == 204
missing_res = await client.get(f"/api/v1/objects/{object_id}")
assert missing_res.status_code == 404
async def test_duplicate_inventory_number_conflicts(client: AsyncClient, auth_headers: dict[str, str]) -> None:
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
payload = {"inventory_number": inventory_number, "name": "First"}
first = await client.post("/api/v1/objects", headers=auth_headers, json=payload)
assert first.status_code == 201
dup = await client.post(
"/api/v1/objects", headers=auth_headers, json={**payload, "name": "Second"}
)
assert dup.status_code == 409
await client.delete(f"/api/v1/objects/{first.json()['id']}", headers=auth_headers)
async def test_object_qrcode(client: AsyncClient, auth_headers: dict[str, str]) -> None:
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
create_res = await client.post(
"/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "QR test"}
)
object_id = create_res.json()["id"]
qr_res = await client.get(f"/api/v1/objects/{object_id}/qrcode.png")
assert qr_res.status_code == 200
assert qr_res.headers["content-type"] == "image/png"
assert qr_res.content[:8] == b"\x89PNG\r\n\x1a\n"
await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)

1023
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff