start
This commit is contained in:
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