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

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Copy to .env and adjust before running `docker compose up`.
POSTGRES_USER=inventory
POSTGRES_PASSWORD=change-me
POSTGRES_DB=inventory
MINIO_ROOT_USER=inventory
MINIO_ROOT_PASSWORD=change-me-too
MINIO_BUCKET=inventory
# Base URL the browser uses to fetch photos directly from MinIO.
# Local dev: the host-exposed MinIO port. Prod: the public MinIO/CDN domain.
MINIO_PUBLIC_URL=http://localhost:9000
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_SECRET_KEY=change-me-to-a-random-secret
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=600
# Base URL encoded into QR codes and used for CORS.
# Local dev: the frontend dev server. Prod: the real domain.
PUBLIC_BASE_URL=http://localhost:5173
ALLOWED_ORIGINS=["http://localhost:5173"]

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.env
# Python
.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
# Node
node_modules/
dist/
# Editors / OS
.DS_Store
.idea/
.vscode/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Инвентаризация
Сервис учёта инвентаря: объекты (инвентарный номер, описание, фото) складываются в ящики (с вложенностью), у каждого объекта и ящика есть QR-код — сканирование обычной камерой телефона открывает публичную страницу с описанием и фото, без установки приложений. Правки защищены простым admin-логином, просмотр — публичный.
Стек: FastAPI + SQLAlchemy (async) + PostgreSQL, MinIO (S3) для фото, React + Vite + Tailwind на фронте, всё поднимается через Docker Compose.
## Быстрый старт (локально)
1. Скопируйте `.env.example` в `.env` и при желании поменяйте пароли/секреты:
```
cp .env.example .env
```
2. Поднимите весь стек:
```
docker compose up -d --build
```
Первый запуск: поднимутся Postgres и MinIO, применятся миграции (сервис `migrate`), создастся бакет MinIO с публичным доступом на чтение (сервис `minio-init`), затем стартуют `backend` и `frontend`.
3. Создайте первого администратора:
```
docker compose exec backend uv run python -m app.cli --username admin
```
(спросит пароль интерактивно; логин без прав — без него нельзя добавлять/редактировать объекты и ящики).
4. Откройте:
- фронтенд — http://localhost:5173 (админка на `/admin/objects`, `/admin/boxes`)
- API-документация — http://localhost:8000/docs
- консоль MinIO — http://localhost:9001 (логин/пароль — `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` из `.env`)
Публичные страницы объекта/ящика (`/objects/:id`, `/boxes/:id`) открываются без авторизации — именно на них ведут QR-коды.
## Разработка
Исходники `backend/` и `frontend/` смонтированы в контейнеры (см. `docker-compose.override.yml`), оба сервиса перезапускаются на лету (`uvicorn --reload`, `vite`). Изменения в коде подхватываются без пересборки образа; пересборка (`--build`) нужна только при изменении зависимостей (`pyproject.toml`/`package.json`) или Dockerfile.
Новая миграция после изменения моделей:
```
docker compose exec backend uv run alembic revision --autogenerate -m "..."
docker compose exec backend uv run alembic upgrade head
```
Тесты бэкенда:
```
docker compose exec backend uv run pytest
```
## Прод-деплой
Пока не настроен (нет `docker-compose.prod.yml`, реверс-прокси и TLS) — это следующий шаг, когда сервис будет готов выкатывать на сервер с доменом. Тогда потребуется поменять в `.env` `PUBLIC_BASE_URL` и `MINIO_PUBLIC_URL` на реальный домен (они зашиты в QR-коды и ссылки на фото) и добавить реверс-прокси (например, Caddy) для HTTPS.

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

View File

@@ -0,0 +1,40 @@
services:
postgres:
ports:
- "5432:5432"
minio:
ports:
- "9000:9000"
- "9001:9001"
migrate:
build:
target: dev
volumes:
- ./backend:/app
- backend-venv:/app/.venv
backend:
build:
target: dev
volumes:
- ./backend:/app
- backend-venv:/app/.venv
ports:
- "8000:8000"
frontend:
build:
target: dev
volumes:
- ./frontend:/app
- frontend-node-modules:/app/node_modules
ports:
- "5173:5173"
environment:
CHOKIDAR_USEPOLLING: "true"
volumes:
backend-venv:
frontend-node-modules:

98
docker-compose.yml Normal file
View File

