start
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user