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

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()