Files
ERP/backend/app/services/storage.py
T
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
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.
2026-09-17 18:04:57 +03:30

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)