A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted. - new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids - replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached - delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials - line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material - storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object) Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
88 lines
2.9 KiB
Python
88 lines
2.9 KiB
Python
"""MinIO client — S3-compatible object storage for invoice / proforma attachments.
|
|
|
|
The `minio` SDK is synchronous; every call is wrapped with
|
|
``asyncio.to_thread`` so the event loop is never blocked.
|
|
"""
|
|
import asyncio
|
|
import io
|
|
import logging
|
|
from functools import lru_cache
|
|
|
|
from minio import Minio
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_client() -> Minio:
|
|
"""Lazily built Minio client from the PROCUREMENT_MINIO_* settings."""
|
|
return Minio(
|
|
settings.MINIO_ENDPOINT,
|
|
access_key=settings.MINIO_ACCESS_KEY,
|
|
secret_key=settings.MINIO_SECRET_KEY,
|
|
secure=settings.MINIO_SECURE,
|
|
)
|
|
|
|
|
|
async def ensure_bucket() -> None:
|
|
"""Create the attachments bucket if it does not exist (idempotent)."""
|
|
client = get_client()
|
|
try:
|
|
exists = await asyncio.to_thread(client.bucket_exists, settings.MINIO_BUCKET)
|
|
if not exists:
|
|
await asyncio.to_thread(client.make_bucket, settings.MINIO_BUCKET)
|
|
logger.info("Created MinIO bucket %r", settings.MINIO_BUCKET)
|
|
except Exception:
|
|
logger.exception("MinIO bucket check failed — is the minio service reachable?")
|
|
|
|
|
|
async def put_object(key: str, content: bytes, mime_type: str) -> None:
|
|
"""Upload a byte payload to the bucket (overwrites any existing object)."""
|
|
await asyncio.to_thread(
|
|
get_client().put_object,
|
|
settings.MINIO_BUCKET,
|
|
key,
|
|
io.BytesIO(content),
|
|
length=len(content),
|
|
content_type=mime_type,
|
|
)
|
|
|
|
|
|
async def get_object_bytes(key: str) -> bytes:
|
|
"""Download an object's bytes from the bucket."""
|
|
response = await asyncio.to_thread(get_client().get_object, settings.MINIO_BUCKET, key)
|
|
try:
|
|
return response.read()
|
|
finally:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
|
|
async def remove_object(key: str) -> None:
|
|
"""Delete an object; missing objects are ignored (best-effort)."""
|
|
try:
|
|
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
|
except Exception:
|
|
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
|
|
|
|
|
async def remove_prefix(prefix: str) -> None:
|
|
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
|
client = get_client()
|
|
|
|
def _sync_remove_prefix():
|
|
try:
|
|
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
|
for obj in objects:
|
|
try:
|
|
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
|
except Exception:
|
|
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
|
except Exception:
|
|
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
|
|
|
await asyncio.to_thread(_sync_remove_prefix)
|
|
|