start
This commit is contained in:
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal 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
3
backend/README.md
Normal 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
38
backend/alembic.ini
Normal 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
55
backend/alembic/env.py
Normal 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())
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
80
backend/alembic/versions/cdcadb640107_initial_schema.py
Normal file
80
backend/alembic/versions/cdcadb640107_initial_schema.py
Normal 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
0
backend/app/__init__.py
Normal file
41
backend/app/cli.py
Normal file
41
backend/app/cli.py
Normal 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()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
28
backend/app/core/config.py
Normal file
28
backend/app/core/config.py
Normal 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()
|
||||
47
backend/app/core/dependencies.py
Normal file
47
backend/app/core/dependencies.py
Normal 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,
|
||||
)
|
||||
40
backend/app/core/security.py
Normal file
40
backend/app/core/security.py
Normal 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)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
14
backend/app/db/base.py
Normal file
14
backend/app/db/base.py
Normal 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
13
backend/app/db/session.py
Normal 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
28
backend/app/main.py
Normal 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"}
|
||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal 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
31
backend/app/models/box.py
Normal 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")
|
||||
34
backend/app/models/object.py
Normal file
34
backend/app/models/object.py
Normal 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"
|
||||
)
|
||||
27
backend/app/models/object_photo.py
Normal file
27
backend/app/models/object_photo.py
Normal 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")
|
||||
17
backend/app/models/user.py
Normal file
17
backend/app/models/user.py
Normal 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())
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
23
backend/app/routers/auth.py
Normal file
23
backend/app/routers/auth.py
Normal 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))
|
||||
54
backend/app/routers/boxes.py
Normal file
54
backend/app/routers/boxes.py
Normal 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))
|
||||
61
backend/app/routers/objects.py
Normal file
61
backend/app/routers/objects.py
Normal 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)
|
||||
35
backend/app/routers/photos.py
Normal file
35
backend/app/routers/photos.py
Normal 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)
|
||||
30
backend/app/routers/qrcodes.py
Normal file
30
backend/app/routers/qrcodes.py
Normal 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)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
6
backend/app/schemas/auth.py
Normal file
6
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
40
backend/app/schemas/box.py
Normal file
40
backend/app/schemas/box.py
Normal 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]
|
||||
50
backend/app/schemas/object.py
Normal file
50
backend/app/schemas/object.py
Normal 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]
|
||||
12
backend/app/schemas/photo.py
Normal file
12
backend/app/schemas/photo.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
13
backend/app/services/auth.py
Normal file
13
backend/app/services/auth.py
Normal 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
|
||||
97
backend/app/services/boxes.py
Normal file
97
backend/app/services/boxes.py
Normal 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()
|
||||
69
backend/app/services/objects.py
Normal file
69
backend/app/services/objects.py
Normal 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()
|
||||
62
backend/app/services/photos.py
Normal file
62
backend/app/services/photos.py
Normal 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()
|
||||
20
backend/app/services/qrcodes.py
Normal file
20
backend/app/services/qrcodes.py
Normal 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
31
backend/pyproject.toml
Normal 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
52
backend/tests/conftest.py
Normal 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}"}
|
||||
23
backend/tests/test_auth.py
Normal file
23
backend/tests/test_auth.py
Normal 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
|
||||
49
backend/tests/test_boxes.py
Normal file
49
backend/tests/test_boxes.py
Normal 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)
|
||||
62
backend/tests/test_objects.py
Normal file
62
backend/tests/test_objects.py
Normal 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
1023
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user