feat(evidence): add S3 storage service and file_uploads model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
"""add file_uploads table
|
||||||
|
|
||||||
|
Revision ID: 49150866ae44
|
||||||
|
Revises: e0d382f083d4
|
||||||
|
Create Date: 2026-03-20 03:15:10.227797
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '49150866ae44'
|
||||||
|
down_revision: Union[str, None] = 'e0d382f083d4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'file_uploads',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('uploaded_by', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('session_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('storage_key', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('storage_key'),
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_uploads_account_id'), 'file_uploads', ['account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_uploads_session_id'), 'file_uploads', ['session_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_file_uploads_session_id'), table_name='file_uploads')
|
||||||
|
op.drop_index(op.f('ix_file_uploads_account_id'), table_name='file_uploads')
|
||||||
|
op.drop_table('file_uploads')
|
||||||
@@ -124,6 +124,13 @@ class Settings(BaseSettings):
|
|||||||
"""Check if any AI provider is configured."""
|
"""Check if any AI provider is configured."""
|
||||||
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
|
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
|
||||||
|
|
||||||
|
# Object Storage (Railway S3-compatible)
|
||||||
|
STORAGE_ENDPOINT: str | None = None
|
||||||
|
STORAGE_ACCESS_KEY: str | None = None
|
||||||
|
STORAGE_SECRET_KEY: str | None = None
|
||||||
|
STORAGE_BUCKET_NAME: str = "resolutionflow-uploads"
|
||||||
|
STORAGE_REGION: str = "us-east-1"
|
||||||
|
|
||||||
# ConnectWise PSA Integration
|
# ConnectWise PSA Integration
|
||||||
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
|
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
|
||||||
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration
|
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from .notification_config import NotificationConfig
|
|||||||
from .notification_log import NotificationLog
|
from .notification_log import NotificationLog
|
||||||
from .notification import Notification
|
from .notification import Notification
|
||||||
from .psa_activity_log import PsaActivityLog
|
from .psa_activity_log import PsaActivityLog
|
||||||
|
from .file_upload import FileUpload
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -108,4 +109,5 @@ __all__ = [
|
|||||||
"NotificationLog",
|
"NotificationLog",
|
||||||
"Notification",
|
"Notification",
|
||||||
"PsaActivityLog",
|
"PsaActivityLog",
|
||||||
|
"FileUpload",
|
||||||
]
|
]
|
||||||
|
|||||||
32
backend/app/models/file_upload.py
Normal file
32
backend/app/models/file_upload.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""File upload metadata — tracks files stored in S3-compatible object storage."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FileUpload(Base):
|
||||||
|
__tablename__ = "file_uploads"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False
|
||||||
|
)
|
||||||
|
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
86
backend/app/services/storage_service.py
Normal file
86
backend/app/services/storage_service.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""S3-compatible object storage service for file uploads."""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||||
|
ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"}
|
||||||
|
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES
|
||||||
|
|
||||||
|
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB
|
||||||
|
MAX_FILES_PER_SESSION = 20
|
||||||
|
MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
PRESIGNED_URL_EXPIRY = 3600 # 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client():
|
||||||
|
"""Get S3 client configured for Railway Object Storage."""
|
||||||
|
if not settings.STORAGE_ENDPOINT:
|
||||||
|
raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)")
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.STORAGE_ENDPOINT,
|
||||||
|
aws_access_key_id=settings.STORAGE_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=settings.STORAGE_SECRET_KEY,
|
||||||
|
region_name=settings.STORAGE_REGION,
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_upload(content_type: str, size_bytes: int) -> str | None:
|
||||||
|
"""Validate file type and size. Returns error message or None."""
|
||||||
|
if content_type not in ALLOWED_TYPES:
|
||||||
|
return f"File type {content_type} not allowed"
|
||||||
|
max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else MAX_TEXT_SIZE
|
||||||
|
if size_bytes > max_size:
|
||||||
|
return f"File too large ({size_bytes} bytes, max {max_size})"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
file_data: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
account_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""Upload file to S3, returns the storage key."""
|
||||||
|
ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin"
|
||||||
|
storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}"
|
||||||
|
|
||||||
|
client = _get_client()
|
||||||
|
client.upload_fileobj(
|
||||||
|
BytesIO(file_data),
|
||||||
|
settings.STORAGE_BUCKET_NAME,
|
||||||
|
storage_key,
|
||||||
|
ExtraArgs={"ContentType": content_type},
|
||||||
|
)
|
||||||
|
return storage_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_presigned_url(storage_key: str) -> str:
|
||||||
|
"""Generate a time-limited presigned URL for downloading a file."""
|
||||||
|
client = _get_client()
|
||||||
|
return client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key},
|
||||||
|
ExpiresIn=PRESIGNED_URL_EXPIRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(storage_key: str) -> None:
|
||||||
|
"""Delete a file from S3."""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key)
|
||||||
|
except ClientError:
|
||||||
|
logger.warning(f"Failed to delete S3 object: {storage_key}")
|
||||||
@@ -51,3 +51,6 @@ python-dotenv==1.0.1
|
|||||||
croniter>=2.0.0
|
croniter>=2.0.0
|
||||||
pytz>=2024.1
|
pytz>=2024.1
|
||||||
apscheduler>=3.10.4
|
apscheduler>=3.10.4
|
||||||
|
|
||||||
|
# Object Storage
|
||||||
|
boto3>=1.34.0
|
||||||
|
|||||||
Reference in New Issue
Block a user