@@ -0,0 +1,98 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${MINIO_BUCKET}
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
(mc mb local/$$MINIO_BUCKET || true) &&
mc anonymous set download local/$$MINIO_BUCKET
"
migrate:
build:
context: ./backend
target: prod
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${MINIO_BUCKET}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_EXPIRE_MINUTES: ${JWT_EXPIRE_MINUTES}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
command: uv run alembic upgrade head
depends_on:
postgres:
condition: service_healthy
backend:
build:
context: ./backend
target: prod
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${MINIO_BUCKET}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_EXPIRE_MINUTES: ${JWT_EXPIRE_MINUTES}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
depends_on:
migrate:
condition: service_completed_successfully
minio-init:
condition: service_completed_successfully
frontend:
build:
context: ./frontend
target: prod
depends_on:
- backend
volumes:
pgdata:
minio-data:

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS dev
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
FROM base AS build
COPY . .
RUN npm run build
FROM nginx:alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Инвентаризация</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html;
}
}

1780
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "inventory-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.82.0",
"react-router": "^8.2.0",
"tailwindcss": "^4.3.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,70 @@
const API_BASE = '/api/v1'
const TOKEN_STORAGE_KEY = 'inventory_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token)
else localStorage.removeItem(TOKEN_STORAGE_KEY)
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers = new Headers(options.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (!res.ok) {
let detail = res.statusText
try {
const data = await res.json()
if (typeof data.detail === 'string') detail = data.detail
} catch {
// response had no JSON body
}
throw new ApiError(res.status, detail)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body ?? {}),
}),
put: <T,>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body ?? {}) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password })
const res = await fetch(`${API_BASE}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) {
throw new ApiError(res.status, 'Неверный логин или пароль')
}
const data = (await res.json()) as { access_token: string }
return data.access_token
}

61
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,61 @@
export interface Photo {
id: string
url: string
content_type: string
position: number
}
export interface BoxRef {
id: string
name: string
}
export interface BoxSummary {
id: string
name: string
}
export interface ObjectSummary {
id: string
inventory_number: string
name: string
}
export interface InventoryObject {
id: string
inventory_number: string
name: string
description: string | null
box_id: string | null
box: BoxRef | null
created_at: string
updated_at: string
qr_code_url: string
photos: Photo[]
}
export interface ObjectInput {
inventory_number: string
name: string
description?: string | null
box_id?: string | null
}
export interface Box {
id: string
name: string
description: string | null
parent_box_id: string | null
created_at: string
updated_at: string
qr_code_url: string
ancestors: BoxSummary[]
children: BoxSummary[]
objects: ObjectSummary[]
}
export interface BoxInput {
name: string
description?: string | null
parent_box_id?: string | null
}

View File

@@ -0,0 +1,38 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
import { getToken, login as apiLogin, setToken as persistToken } from '../api/client'
interface AuthContextValue {
isAuthenticated: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(() => getToken())
const value = useMemo<AuthContextValue>(
() => ({
isAuthenticated: !!token,
login: async (username: string, password: string) => {
const newToken = await apiLogin(username, password)
persistToken(newToken)
setTokenState(newToken)
},
logout: () => {
persistToken(null)
setTokenState(null)
},
}),
[token],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router'
import { useAuth } from './AuthContext'
export function RequireAuth({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth()
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,14 @@
import { Link } from 'react-router'
import type { BoxSummary } from '../api/types'
export function BoxCard({ box, linkBase = '/boxes' }: { box: BoxSummary; linkBase?: string }) {
return (
<Link
to={`${linkBase}/${box.id}`}
className="flex items-center gap-2 rounded-lg border border-slate-200 p-3 transition hover:border-slate-400 dark:border-slate-800 dark:hover:border-slate-600"
>
<span aria-hidden>📦</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{box.name}</span>
</Link>
)
}

View File

@@ -0,0 +1,26 @@
import { Link } from 'react-router'
import type { BoxSummary } from '../api/types'
export function Breadcrumbs({
ancestors,
current,
basePath = '/boxes',
}: {
ancestors: BoxSummary[]
current: string
basePath?: string
}) {
return (
<nav className="flex flex-wrap items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
{ancestors.map((box) => (
<span key={box.id} className="flex items-center gap-1">
<Link to={`${basePath}/${box.id}`} className="hover:underline">
{box.name}
</Link>
<span>/</span>
</span>
))}
<span className="font-medium text-slate-900 dark:text-slate-100">{current}</span>
</nav>
)
}

View File

@@ -0,0 +1,23 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'secondary' | 'danger'
const VARIANT_CLASSES: Record<Variant, string> = {
primary: 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200',
secondary:
'bg-transparent text-slate-900 border border-slate-300 hover:bg-slate-100 dark:text-slate-100 dark:border-slate-700 dark:hover:bg-slate-800',
danger: 'bg-red-600 text-white hover:bg-red-700',
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
}
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,35 @@
import { NavLink, Outlet } from 'react-router'
import { useAuth } from '../auth/AuthContext'
import { Button } from './Button'
const linkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${
isActive
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`
export function AdminLayout() {
const { logout } = useAuth()
return (
<div className="mx-auto flex min-h-svh max-w-4xl flex-col">
<header className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<nav className="flex gap-1">
<NavLink to="/admin/objects" className={linkClass}>
Объекты
</NavLink>
<NavLink to="/admin/boxes" className={linkClass}>
Ящики
</NavLink>
</nav>
<Button variant="secondary" onClick={logout}>
Выйти
</Button>
</header>
<main className="flex-1 p-4">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Link } from 'react-router'
import type { ObjectSummary } from '../api/types'
export function ObjectCard({ object, linkBase = '/objects' }: { object: ObjectSummary; linkBase?: string }) {
return (
<Link
to={`${linkBase}/${object.id}`}
className="flex flex-col gap-1 rounded-lg border border-slate-200 p-3 transition hover:border-slate-400 dark:border-slate-800 dark:hover:border-slate-600"
>
<span className="font-medium text-slate-900 dark:text-slate-100">{object.name}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{object.inventory_number}</span>
</Link>
)
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
import type { Photo } from '../api/types'
export function PhotoGallery({ photos }: { photos: Photo[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
if (photos.length === 0) {
return <p className="text-sm text-slate-500 dark:text-slate-400">Фото не добавлены</p>
}
return (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{photos.map((photo, index) => (
<button
key={photo.id}
onClick={() => setOpenIndex(index)}
className="aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800"
>
<img src={photo.url} alt="" className="h-full w-full object-cover" loading="lazy" />
</button>
))}
</div>
{openIndex !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setOpenIndex(null)}
>
<img
src={photos[openIndex].url}
alt=""
className="max-h-full max-w-full rounded-lg object-contain"
/>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,53 @@
import { useRef, useState } from 'react'
import type { Photo } from '../api/types'
import { useDeletePhoto, useUploadPhoto } from '../hooks/useObjects'
export function PhotoUploader({ objectId, photos }: { objectId: string; photos: Photo[] }) {
const uploadPhoto = useUploadPhoto(objectId)
const deletePhoto = useDeletePhoto(objectId)
const fileInputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
setError(null)
for (const file of Array.from(files)) {
try {
await uploadPhoto.mutateAsync(file)
} catch {
setError('Не удалось загрузить одно или несколько фото')
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800">
<img src={photo.url} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => deletePhoto.mutate(photo.id)}
className="absolute top-1 right-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
>
Удалить
</button>
</div>
))}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
onChange={(e) => handleFiles(e.target.files)}
className="block text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-900"
/>
{uploadPhoto.isPending && <p className="text-sm text-slate-500">Загрузка...</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { Button } from './Button'
function handlePrint(url: string, title: string) {
const win = window.open('', '_blank', 'width=400,height=500')
if (!win) return
win.document.write(
`<html><head><title>${title}</title></head><body style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif">` +
`<img src="${url}" onload="window.print()" style="width:280px;height:280px" />` +
`<p style="margin-top:12px">${title}</p>` +
`</body></html>`,
)
win.document.close()
}
export function QrCodeCard({ url, title, fileName }: { url: string; title: string; fileName: string }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<img src={url} alt={`QR-код: ${title}`} className="h-40 w-40" />
<div className="flex gap-2">
<a href={url} download={fileName}>
<Button type="button" variant="secondary">
Скачать
</Button>
</a>
<Button type="button" variant="secondary" onClick={() => handlePrint(url, title)}>
Печать
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Box, BoxInput, BoxSummary } from '../api/types'
export function useBoxesList(search: string, rootOnly = false) {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (rootOnly) params.set('root_only', 'true')
const qs = params.toString()
return useQuery({
queryKey: ['boxes', 'list', search, rootOnly],
queryFn: () => api.get<BoxSummary[]>(`/boxes${qs ? `?${qs}` : ''}`),
})
}
export function useBox(id: string | undefined) {
return useQuery({
queryKey: ['boxes', id],
queryFn: () => api.get<Box>(`/boxes/${id}`),
enabled: !!id,
})
}
export function useCreateBox() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: BoxInput) => api.post<Box>('/boxes', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}
export function useUpdateBox(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Partial<BoxInput>) => api.put<Box>(`/boxes/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}
export function useDeleteBox() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/boxes/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}

View File

@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import type { InventoryObject, ObjectInput, Photo } from '../api/types'
export function useObjectsList(search: string) {
return useQuery({
queryKey: ['objects', search],
queryFn: () =>
api.get<InventoryObject[]>(`/objects${search ? `?search=${encodeURIComponent(search)}` : ''}`),
})
}
export function useObject(id: string | undefined) {
return useQuery({
queryKey: ['objects', id],
queryFn: () => api.get<InventoryObject>(`/objects/${id}`),
enabled: !!id,
})
}
export function useCreateObject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ObjectInput) => api.post<InventoryObject>('/objects', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects'] }),
})
}
export function useUpdateObject(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Partial<ObjectInput>) => api.put<InventoryObject>(`/objects/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objects'] })
queryClient.invalidateQueries({ queryKey: ['boxes'] })
},
})
}
export function useDeleteObject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/objects/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objects'] })
queryClient.invalidateQueries({ queryKey: ['boxes'] })
},
})
}
export function useUploadPhoto(objectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<Photo>(`/objects/${objectId}/photos`, form)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects', objectId] }),
})
}
export function useDeletePhoto(objectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (photoId: string) => api.delete(`/photos/${photoId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects', objectId] }),
})
}

18
frontend/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
@import "tailwindcss";
@theme {
--font-sans: system-ui, "Segoe UI", Roboto, sans-serif;
}
:root {
color-scheme: light dark;
}
body {
margin: 0;
@apply bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100;
}
#root {
min-height: 100svh;
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { AuthProvider } from './auth/AuthContext'
import './index.css'
import { AppRoutes } from './router'
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 10_000 } },
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,71 @@
import { useState, type FormEvent } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router'
import { useAuth } from '../auth/AuthContext'
import { Button } from '../components/Button'
export function LoginPage() {
const { isAuthenticated, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
if (isAuthenticated) {
const from = (location.state as { from?: Location })?.from?.pathname ?? '/admin/objects'
return <Navigate to={from} replace />
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await login(username, password)
navigate('/admin/objects', { replace: true })
} catch {
setError('Неверный логин или пароль')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-svh items-center justify-center p-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-xl font-semibold">Вход в админ-панель</h1>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
Логин
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Пароль
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Вход...' : 'Войти'}
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { Link, useNavigate, useParams } from 'react-router'
import { Breadcrumbs } from '../../components/Breadcrumbs'
import { Button } from '../../components/Button'
import { QrCodeCard } from '../../components/QrCodeCard'
import { useBox, useBoxesList, useDeleteBox } from '../../hooks/useBoxes'
import { useDeleteObject } from '../../hooks/useObjects'
export function BoxBrowsePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const deleteBox = useDeleteBox()
const deleteObject = useDeleteObject()
const { data: box, isLoading: boxLoading } = useBox(id)
const { data: rootBoxes, isLoading: rootLoading } = useBoxesList('', true)
if (id) {
if (boxLoading) return <p className="text-slate-500">Загрузка...</p>
if (!box) return <p className="text-slate-500">Ящик не найден</p>
return (
<div className="space-y-6">
<Breadcrumbs ancestors={box.ancestors} current={box.name} basePath="/admin/boxes" />
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-xl font-semibold">{box.name}</h1>
{box.description && <p className="text-sm text-slate-500 dark:text-slate-400">{box.description}</p>}
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/boxes/${box.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (!confirm(`Удалить ящик «${box.name}»?`)) return
deleteBox.mutate(box.id, {
onSuccess: () => navigate(box.parent_box_id ? `/admin/boxes/${box.parent_box_id}` : '/admin/boxes'),
})
}}
>
Удалить
</Button>
</div>
</div>
<QrCodeCard url={`/api/v1/boxes/${box.id}/qrcode.png`} title={box.name} fileName={`${box.name}.png`} />
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Вложенные ящики</h2>
<Link to={`/admin/boxes/new?parent=${box.id}`} className="text-sm text-slate-600 hover:underline dark:text-slate-300">
+ Добавить ящик
</Link>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{box.children.map((child) => (
<Link
key={child.id}
to={`/admin/boxes/${child.id}`}
className="flex items-center gap-2 py-2 hover:underline"
>
📦 {child.name}
</Link>
))}
{box.children.length === 0 && <p className="py-2 text-sm text-slate-500 dark:text-slate-400">Нет вложенных ящиков</p>}
</div>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Объекты</h2>
<Link to={`/admin/objects/new`} className="text-sm text-slate-600 hover:underline dark:text-slate-300">
+ Добавить объект
</Link>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{box.objects.map((object) => (
<div key={object.id} className="flex items-center justify-between gap-2 py-2">
<div>
<p className="font-medium">{object.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{object.inventory_number}</p>
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/objects/${object.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (confirm(`Удалить объект «${object.name}»?`)) deleteObject.mutate(object.id)
}}
>
Удалить
</Button>
</div>
</div>
))}
{box.objects.length === 0 && <p className="py-2 text-sm text-slate-500 dark:text-slate-400">В этом ящике пока пусто</p>}
</div>
</section>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Ящики</h1>
<Link to="/admin/boxes/new">
<Button>Добавить</Button>
</Link>
</div>
{rootLoading && <p className="text-slate-500">Загрузка...</p>}
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{rootBoxes?.map((rootBox) => (
<Link key={rootBox.id} to={`/admin/boxes/${rootBox.id}`} className="flex items-center gap-2 py-3 hover:underline">
📦 {rootBox.name}
</Link>
))}
{rootBoxes?.length === 0 && !rootLoading && <p className="py-4 text-slate-500">Ящиков пока нет</p>}
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import { z } from 'zod'
import { ApiError } from '../../api/client'
import { Button } from '../../components/Button'
import { useBox, useBoxesList, useCreateBox, useUpdateBox } from '../../hooks/useBoxes'
const schema = z.object({
name: z.string().min(1, 'Обязательное поле'),
description: z.string(),
parent_box_id: z.string(),
})
type FormValues = z.infer<typeof schema>
export function BoxFormPage() {
const { id } = useParams<{ id: string }>()
const [searchParams] = useSearchParams()
const isEditing = !!id
const navigate = useNavigate()
const { data: box } = useBox(id)
const { data: allBoxes } = useBoxesList('')
const createBox = useCreateBox()
const updateBox = useUpdateBox(id ?? '')
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', description: '', parent_box_id: searchParams.get('parent') ?? '' },
})
useEffect(() => {
if (box) {
reset({
name: box.name,
description: box.description ?? '',
parent_box_id: box.parent_box_id ?? '',
})
}
}, [box, reset])
async function onSubmit(values: FormValues) {
const payload = {
name: values.name,
description: values.description || null,
parent_box_id: values.parent_box_id || null,
}
try {
if (isEditing) {
const updated = await updateBox.mutateAsync(payload)
navigate(`/admin/boxes/${updated.id}`)
} else {
const created = await createBox.mutateAsync(payload)
navigate(`/admin/boxes/${created.id}`)
}
} catch (err) {
if (err instanceof ApiError) {
setError('root', { message: err.message })
} else {
setError('root', { message: 'Не удалось сохранить ящик' })
}
}
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-xl font-semibold">{isEditing ? 'Изменить ящик' : 'Новый ящик'}</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Название</label>
<input
{...register('name')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Описание</label>
<textarea
{...register('description')}
rows={3}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Родительский ящик</label>
<select
{...register('parent_box_id')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
>
<option value=""> верхний уровень </option>
{allBoxes
?.filter((b) => b.id !== id)
.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>
Отмена
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate, useParams } from 'react-router'
import { z } from 'zod'
import { api, ApiError } from '../../api/client'
import { Button } from '../../components/Button'
import { PhotoUploader } from '../../components/PhotoUploader'
import { QrCodeCard } from '../../components/QrCodeCard'
import { useBoxesList } from '../../hooks/useBoxes'
import { useCreateObject, useObject, useUpdateObject } from '../../hooks/useObjects'
interface PendingPhoto {
file: File
previewUrl: string
}
const schema = z.object({
inventory_number: z.string().min(1, 'Обязательное поле'),
name: z.string().min(1, 'Обязательное поле'),
description: z.string(),
box_id: z.string(),
})
type FormValues = z.infer<typeof schema>
export function ObjectFormPage() {
const { id } = useParams<{ id: string }>()
const isEditing = !!id
const navigate = useNavigate()
const { data: object } = useObject(id)
const { data: boxes } = useBoxesList('')
const createObject = useCreateObject()
const updateObject = useUpdateObject(id ?? '')
const [pendingPhotos, setPendingPhotos] = useState<PendingPhoto[]>([])
const pendingFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
return () => {
pendingPhotos.forEach((p) => URL.revokeObjectURL(p.previewUrl))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function addPendingPhotos(files: FileList | null) {
if (!files || files.length === 0) return
const added = Array.from(files).map((file) => ({ file, previewUrl: URL.createObjectURL(file) }))
setPendingPhotos((prev) => [...prev, ...added])
if (pendingFileInputRef.current) pendingFileInputRef.current.value = ''
}
function removePendingPhoto(index: number) {
setPendingPhotos((prev) => {
URL.revokeObjectURL(prev[index].previewUrl)
return prev.filter((_, i) => i !== index)
})
}
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { inventory_number: '', name: '', description: '', box_id: '' },
})
useEffect(() => {
if (object) {
reset({
inventory_number: object.inventory_number,
name: object.name,
description: object.description ?? '',
box_id: object.box_id ?? '',
})
}
}, [object, reset])
async function onSubmit(values: FormValues) {
const payload = {
inventory_number: values.inventory_number,
name: values.name,
description: values.description || null,
box_id: values.box_id || null,
}
try {
if (isEditing) {
await updateObject.mutateAsync(payload)
navigate('/admin/objects')
} else {
const created = await createObject.mutateAsync(payload)
const failedFiles: string[] = []
for (const { file } of pendingPhotos) {
const form = new FormData()
form.append('file', file)
try {
await api.post(`/objects/${created.id}/photos`, form)
} catch {
failedFiles.push(file.name)
}
}
if (failedFiles.length > 0) {
// The object itself is already saved; surface the failure before leaving the page.
alert(`Объект сохранён, но не удалось загрузить: ${failedFiles.join(', ')}. Попробуйте ещё раз на странице объекта.`)
}
navigate(`/admin/objects/${created.id}/edit`)
}
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('inventory_number', { message: 'Этот инвентарный номер уже используется' })
} else {
setError('root', { message: 'Не удалось сохранить объект' })
}
}
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-xl font-semibold">{isEditing ? 'Изменить объект' : 'Новый объект'}</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Инвентарный номер</label>
<input
{...register('inventory_number')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.inventory_number && <p className="text-sm text-red-600">{errors.inventory_number.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Название</label>
<input
{...register('name')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Описание</label>
<textarea
{...register('description')}
rows={4}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Ящик</label>
<select
{...register('box_id')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
>
<option value=""> без ящика </option>
{boxes?.map((box) => (
<option key={box.id} value={box.id}>
{box.name}
</option>
))}
</select>
</div>
{!isEditing && (
<div className="space-y-1">
<label className="text-sm font-medium">Фотографии</label>
{pendingPhotos.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{pendingPhotos.map((photo, index) => (
<div
key={photo.previewUrl}
className="group relative aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800"
>
<img src={photo.previewUrl} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removePendingPhoto(index)}
className="absolute top-1 right-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
>
Удалить
</button>
</div>
))}
</div>
)}
<input
ref={pendingFileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
onChange={(e) => addPendingPhotos(e.target.files)}
className="block text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-900"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">Загрузятся сразу после сохранения объекта</p>
</div>
)}
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button type="button" variant="secondary" onClick={() => navigate('/admin/objects')}>
Отмена
</Button>
</div>
</form>
{isEditing && object && (
<>
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Фотографии</h2>
<PhotoUploader objectId={object.id} photos={object.photos} />
</section>
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">QR-код</h2>
<QrCodeCard
url={`/api/v1/objects/${object.id}/qrcode.png`}
title={object.name}
fileName={`${object.inventory_number}.png`}
/>
</section>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useDeleteObject, useObjectsList } from '../../hooks/useObjects'
import { Button } from '../../components/Button'
export function ObjectListPage() {
const [search, setSearch] = useState('')
const { data: objects, isLoading } = useObjectsList(search)
const deleteObject = useDeleteObject()
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Объекты</h1>
<Link to="/admin/objects/new">
<Button>Добавить</Button>
</Link>
</div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по номеру или названию..."
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{isLoading && <p className="text-slate-500">Загрузка...</p>}
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{objects?.map((object) => (
<div key={object.id} className="flex items-center justify-between gap-2 py-3">
<div>
<p className="font-medium">{object.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{object.inventory_number}
{object.box && ` · ${object.box.name}`}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/objects/${object.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (confirm(`Удалить объект «${object.name}»?`)) deleteObject.mutate(object.id)
}}
>
Удалить
</Button>
</div>
</div>
))}
{objects?.length === 0 && !isLoading && <p className="py-4 text-slate-500">Ничего не найдено</p>}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { useParams } from 'react-router'
import { useBox } from '../../hooks/useBoxes'
import { Breadcrumbs } from '../../components/Breadcrumbs'
import { BoxCard } from '../../components/BoxCard'
import { ObjectCard } from '../../components/ObjectCard'
export function BoxDetailPage() {
const { id } = useParams<{ id: string }>()
const { data: box, isLoading, isError } = useBox(id)
if (isLoading) {
return <p className="p-4 text-center text-slate-500">Загрузка...</p>
}
if (isError || !box) {
return <p className="p-4 text-center text-slate-500">Ящик не найден</p>
}
return (
<div className="mx-auto max-w-lg space-y-4 p-4">
<Breadcrumbs ancestors={box.ancestors} current={box.name} />
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{box.name}</h1>
{box.description && <p className="text-slate-700 dark:text-slate-300">{box.description}</p>}
{box.children.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Вложенные ящики</h2>
<div className="grid gap-2">
{box.children.map((child) => (
<BoxCard key={child.id} box={child} />
))}
</div>
</section>
)}
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Объекты</h2>
{box.objects.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">В этом ящике пока пусто</p>
) : (
<div className="grid gap-2">
{box.objects.map((object) => (
<ObjectCard key={object.id} object={object} />
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Link, useParams } from 'react-router'
import { useObject } from '../../hooks/useObjects'
import { PhotoGallery } from '../../components/PhotoGallery'
export function ObjectDetailPage() {
const { id } = useParams<{ id: string }>()
const { data: object, isLoading, isError } = useObject(id)
if (isLoading) {
return <p className="p-4 text-center text-slate-500">Загрузка...</p>
}
if (isError || !object) {
return <p className="p-4 text-center text-slate-500">Объект не найден</p>
}
return (
<div className="mx-auto max-w-lg space-y-4 p-4">
{object.box && (
<Link to={`/boxes/${object.box.id}`} className="text-sm text-slate-500 hover:underline dark:text-slate-400">
{object.box.name}
</Link>
)}
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{object.name}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Инв. номер: {object.inventory_number}</p>
</div>
{object.description && (
<p className="whitespace-pre-wrap text-slate-700 dark:text-slate-300">{object.description}</p>
)}
<PhotoGallery photos={object.photos} />
</div>
)
}

47
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { Navigate, Route, Routes } from 'react-router'
import { useAuth } from './auth/AuthContext'
import { RequireAuth } from './auth/RequireAuth'
import { AdminLayout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { BoxBrowsePage } from './pages/admin/BoxBrowsePage'
import { BoxFormPage } from './pages/admin/BoxFormPage'
import { ObjectFormPage } from './pages/admin/ObjectFormPage'
import { ObjectListPage } from './pages/admin/ObjectListPage'
import { BoxDetailPage } from './pages/public/BoxDetailPage'
import { ObjectDetailPage } from './pages/public/ObjectDetailPage'
function Home() {
const { isAuthenticated } = useAuth()
return <Navigate to={isAuthenticated ? '/admin/objects' : '/login'} replace />
}
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/objects/:id" element={<ObjectDetailPage />} />
<Route path="/boxes/:id" element={<BoxDetailPage />} />
<Route
path="/admin"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route path="objects" element={<ObjectListPage />} />
<Route path="objects/new" element={<ObjectFormPage />} />
<Route path="objects/:id/edit" element={<ObjectFormPage />} />
<Route path="boxes" element={<BoxBrowsePage />} />
<Route path="boxes/new" element={<BoxFormPage />} />
<Route path="boxes/:id" element={<BoxBrowsePage />} />
<Route path="boxes/:id/edit" element={<BoxFormPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: true,
port: 5173,
// Dev-only: this server never leaves the docker-compose network / localhost.
allowedHosts: true,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from inventory!")
if __name__ == "__main__":
main()

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "inventory"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"pillow>=12.3.0",
]

100
test.py Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Делает обои: размытый затемнённый фон + исходник в квадрате со скруглёнными углами.
Установка зависимости:
pip install pillow
Использование:
python make_wallpaper.py photo.jpg
python make_wallpaper.py photo.jpg -o wall.jpg --size 600
python make_wallpaper.py photo.jpg -W 2560 -H 1440 --size 800 --offset 0.35
"""
import argparse
from PIL import Image, ImageFilter, ImageEnhance, ImageDraw
def make_wallpaper(src_path, out_path, W=1920, H=1080, square=600,
radius_ratio=0.178, offset=0.5, blur=40, dim=0.6,
rim=True, shadow=True):
src = Image.open(src_path).convert("RGB")
sw, sh = src.size
# --- фон: масштабируем с запасом, обрезаем по центру, размываем и затемняем
scale = max(W / sw, H / sh) * 1.15
bg = src.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
left, top = (bg.width - W) // 2, (bg.height - H) // 2
bg = bg.crop((left, top, left + W, top + H))
bg = bg.filter(ImageFilter.GaussianBlur(blur))
bg = ImageEnhance.Brightness(bg).enhance(dim)
# --- квадратный кроп из исходника
side = min(sw, sh)
if sh >= sw: # вертикальное фото — двигаем по вертикали
y = int((sh - side) * offset)
box = (0, y, side, y + side)
else: # горизонтальное — по горизонтали
x = int((sw - side) * offset)
box = (x, 0, x + side, side)
S = min(square, W, H)
R = int(S * radius_ratio)
fg = src.crop(box).resize((S, S), Image.LANCZOS)
fg = fg.filter(ImageFilter.UnsharpMask(radius=2, percent=40, threshold=3))
# --- маска со скруглением (рисуем крупно и уменьшаем = гладкие края)
SS = 4
mask = Image.new("L", (S * SS, S * SS), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[0, 0, S * SS - 1, S * SS - 1], radius=R * SS, fill=255)
mask = mask.resize((S, S), Image.LANCZOS)
px, py = (W - S) // 2, (H - S) // 2
# --- мягкая тень
if shadow:
sh_layer = Image.new("L", (W, H), 0)
sh_layer.paste(mask.point(lambda v: int(v * 0.55)), (px, py + max(4, S // 40)))
sh_layer = sh_layer.filter(ImageFilter.GaussianBlur(max(8, S // 22)))
bg = Image.composite(Image.new("RGB", (W, H), (0, 0, 8)), bg, sh_layer)
bg.paste(fg, (px, py), mask)
# --- тонкая светлая рамка
if rim:
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ImageDraw.Draw(layer).rounded_rectangle(
[px, py, px + S - 1, py + S - 1], radius=R,
outline=(255, 255, 255, 45), width=2)
bg = Image.alpha_composite(bg.convert("RGBA"), layer).convert("RGB")
bg.save(out_path, quality=95)
return out_path
def main():
p = argparse.ArgumentParser(description="Обои с размытым фоном и скруглённым квадратом")
p.add_argument("input")
p.add_argument("-o", "--output", default="wallpaper.jpg")
p.add_argument("-W", "--width", type=int, default=1920)
p.add_argument("-H", "--height", type=int, default=1080)
p.add_argument("--size", type=int, default=600, help="сторона квадрата в пикселях")
p.add_argument("--radius", type=float, default=0.178,
help="радиус скругления как доля стороны (0.178 ≈ стиль iOS)")
p.add_argument("--offset", type=float, default=0.5,
help="сдвиг кропа 0..1 (0 — верх/лево, 1 — низ/право)")
p.add_argument("--blur", type=int, default=40)
p.add_argument("--dim", type=float, default=0.6, help="яркость фона, 1.0 = без затемнения")
p.add_argument("--no-rim", action="store_true")
p.add_argument("--no-shadow", action="store_true")
a = p.parse_args()
out = make_wallpaper(a.input, a.output, a.width, a.height, a.size,
a.radius, a.offset, a.blur, a.dim,
rim=not a.no_rim, shadow=not a.no_shadow)
print("Сохранено:", out)
if __name__ == "__main__":
main()

64
uv.lock generated Normal file
View File

@@ -0,0 +1,64 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "inventory"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pillow" },
]
[package.metadata]
requires-dist = [{ name = "pillow", specifier = ">=12.3.0" }]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]