diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..0ba12c0 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone +from decimal import Decimal +from sqlalchemy import String, Boolean, DateTime, DECIMAL, Integer, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + body: Mapped[str | None] = mapped_column(Text) + link: Mapped[str | None] = mapped_column(String(500)) + is_read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + +class AlertConfig(Base): + __tablename__ = "alert_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + value: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(String(500)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..f0d5a55 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,109 @@ +import io +import uuid +from datetime import timedelta +from urllib.parse import urlparse + +import structlog +from minio import Minio +from minio.error import S3Error +from fastapi import UploadFile, HTTPException, status + +from app.core.config import settings + +logger = structlog.get_logger() + +_client: Minio | None = None +_public_client: Minio | None = None + + +def get_client() -> Minio: + global _client + if _client is None: + _client = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, + ) + return _client + + +def _get_public_client() -> Minio: + global _public_client + if _public_client is None: + parsed = urlparse(settings.minio_public_url) + endpoint = parsed.netloc + secure = parsed.scheme == "https" + _public_client = Minio( + endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=secure, + region="us-east-1", + ) + return _public_client + + +def _ensure_bucket(bucket: str) -> None: + client = get_client() + try: + if not client.bucket_exists(bucket): + client.make_bucket(bucket) + logger.info("minio_bucket_created", bucket=bucket) + except S3Error as exc: + logger.error("minio_bucket_error", bucket=bucket, error=str(exc)) + raise + + +async def upload_photo(file: UploadFile, valuation_id: int) -> str: + bucket = settings.minio_bucket_photos + try: + _ensure_bucket(bucket) + except S3Error as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Storage non disponibile", + ) from exc + + ext = "" + if file.filename and "." in file.filename: + ext = "." + file.filename.rsplit(".", 1)[-1].lower() + + object_name = f"valuations/{valuation_id}/{uuid.uuid4().hex}{ext}" + + content = await file.read() + content_length = len(content) + content_type = file.content_type or "application/octet-stream" + + client = get_client() + try: + client.put_object( + bucket, + object_name, + io.BytesIO(content), + length=content_length, + content_type=content_type, + ) + logger.info("minio_upload_ok", object_name=object_name) + return object_name + except S3Error as exc: + logger.error("minio_upload_error", object_name=object_name, error=str(exc)) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Errore durante il caricamento del file", + ) from exc + + +def get_presigned_url(storage_path: str, expires_hours: int = 4) -> str | None: + bucket = settings.minio_bucket_photos + client = _get_public_client() + try: + url = client.presigned_get_object( + bucket, + storage_path, + expires=timedelta(hours=expires_hours), + ) + return url + except S3Error as exc: + logger.warning("minio_presigned_error", path=storage_path, error=str(exc)) + return None