This commit is contained in:
jze9
2026-07-21 16:11:09 +05:00
commit 78862296c4
94 changed files with 6090 additions and 0 deletions

View File

View File

@@ -0,0 +1,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import verify_password
from app.models.user import User
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.hashed_password):
return None
return user

View File

@@ -0,0 +1,97 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.box import Box
from app.schemas.box import BoxCreate, BoxRead, BoxSummary, BoxUpdate
from app.schemas.object import ObjectSummary
from app.services.qrcodes import box_public_url
def _select_with_relations():
return select(Box).options(selectinload(Box.children), selectinload(Box.objects))
async def list_boxes(
db: AsyncSession, search: str | None = None, root_only: bool = False
) -> list[Box]:
stmt = select(Box).order_by(Box.name)
if root_only:
stmt = stmt.where(Box.parent_box_id.is_(None))
if search:
stmt = stmt.where(Box.name.ilike(f"%{search}%"))
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_box(db: AsyncSession, box_id: uuid.UUID) -> Box | None:
stmt = _select_with_relations().where(Box.id == box_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def build_box_read(db: AsyncSession, box: Box) -> BoxRead:
ancestors: list[BoxSummary] = []
current_parent_id = box.parent_box_id
depth = 0
while current_parent_id is not None and depth < 100:
parent = await db.get(Box, current_parent_id)
if parent is None:
break
ancestors.append(BoxSummary(id=parent.id, name=parent.name))
current_parent_id = parent.parent_box_id
depth += 1
ancestors.reverse()
return BoxRead(
id=box.id,
name=box.name,
description=box.description,
parent_box_id=box.parent_box_id,
created_at=box.created_at,
updated_at=box.updated_at,
qr_code_url=box_public_url(box.id),
ancestors=ancestors,
children=[BoxSummary.model_validate(c) for c in box.children],
objects=[ObjectSummary.model_validate(o) for o in box.objects],
)
async def _is_descendant(db: AsyncSession, box_id: uuid.UUID, candidate_ancestor_id: uuid.UUID) -> bool:
"""Whether candidate_ancestor_id is box_id itself or one of its descendants."""
if box_id == candidate_ancestor_id:
return True
stmt = select(Box.id).where(Box.parent_box_id == box_id)
result = await db.execute(stmt)
for child_id in result.scalars().all():
if await _is_descendant(db, child_id, candidate_ancestor_id):
return True
return False
async def create_box(db: AsyncSession, data: BoxCreate) -> Box:
box = Box(**data.model_dump())
db.add(box)
await db.commit()
return await get_box(db, box.id)
async def update_box(db: AsyncSession, box: Box, data: BoxUpdate) -> Box:
updates = data.model_dump(exclude_unset=True)
new_parent_id = updates.get("parent_box_id", box.parent_box_id)
if new_parent_id is not None and await _is_descendant(db, box.id, new_parent_id):
raise ValueError("A box cannot be moved into itself or one of its own descendants")
for field, value in updates.items():
setattr(box, field, value)
await db.commit()
return await get_box(db, box.id)
async def delete_box(db: AsyncSession, box: Box) -> None:
child_count = await db.scalar(select(Box.id).where(Box.parent_box_id == box.id).limit(1))
if child_count is not None:
raise ValueError("Cannot delete a box that still contains nested boxes")
await db.delete(box)
await db.commit()

View File

@@ -0,0 +1,69 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.object import InventoryObject
from app.schemas.object import BoxRef, ObjectCreate, ObjectRead, ObjectUpdate
from app.services.photos import build_photo_read, delete_photo
from app.services.qrcodes import object_public_url
def build_object_read(obj: InventoryObject) -> ObjectRead:
return ObjectRead(
id=obj.id,
inventory_number=obj.inventory_number,
name=obj.name,
description=obj.description,
box_id=obj.box_id,
box=BoxRef.model_validate(obj.box) if obj.box else None,
created_at=obj.created_at,
updated_at=obj.updated_at,
qr_code_url=object_public_url(obj.id),
photos=[build_photo_read(p) for p in obj.photos],
)
def _select_with_relations():
return select(InventoryObject).options(
selectinload(InventoryObject.photos), selectinload(InventoryObject.box)
)
async def list_objects(db: AsyncSession, search: str | None = None) -> list[InventoryObject]:
stmt = _select_with_relations().order_by(InventoryObject.created_at.desc())
if search:
like = f"%{search}%"
stmt = stmt.where(
(InventoryObject.inventory_number.ilike(like)) | (InventoryObject.name.ilike(like))
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_object(db: AsyncSession, object_id: uuid.UUID) -> InventoryObject | None:
stmt = _select_with_relations().where(InventoryObject.id == object_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_object(db: AsyncSession, data: ObjectCreate) -> InventoryObject:
obj = InventoryObject(**data.model_dump())
db.add(obj)
await db.commit()
return await get_object(db, obj.id)
async def update_object(db: AsyncSession, obj: InventoryObject, data: ObjectUpdate) -> InventoryObject:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
await db.commit()
return await get_object(db, obj.id)
async def delete_object(db: AsyncSession, minio_client, obj: InventoryObject) -> None:
for photo in list(obj.photos):
await delete_photo(db, minio_client, photo)
await db.delete(obj)
await db.commit()

View File

@@ -0,0 +1,62 @@
import uuid
from mimetypes import guess_extension
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from app.core.config import get_settings
from app.models.object_photo import ObjectPhoto
from app.schemas.photo import PhotoRead
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
def build_photo_read(photo: ObjectPhoto) -> PhotoRead:
settings = get_settings()
url = f"{settings.minio_public_url}/{settings.minio_bucket}/{photo.storage_key}"
return PhotoRead(id=photo.id, url=url, content_type=photo.content_type, position=photo.position)
async def upload_photo(
db: AsyncSession, minio_client, object_id: uuid.UUID, upload_file: UploadFile
) -> ObjectPhoto:
if upload_file.content_type not in ALLOWED_CONTENT_TYPES:
raise ValueError(f"Unsupported content type: {upload_file.content_type}")
settings = get_settings()
extension = guess_extension(upload_file.content_type) or ""
photo_id = uuid.uuid4()
storage_key = f"objects/{object_id}/{photo_id}{extension}"
body = await upload_file.read()
await run_in_threadpool(
minio_client.put_object,
Bucket=settings.minio_bucket,
Key=storage_key,
Body=body,
ContentType=upload_file.content_type,
)
max_position = await db.scalar(
select(func.coalesce(func.max(ObjectPhoto.position), -1)).where(ObjectPhoto.object_id == object_id)
)
photo = ObjectPhoto(
id=photo_id,
object_id=object_id,
storage_key=storage_key,
content_type=upload_file.content_type,
position=max_position + 1,
)
db.add(photo)
await db.commit()
await db.refresh(photo)
return photo
async def delete_photo(db: AsyncSession, minio_client, photo: ObjectPhoto) -> None:
settings = get_settings()
await run_in_threadpool(minio_client.delete_object, Bucket=settings.minio_bucket, Key=photo.storage_key)
await db.delete(photo)
await db.commit()

View File

@@ -0,0 +1,20 @@
import io
import qrcode
from app.core.config import get_settings
def object_public_url(object_id) -> str:
return f"{get_settings().public_base_url}/objects/{object_id}"
def box_public_url(box_id) -> str:
return f"{get_settings().public_base_url}/boxes/{box_id}"
def generate_qr_png(data: str) -> bytes:
img = qrcode.make(data, box_size=10, border=2)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()