start
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal 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
17
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
47
README.md
Normal file
47
README.md
Normal 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
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
RUN uv sync --frozen
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
|
FROM base AS prod
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
3
backend/README.md
Normal file
3
backend/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Inventory backend
|
||||||
|
|
||||||
|
FastAPI service for the inventory tracking system. See the repo root README for how to run the full stack via Docker Compose.
|
||||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
55
backend/alembic/env.py
Normal file
55
backend/alembic/env.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models import Box, InventoryObject, ObjectPhoto, User # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
80
backend/alembic/versions/cdcadb640107_initial_schema.py
Normal file
80
backend/alembic/versions/cdcadb640107_initial_schema.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: cdcadb640107
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-21 07:34:21.610198
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'cdcadb640107'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('boxes',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('parent_box_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['parent_box_id'], ['boxes.id'], name=op.f('fk_boxes_parent_box_id_boxes'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_boxes'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_boxes_parent_box_id'), 'boxes', ['parent_box_id'], unique=False)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||||
|
op.create_table('objects',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('inventory_number', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('box_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['box_id'], ['boxes.id'], name=op.f('fk_objects_box_id_boxes'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_objects'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_objects_box_id'), 'objects', ['box_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_objects_inventory_number'), 'objects', ['inventory_number'], unique=True)
|
||||||
|
op.create_table('object_photos',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('object_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('storage_key', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['object_id'], ['objects.id'], name=op.f('fk_object_photos_object_id_objects'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_object_photos'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_object_photos_object_id'), 'object_photos', ['object_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_object_photos_object_id'), table_name='object_photos')
|
||||||
|
op.drop_table('object_photos')
|
||||||
|
op.drop_index(op.f('ix_objects_inventory_number'), table_name='objects')
|
||||||
|
op.drop_index(op.f('ix_objects_box_id'), table_name='objects')
|
||||||
|
op.drop_table('objects')
|
||||||
|
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_index(op.f('ix_boxes_parent_box_id'), table_name='boxes')
|
||||||
|
op.drop_table('boxes')
|
||||||
|
# ### end Alembic commands ###
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
41
backend/app/cli.py
Normal file
41
backend/app/cli.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def create_admin(username: str = typer.Option(..., prompt=True)) -> None:
|
||||||
|
"""Create an admin user for the inventory backend."""
|
||||||
|
password = getpass.getpass("Password: ")
|
||||||
|
confirm = getpass.getpass("Confirm password: ")
|
||||||
|
if password != confirm:
|
||||||
|
typer.echo("Passwords do not match", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if len(password) < 8:
|
||||||
|
typer.echo("Password must be at least 8 characters", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
asyncio.run(_create_admin(username, password))
|
||||||
|
typer.echo(f"Created user '{username}'")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_admin(username: str, password: str) -> None:
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
existing = await db.execute(select(User).where(User.username == username))
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
typer.echo(f"User '{username}' already exists", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
db.add(User(username=username, hashed_password=hash_password(password)))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
28
backend/app/core/config.py
Normal file
28
backend/app/core/config.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
database_url: str
|
||||||
|
|
||||||
|
minio_endpoint: str
|
||||||
|
minio_access_key: str
|
||||||
|
minio_secret_key: str
|
||||||
|
minio_bucket: str
|
||||||
|
minio_use_ssl: bool = False
|
||||||
|
minio_public_url: str
|
||||||
|
|
||||||
|
jwt_secret_key: str
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 600
|
||||||
|
|
||||||
|
public_base_url: str
|
||||||
|
allowed_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
47
backend/app/core/dependencies.py
Normal file
47
backend/app/core/dependencies.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
db: DbSession, token: Annotated[str, Depends(_oauth2_scheme)]
|
||||||
|
) -> User:
|
||||||
|
credentials_error = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
user_id = decode_access_token(token)
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_error
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_error
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client():
|
||||||
|
settings = get_settings()
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=("https://" if settings.minio_use_ssl else "http://") + settings.minio_endpoint,
|
||||||
|
aws_access_key_id=settings.minio_access_key,
|
||||||
|
aws_secret_access_key=settings.minio_secret_key,
|
||||||
|
)
|
||||||
40
backend/app/core/security.py
Normal file
40
backend/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(hashed, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: UUID) -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
payload = {"sub": str(user_id), "exp": expires_at}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> UUID | None:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
subject = payload.get("sub")
|
||||||
|
if subject is None:
|
||||||
|
return None
|
||||||
|
return UUID(subject)
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
14
backend/app/db/base.py
Normal file
14
backend/app/db/base.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
13
backend/app/db/session.py
Normal file
13
backend/app/db/session.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||||
|
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
yield session
|
||||||
28
backend/app/main.py
Normal file
28
backend/app/main.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.routers import auth, boxes, objects, photos, qrcodes
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
app = FastAPI(title="Inventory API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(objects.router)
|
||||||
|
app.include_router(boxes.router)
|
||||||
|
app.include_router(photos.router)
|
||||||
|
app.include_router(qrcodes.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.models.box import Box
|
||||||
|
from app.models.object import InventoryObject
|
||||||
|
from app.models.object_photo import ObjectPhoto
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["Box", "InventoryObject", "ObjectPhoto", "User"]
|
||||||
31
backend/app/models/box.py
Normal file
31
backend/app/models/box.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.object import InventoryObject
|
||||||
|
|
||||||
|
|
||||||
|
class Box(Base):
|
||||||
|
__tablename__ = "boxes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
parent_box_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("boxes.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
parent: Mapped["Box | None"] = relationship(remote_side=[id], back_populates="children")
|
||||||
|
children: Mapped[list["Box"]] = relationship(back_populates="parent")
|
||||||
|
objects: Mapped[list["InventoryObject"]] = relationship(back_populates="box")
|
||||||
34
backend/app/models/object.py
Normal file
34
backend/app/models/object.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.box import Box
|
||||||
|
from app.models.object_photo import ObjectPhoto
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryObject(Base):
|
||||||
|
__tablename__ = "objects"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
inventory_number: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
box_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("boxes.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
box: Mapped["Box | None"] = relationship(back_populates="objects")
|
||||||
|
photos: Mapped[list["ObjectPhoto"]] = relationship(
|
||||||
|
back_populates="object", cascade="all, delete-orphan", order_by="ObjectPhoto.position"
|
||||||
|
)
|
||||||
27
backend/app/models/object_photo.py
Normal file
27
backend/app/models/object_photo.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.object import InventoryObject
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectPhoto(Base):
|
||||||
|
__tablename__ = "object_photos"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
object_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("objects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
storage_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
object: Mapped["InventoryObject"] = relationship(back_populates="photos")
|
||||||
17
backend/app/models/user.py
Normal file
17
backend/app/models/user.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
23
backend/app/routers/auth.py
Normal file
23
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
|
from app.core.dependencies import DbSession
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
from app.schemas.auth import Token
|
||||||
|
from app.services.auth import authenticate_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=Token)
|
||||||
|
async def login(db: DbSession, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
|
||||||
|
user = await authenticate_user(db, form_data.username, form_data.password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return Token(access_token=create_access_token(user.id))
|
||||||
54
backend/app/routers/boxes.py
Normal file
54
backend/app/routers/boxes.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.core.dependencies import CurrentUser, DbSession
|
||||||
|
from app.schemas.box import BoxCreate, BoxRead, BoxSummary, BoxUpdate
|
||||||
|
from app.services import boxes as boxes_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/boxes", tags=["boxes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BoxSummary])
|
||||||
|
async def list_boxes(
|
||||||
|
db: DbSession, search: str | None = None, root_only: bool = False
|
||||||
|
) -> list[BoxSummary]:
|
||||||
|
items = await boxes_service.list_boxes(db, search=search, root_only=root_only)
|
||||||
|
return [BoxSummary.model_validate(b) for b in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BoxRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_box(db: DbSession, _: CurrentUser, data: BoxCreate) -> BoxRead:
|
||||||
|
box = await boxes_service.create_box(db, data)
|
||||||
|
return await boxes_service.build_box_read(db, box)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{box_id}", response_model=BoxRead)
|
||||||
|
async def get_box(db: DbSession, box_id: uuid.UUID) -> BoxRead:
|
||||||
|
box = await boxes_service.get_box(db, box_id)
|
||||||
|
if box is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
|
||||||
|
return await boxes_service.build_box_read(db, box)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{box_id}", response_model=BoxRead)
|
||||||
|
async def update_box(db: DbSession, _: CurrentUser, box_id: uuid.UUID, data: BoxUpdate) -> BoxRead:
|
||||||
|
box = await boxes_service.get_box(db, box_id)
|
||||||
|
if box is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
|
||||||
|
try:
|
||||||
|
box = await boxes_service.update_box(db, box, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
|
return await boxes_service.build_box_read(db, box)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{box_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_box(db: DbSession, _: CurrentUser, box_id: uuid.UUID) -> None:
|
||||||
|
box = await boxes_service.get_box(db, box_id)
|
||||||
|
if box is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
|
||||||
|
try:
|
||||||
|
await boxes_service.delete_box(db, box)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
61
backend/app/routers/objects.py
Normal file
61
backend/app/routers/objects.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.core.dependencies import CurrentUser, DbSession, get_minio_client
|
||||||
|
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate
|
||||||
|
from app.services import objects as objects_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ObjectRead])
|
||||||
|
async def list_objects(db: DbSession, search: str | None = None) -> list[ObjectRead]:
|
||||||
|
items = await objects_service.list_objects(db, search=search)
|
||||||
|
return [objects_service.build_object_read(o) for o in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ObjectRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_object(db: DbSession, _: CurrentUser, data: ObjectCreate) -> ObjectRead:
|
||||||
|
try:
|
||||||
|
obj = await objects_service.create_object(db, data)
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Inventory number already in use"
|
||||||
|
)
|
||||||
|
return objects_service.build_object_read(obj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{object_id}", response_model=ObjectRead)
|
||||||
|
async def get_object(db: DbSession, object_id: uuid.UUID) -> ObjectRead:
|
||||||
|
obj = await objects_service.get_object(db, object_id)
|
||||||
|
if obj is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
return objects_service.build_object_read(obj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{object_id}", response_model=ObjectRead)
|
||||||
|
async def update_object(
|
||||||
|
db: DbSession, _: CurrentUser, object_id: uuid.UUID, data: ObjectUpdate
|
||||||
|
) -> ObjectRead:
|
||||||
|
obj = await objects_service.get_object(db, object_id)
|
||||||
|
if obj is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
try:
|
||||||
|
obj = await objects_service.update_object(db, obj, data)
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Inventory number already in use"
|
||||||
|
)
|
||||||
|
return objects_service.build_object_read(obj)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{object_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID) -> None:
|
||||||
|
obj = await objects_service.get_object(db, object_id)
|
||||||
|
if obj is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
await objects_service.delete_object(db, get_minio_client(), obj)
|
||||||
35
backend/app/routers/photos.py
Normal file
35
backend/app/routers/photos.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, status
|
||||||
|
|
||||||
|
from app.core.dependencies import CurrentUser, DbSession, get_minio_client
|
||||||
|
from app.models.object_photo import ObjectPhoto
|
||||||
|
from app.schemas.photo import PhotoRead
|
||||||
|
from app.services import objects as objects_service
|
||||||
|
from app.services.photos import build_photo_read, delete_photo, upload_photo
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["photos"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/objects/{object_id}/photos", response_model=PhotoRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def upload_object_photo(
|
||||||
|
db: DbSession, _: CurrentUser, object_id: uuid.UUID, file: UploadFile
|
||||||
|
) -> PhotoRead:
|
||||||
|
obj = await objects_service.get_object(db, object_id)
|
||||||
|
if obj is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
try:
|
||||||
|
photo = await upload_photo(db, get_minio_client(), object_id, file)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
return build_photo_read(photo)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/photos/{photo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_object_photo(db: DbSession, _: CurrentUser, photo_id: uuid.UUID) -> None:
|
||||||
|
photo = await db.get(ObjectPhoto, photo_id)
|
||||||
|
if photo is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Photo not found")
|
||||||
|
await delete_photo(db, get_minio_client(), photo)
|
||||||
30
backend/app/routers/qrcodes.py
Normal file
30
backend/app/routers/qrcodes.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
|
from app.core.dependencies import DbSession
|
||||||
|
from app.services import boxes as boxes_service
|
||||||
|
from app.services import objects as objects_service
|
||||||
|
from app.services.qrcodes import box_public_url, generate_qr_png, object_public_url
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["qrcodes"])
|
||||||
|
|
||||||
|
_PNG_HEADERS = {"Cache-Control": "public, max-age=86400"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objects/{object_id}/qrcode.png")
|
||||||
|
async def object_qrcode(db: DbSession, object_id: uuid.UUID) -> Response:
|
||||||
|
obj = await objects_service.get_object(db, object_id)
|
||||||
|
if obj is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||||
|
png = generate_qr_png(object_public_url(obj.id))
|
||||||
|
return Response(content=png, media_type="image/png", headers=_PNG_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boxes/{box_id}/qrcode.png")
|
||||||
|
async def box_qrcode(db: DbSession, box_id: uuid.UUID) -> Response:
|
||||||
|
box = await boxes_service.get_box(db, box_id)
|
||||||
|
if box is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Box not found")
|
||||||
|
png = generate_qr_png(box_public_url(box.id))
|
||||||
|
return Response(content=png, media_type="image/png", headers=_PNG_HEADERS)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
6
backend/app/schemas/auth.py
Normal file
6
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
40
backend/app/schemas/box.py
Normal file
40
backend/app/schemas/box.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.schemas.object import ObjectSummary
|
||||||
|
|
||||||
|
|
||||||
|
class BoxSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BoxCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
parent_box_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BoxUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
parent_box_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
parent_box_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
qr_code_url: str
|
||||||
|
ancestors: list[BoxSummary]
|
||||||
|
children: list[BoxSummary]
|
||||||
|
objects: list[ObjectSummary]
|
||||||
50
backend/app/schemas/object.py
Normal file
50
backend/app/schemas/object.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.schemas.photo import PhotoRead
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
inventory_number: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BoxRef(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectCreate(BaseModel):
|
||||||
|
inventory_number: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
box_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectUpdate(BaseModel):
|
||||||
|
inventory_number: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
box_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
inventory_number: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
box_id: uuid.UUID | None
|
||||||
|
box: BoxRef | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
qr_code_url: str
|
||||||
|
photos: list[PhotoRead]
|
||||||
12
backend/app/schemas/photo.py
Normal file
12
backend/app/schemas/photo.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
url: str
|
||||||
|
content_type: str
|
||||||
|
position: int
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
13
backend/app/services/auth.py
Normal file
13
backend/app/services/auth.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None or not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
97
backend/app/services/boxes.py
Normal file
97
backend/app/services/boxes.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.box import Box
|
||||||
|
from app.schemas.box import BoxCreate, BoxRead, BoxSummary, BoxUpdate
|
||||||
|
from app.schemas.object import ObjectSummary
|
||||||
|
from app.services.qrcodes import box_public_url
|
||||||
|
|
||||||
|
|
||||||
|
def _select_with_relations():
|
||||||
|
return select(Box).options(selectinload(Box.children), selectinload(Box.objects))
|
||||||
|
|
||||||
|
|
||||||
|
async def list_boxes(
|
||||||
|
db: AsyncSession, search: str | None = None, root_only: bool = False
|
||||||
|
) -> list[Box]:
|
||||||
|
stmt = select(Box).order_by(Box.name)
|
||||||
|
if root_only:
|
||||||
|
stmt = stmt.where(Box.parent_box_id.is_(None))
|
||||||
|
if search:
|
||||||
|
stmt = stmt.where(Box.name.ilike(f"%{search}%"))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_box(db: AsyncSession, box_id: uuid.UUID) -> Box | None:
|
||||||
|
stmt = _select_with_relations().where(Box.id == box_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def build_box_read(db: AsyncSession, box: Box) -> BoxRead:
|
||||||
|
ancestors: list[BoxSummary] = []
|
||||||
|
current_parent_id = box.parent_box_id
|
||||||
|
depth = 0
|
||||||
|
while current_parent_id is not None and depth < 100:
|
||||||
|
parent = await db.get(Box, current_parent_id)
|
||||||
|
if parent is None:
|
||||||
|
break
|
||||||
|
ancestors.append(BoxSummary(id=parent.id, name=parent.name))
|
||||||
|
current_parent_id = parent.parent_box_id
|
||||||
|
depth += 1
|
||||||
|
ancestors.reverse()
|
||||||
|
|
||||||
|
return BoxRead(
|
||||||
|
id=box.id,
|
||||||
|
name=box.name,
|
||||||
|
description=box.description,
|
||||||
|
parent_box_id=box.parent_box_id,
|
||||||
|
created_at=box.created_at,
|
||||||
|
updated_at=box.updated_at,
|
||||||
|
qr_code_url=box_public_url(box.id),
|
||||||
|
ancestors=ancestors,
|
||||||
|
children=[BoxSummary.model_validate(c) for c in box.children],
|
||||||
|
objects=[ObjectSummary.model_validate(o) for o in box.objects],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_descendant(db: AsyncSession, box_id: uuid.UUID, candidate_ancestor_id: uuid.UUID) -> bool:
|
||||||
|
"""Whether candidate_ancestor_id is box_id itself or one of its descendants."""
|
||||||
|
if box_id == candidate_ancestor_id:
|
||||||
|
return True
|
||||||
|
stmt = select(Box.id).where(Box.parent_box_id == box_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
for child_id in result.scalars().all():
|
||||||
|
if await _is_descendant(db, child_id, candidate_ancestor_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_box(db: AsyncSession, data: BoxCreate) -> Box:
|
||||||
|
box = Box(**data.model_dump())
|
||||||
|
db.add(box)
|
||||||
|
await db.commit()
|
||||||
|
return await get_box(db, box.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_box(db: AsyncSession, box: Box, data: BoxUpdate) -> Box:
|
||||||
|
updates = data.model_dump(exclude_unset=True)
|
||||||
|
new_parent_id = updates.get("parent_box_id", box.parent_box_id)
|
||||||
|
if new_parent_id is not None and await _is_descendant(db, box.id, new_parent_id):
|
||||||
|
raise ValueError("A box cannot be moved into itself or one of its own descendants")
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(box, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return await get_box(db, box.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_box(db: AsyncSession, box: Box) -> None:
|
||||||
|
child_count = await db.scalar(select(Box.id).where(Box.parent_box_id == box.id).limit(1))
|
||||||
|
if child_count is not None:
|
||||||
|
raise ValueError("Cannot delete a box that still contains nested boxes")
|
||||||
|
await db.delete(box)
|
||||||
|
await db.commit()
|
||||||
69
backend/app/services/objects.py
Normal file
69
backend/app/services/objects.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.object import InventoryObject
|
||||||
|
from app.schemas.object import BoxRef, ObjectCreate, ObjectRead, ObjectUpdate
|
||||||
|
from app.services.photos import build_photo_read, delete_photo
|
||||||
|
from app.services.qrcodes import object_public_url
|
||||||
|
|
||||||
|
|
||||||
|
def build_object_read(obj: InventoryObject) -> ObjectRead:
|
||||||
|
return ObjectRead(
|
||||||
|
id=obj.id,
|
||||||
|
inventory_number=obj.inventory_number,
|
||||||
|
name=obj.name,
|
||||||
|
description=obj.description,
|
||||||
|
box_id=obj.box_id,
|
||||||
|
box=BoxRef.model_validate(obj.box) if obj.box else None,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
updated_at=obj.updated_at,
|
||||||
|
qr_code_url=object_public_url(obj.id),
|
||||||
|
photos=[build_photo_read(p) for p in obj.photos],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_with_relations():
|
||||||
|
return select(InventoryObject).options(
|
||||||
|
selectinload(InventoryObject.photos), selectinload(InventoryObject.box)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_objects(db: AsyncSession, search: str | None = None) -> list[InventoryObject]:
|
||||||
|
stmt = _select_with_relations().order_by(InventoryObject.created_at.desc())
|
||||||
|
if search:
|
||||||
|
like = f"%{search}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
(InventoryObject.inventory_number.ilike(like)) | (InventoryObject.name.ilike(like))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_object(db: AsyncSession, object_id: uuid.UUID) -> InventoryObject | None:
|
||||||
|
stmt = _select_with_relations().where(InventoryObject.id == object_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_object(db: AsyncSession, data: ObjectCreate) -> InventoryObject:
|
||||||
|
obj = InventoryObject(**data.model_dump())
|
||||||
|
db.add(obj)
|
||||||
|
await db.commit()
|
||||||
|
return await get_object(db, obj.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_object(db: AsyncSession, obj: InventoryObject, data: ObjectUpdate) -> InventoryObject:
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return await get_object(db, obj.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_object(db: AsyncSession, minio_client, obj: InventoryObject) -> None:
|
||||||
|
for photo in list(obj.photos):
|
||||||
|
await delete_photo(db, minio_client, photo)
|
||||||
|
await db.delete(obj)
|
||||||
|
await db.commit()
|
||||||
62
backend/app/services/photos.py
Normal file
62
backend/app/services/photos.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import uuid
|
||||||
|
from mimetypes import guess_extension
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.object_photo import ObjectPhoto
|
||||||
|
from app.schemas.photo import PhotoRead
|
||||||
|
|
||||||
|
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_photo_read(photo: ObjectPhoto) -> PhotoRead:
|
||||||
|
settings = get_settings()
|
||||||
|
url = f"{settings.minio_public_url}/{settings.minio_bucket}/{photo.storage_key}"
|
||||||
|
return PhotoRead(id=photo.id, url=url, content_type=photo.content_type, position=photo.position)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_photo(
|
||||||
|
db: AsyncSession, minio_client, object_id: uuid.UUID, upload_file: UploadFile
|
||||||
|
) -> ObjectPhoto:
|
||||||
|
if upload_file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
raise ValueError(f"Unsupported content type: {upload_file.content_type}")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
extension = guess_extension(upload_file.content_type) or ""
|
||||||
|
photo_id = uuid.uuid4()
|
||||||
|
storage_key = f"objects/{object_id}/{photo_id}{extension}"
|
||||||
|
|
||||||
|
body = await upload_file.read()
|
||||||
|
await run_in_threadpool(
|
||||||
|
minio_client.put_object,
|
||||||
|
Bucket=settings.minio_bucket,
|
||||||
|
Key=storage_key,
|
||||||
|
Body=body,
|
||||||
|
ContentType=upload_file.content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_position = await db.scalar(
|
||||||
|
select(func.coalesce(func.max(ObjectPhoto.position), -1)).where(ObjectPhoto.object_id == object_id)
|
||||||
|
)
|
||||||
|
photo = ObjectPhoto(
|
||||||
|
id=photo_id,
|
||||||
|
object_id=object_id,
|
||||||
|
storage_key=storage_key,
|
||||||
|
content_type=upload_file.content_type,
|
||||||
|
position=max_position + 1,
|
||||||
|
)
|
||||||
|
db.add(photo)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(photo)
|
||||||
|
return photo
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_photo(db: AsyncSession, minio_client, photo: ObjectPhoto) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
await run_in_threadpool(minio_client.delete_object, Bucket=settings.minio_bucket, Key=photo.storage_key)
|
||||||
|
await db.delete(photo)
|
||||||
|
await db.commit()
|
||||||
20
backend/app/services/qrcodes.py
Normal file
20
backend/app/services/qrcodes.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def object_public_url(object_id) -> str:
|
||||||
|
return f"{get_settings().public_base_url}/objects/{object_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def box_public_url(box_id) -> str:
|
||||||
|
return f"{get_settings().public_base_url}/boxes/{box_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_png(data: str) -> bytes:
|
||||||
|
img = qrcode.make(data, box_size=10, border=2)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
31
backend/pyproject.toml
Normal file
31
backend/pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[project]
|
||||||
|
name = "inventory-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Inventory tracking service API"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.139.2",
|
||||||
|
"uvicorn[standard]>=0.34.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"pydantic-settings>=2.7.0",
|
||||||
|
"argon2-cffi>=23.1.0",
|
||||||
|
"pyjwt>=2.10.0",
|
||||||
|
"boto3>=1.35.0",
|
||||||
|
"qrcode[pil]>=8.0",
|
||||||
|
"python-multipart>=0.0.20",
|
||||||
|
"typer>=0.15.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.0",
|
||||||
|
"pytest-asyncio>=0.25.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
pythonpath = ["."]
|
||||||
52
backend/tests/conftest.py
Normal file
52
backend/tests/conftest.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.db.session import async_session_maker, engine
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _dispose_engine_after_test() -> AsyncIterator[None]:
|
||||||
|
# Each test runs in its own event loop; pooled asyncpg connections from a
|
||||||
|
# previous test's loop are unusable in the next one, so drop the pool.
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncIterator[AsyncClient]:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def admin_user(db: AsyncSession) -> AsyncIterator[User]:
|
||||||
|
user = User(username=f"admin-{uuid.uuid4().hex[:8]}", hashed_password=hash_password("adminpass123"))
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
yield user
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_headers(client: AsyncClient, admin_user: User) -> dict[str, str]:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/auth/token", data={"username": admin_user.username, "password": "adminpass123"}
|
||||||
|
)
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
23
backend/tests/test_auth.py
Normal file
23
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_success(client: AsyncClient, admin_user: User) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/auth/token", data={"username": admin_user.username, "password": "adminpass123"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_wrong_password(client: AsyncClient, admin_user: User) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/auth/token", data={"username": admin_user.username, "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mutation_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post("/api/v1/objects", json={"inventory_number": "X", "name": "X"})
|
||||||
|
assert res.status_code == 401
|
||||||
49
backend/tests/test_boxes.py
Normal file
49
backend/tests/test_boxes.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nested_box_restrict_delete_and_cycle_prevention(
|
||||||
|
client: AsyncClient, auth_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
parent_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Parent"})
|
||||||
|
parent_id = parent_res.json()["id"]
|
||||||
|
|
||||||
|
child_res = await client.post(
|
||||||
|
"/api/v1/boxes", headers=auth_headers, json={"name": "Child", "parent_box_id": parent_id}
|
||||||
|
)
|
||||||
|
child_id = child_res.json()["id"]
|
||||||
|
|
||||||
|
# Cannot delete a box that still has children.
|
||||||
|
delete_res = await client.delete(f"/api/v1/boxes/{parent_id}", headers=auth_headers)
|
||||||
|
assert delete_res.status_code == 409
|
||||||
|
|
||||||
|
# Cannot move a box into its own descendant.
|
||||||
|
cycle_res = await client.put(
|
||||||
|
f"/api/v1/boxes/{parent_id}", headers=auth_headers, json={"parent_box_id": child_id}
|
||||||
|
)
|
||||||
|
assert cycle_res.status_code == 409
|
||||||
|
|
||||||
|
box_detail = await client.get(f"/api/v1/boxes/{child_id}")
|
||||||
|
assert box_detail.json()["ancestors"] == [{"id": parent_id, "name": "Parent"}]
|
||||||
|
|
||||||
|
await client.delete(f"/api/v1/boxes/{child_id}", headers=auth_headers)
|
||||||
|
await client.delete(f"/api/v1/boxes/{parent_id}", headers=auth_headers)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleting_box_unassigns_objects(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||||
|
box_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Temp box"})
|
||||||
|
box_id = box_res.json()["id"]
|
||||||
|
|
||||||
|
obj_res = await client.post(
|
||||||
|
"/api/v1/objects",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"inventory_number": "BOX-TEST-1", "name": "In a box", "box_id": box_id},
|
||||||
|
)
|
||||||
|
object_id = obj_res.json()["id"]
|
||||||
|
assert obj_res.json()["box_id"] == box_id
|
||||||
|
|
||||||
|
await client.delete(f"/api/v1/boxes/{box_id}", headers=auth_headers)
|
||||||
|
|
||||||
|
obj_after = await client.get(f"/api/v1/objects/{object_id}")
|
||||||
|
assert obj_after.json()["box_id"] is None
|
||||||
|
|
||||||
|
await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)
|
||||||
62
backend/tests/test_objects.py
Normal file
62
backend/tests/test_objects.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_read_update_delete_object(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||||
|
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
create_res = await client.post(
|
||||||
|
"/api/v1/objects",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"inventory_number": inventory_number, "name": "Test drill", "description": "for tests"},
|
||||||
|
)
|
||||||
|
assert create_res.status_code == 201
|
||||||
|
object_id = create_res.json()["id"]
|
||||||
|
|
||||||
|
public_res = await client.get(f"/api/v1/objects/{object_id}")
|
||||||
|
assert public_res.status_code == 200
|
||||||
|
assert public_res.json()["inventory_number"] == inventory_number
|
||||||
|
assert public_res.json()["photos"] == []
|
||||||
|
|
||||||
|
update_res = await client.put(
|
||||||
|
f"/api/v1/objects/{object_id}", headers=auth_headers, json={"name": "Renamed drill"}
|
||||||
|
)
|
||||||
|
assert update_res.status_code == 200
|
||||||
|
assert update_res.json()["name"] == "Renamed drill"
|
||||||
|
|
||||||
|
delete_res = await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)
|
||||||
|
assert delete_res.status_code == 204
|
||||||
|
|
||||||
|
missing_res = await client.get(f"/api/v1/objects/{object_id}")
|
||||||
|
assert missing_res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_inventory_number_conflicts(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||||
|
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||||
|
payload = {"inventory_number": inventory_number, "name": "First"}
|
||||||
|
|
||||||
|
first = await client.post("/api/v1/objects", headers=auth_headers, json=payload)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
dup = await client.post(
|
||||||
|
"/api/v1/objects", headers=auth_headers, json={**payload, "name": "Second"}
|
||||||
|
)
|
||||||
|
assert dup.status_code == 409
|
||||||
|
|
||||||
|
await client.delete(f"/api/v1/objects/{first.json()['id']}", headers=auth_headers)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_object_qrcode(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||||
|
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||||
|
create_res = await client.post(
|
||||||
|
"/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "QR test"}
|
||||||
|
)
|
||||||
|
object_id = create_res.json()["id"]
|
||||||
|
|
||||||
|
qr_res = await client.get(f"/api/v1/objects/{object_id}/qrcode.png")
|
||||||
|
assert qr_res.status_code == 200
|
||||||
|
assert qr_res.headers["content-type"] == "image/png"
|
||||||
|
assert qr_res.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)
|
||||||
1023
backend/uv.lock
generated
Normal file
1023
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
docker-compose.override.yml
Normal file
40
docker-compose.override.yml
Normal 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
98
docker-compose.yml
Normal 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
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
8
frontend/.oxlintrc.json
Normal 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
18
frontend/Dockerfile
Normal 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
32
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
18
frontend/nginx.conf
Normal 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
1780
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
70
frontend/src/api/client.ts
Normal file
70
frontend/src/api/client.ts
Normal 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
61
frontend/src/api/types.ts
Normal 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
|
||||||
|
}
|
||||||
38
frontend/src/auth/AuthContext.tsx
Normal file
38
frontend/src/auth/AuthContext.tsx
Normal 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
|
||||||
|
}
|
||||||
13
frontend/src/auth/RequireAuth.tsx
Normal file
13
frontend/src/auth/RequireAuth.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
14
frontend/src/components/BoxCard.tsx
Normal file
14
frontend/src/components/BoxCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/components/Breadcrumbs.tsx
Normal file
26
frontend/src/components/Breadcrumbs.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
frontend/src/components/Button.tsx
Normal file
23
frontend/src/components/Button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
frontend/src/components/Layout.tsx
Normal file
35
frontend/src/components/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
frontend/src/components/ObjectCard.tsx
Normal file
14
frontend/src/components/ObjectCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/components/PhotoGallery.tsx
Normal file
39
frontend/src/components/PhotoGallery.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
frontend/src/components/PhotoUploader.tsx
Normal file
53
frontend/src/components/PhotoUploader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/src/components/QrCodeCard.tsx
Normal file
31
frontend/src/components/QrCodeCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
frontend/src/hooks/useBoxes.ts
Normal file
46
frontend/src/hooks/useBoxes.ts
Normal 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'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
69
frontend/src/hooks/useObjects.ts
Normal file
69
frontend/src/hooks/useObjects.ts
Normal 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
18
frontend/src/index.css
Normal 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
23
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
71
frontend/src/pages/LoginPage.tsx
Normal file
71
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
frontend/src/pages/admin/BoxBrowsePage.tsx
Normal file
126
frontend/src/pages/admin/BoxBrowsePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
frontend/src/pages/admin/BoxFormPage.tsx
Normal file
126
frontend/src/pages/admin/BoxFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
frontend/src/pages/admin/ObjectFormPage.tsx
Normal file
233
frontend/src/pages/admin/ObjectFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/src/pages/admin/ObjectListPage.tsx
Normal file
58
frontend/src/pages/admin/ObjectListPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
frontend/src/pages/public/BoxDetailPage.tsx
Normal file
49
frontend/src/pages/public/BoxDetailPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/pages/public/ObjectDetailPage.tsx
Normal file
33
frontend/src/pages/public/ObjectDetailPage.tsx
Normal 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
47
frontend/src/router.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from inventory!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal 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
100
test.py
Normal 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
64
uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user