feat(T05): Dateiablage pro Fahrzeug + File UI with thumbnails and gallery

This commit is contained in:
2026-07-17 01:38:48 +02:00
parent 149ac04dc1
commit a38d340ddc
18 changed files with 2649 additions and 69 deletions
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, contacts, ocr, users, vehicles
from app.routers import auth, contacts, files, ocr, users, vehicles
@asynccontextmanager
@@ -42,6 +42,7 @@ api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
api_v1_router.include_router(vehicles.router)
api_v1_router.include_router(contacts.router)
api_v1_router.include_router(files.router)
api_v1_router.include_router(ocr.router)
# Health endpoint (no auth required)
+83
View File
@@ -0,0 +1,83 @@
"""SQLAlchemy model for vehicle file attachments."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class File(Base):
"""File attachment entity linked to a vehicle."""
__tablename__ = "files"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("vehicles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
original_filename: Mapped[str] = mapped_column(
String(255), nullable=False
)
stored_filename: Mapped[str] = mapped_column(
String(255), nullable=False
)
file_path: Mapped[str] = mapped_column(
String(512), nullable=False
)
mime_type: Mapped[str] = mapped_column(
String(100), nullable=False
)
file_size: Mapped[int] = mapped_column(
Integer, nullable=False
)
thumbnail_path: Mapped[str | None] = mapped_column(
String(512), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
vehicle: Mapped["Vehicle"] = relationship(
"Vehicle", back_populates="files"
)
def __repr__(self) -> str:
return f"<File id={self.id} vehicle_id={self.vehicle_id} filename={self.original_filename}>"
def to_dict(self) -> dict:
"""Serialize file for API responses."""
return {
"id": str(self.id),
"vehicle_id": str(self.vehicle_id),
"original_filename": self.original_filename,
"stored_filename": self.stored_filename,
"file_path": self.file_path,
"mime_type": self.mime_type,
"file_size": self.file_size,
"thumbnail_path": self.thumbnail_path,
"created_at": (
self.created_at.isoformat() if self.created_at else None
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
}
+3
View File
@@ -130,6 +130,9 @@ class Vehicle(Base):
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
back_populates="vehicle", cascade="all, delete-orphan"
)
files: Mapped[list["File"]] = relationship(
"File", back_populates="vehicle", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
+209
View File
@@ -0,0 +1,209 @@
"""Files router: upload, list, download, and delete files for vehicles."""
import os
import uuid
from fastapi import APIRouter, Depends, File as FastAPIFile, HTTPException, UploadFile, status
from fastapi.responses import FileResponse as FastAPIFileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.models.vehicle import Vehicle
from app.schemas.file import (
FileDeleteResponse,
FileListResponse,
FileResponse,
FileUploadResponse,
)
from app.services import file_service, vehicle_service
router = APIRouter(prefix="/vehicles", tags=["files"])
async def _verify_vehicle_exists(
db: AsyncSession, vehicle_id: uuid.UUID
) -> Vehicle:
"""Verify that a vehicle exists, raising 404 if not."""
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
if vehicle is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": {
"code": "VEHICLE_NOT_FOUND",
"message": "Vehicle not found",
}
},
)
return vehicle
@router.get(
"/{vehicle_id}/files",
response_model=FileListResponse,
status_code=status.HTTP_200_OK,
)
async def list_files(
vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
current_user: User = Depends(get_current_user),
):
"""List all files for a vehicle with pagination."""
await _verify_vehicle_exists(db, vehicle_id)
files, total = await file_service.list_files(
db,
vehicle_id=vehicle_id,
page=pagination["page"],
page_size=pagination["page_size"],
)
return FileListResponse(
items=[FileResponse.model_validate(f) for f in files],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post(
"/{vehicle_id}/files",
response_model=FileUploadResponse,
status_code=status.HTTP_201_CREATED,
)
async def upload_file(
vehicle_id: uuid.UUID,
file: UploadFile = FastAPIFile(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a file for a vehicle (multipart/form-data).
Accepts images (jpg, png, webp), documents (pdf, doc, docx).
Max file size: 20MB.
"""
await _verify_vehicle_exists(db, vehicle_id)
# Read file content
file_content = await file.read()
file_size = len(file_content)
# Check file size (20MB limit)
if not file_service.validate_file_size(file_size, max_size_mb=20):
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail={
"error": {
"code": "FILE_TOO_LARGE",
"message": f"File size {file_size} bytes exceeds 20MB limit",
}
},
)
# Validate MIME type
mime_type = file.content_type or ""
if not file_service.validate_mime_type(mime_type, file.filename or ""):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "INVALID_MIME_TYPE",
"message": f"Unsupported file type: {mime_type}. Allowed: jpg, png, webp, pdf, doc, docx",
}
},
)
try:
file_record = await file_service.upload_file(
db,
vehicle_id=vehicle_id,
file_content=file_content,
original_filename=file.filename or "unnamed",
mime_type=mime_type,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "UPLOAD_FAILED",
"message": str(exc),
}
},
)
return FileUploadResponse.model_validate(file_record)
@router.get(
"/{vehicle_id}/files/{file_id}",
status_code=status.HTTP_200_OK,
)
async def download_file(
vehicle_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download a file by its ID."""
await _verify_vehicle_exists(db, vehicle_id)
file_record = await file_service.get_file(db, vehicle_id, file_id)
if file_record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": {
"code": "FILE_NOT_FOUND",
"message": "File not found",
}
},
)
if not os.path.exists(file_record.file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": {
"code": "FILE_NOT_FOUND",
"message": "File not found on disk",
}
},
)
return FastAPIFileResponse(
path=file_record.file_path,
filename=file_record.original_filename,
media_type=file_record.mime_type,
)
@router.delete(
"/{vehicle_id}/files/{file_id}",
response_model=FileDeleteResponse,
status_code=status.HTTP_200_OK,
)
async def delete_file(
vehicle_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a file by its ID."""
await _verify_vehicle_exists(db, vehicle_id)
file_record = await file_service.delete_file(db, vehicle_id, file_id)
if file_record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": {
"code": "FILE_NOT_FOUND",
"message": "File not found",
}
},
)
return FileDeleteResponse(message="File deleted", id=file_record.id)
+57
View File
@@ -0,0 +1,57 @@
"""Pydantic schemas for file upload, download, and list responses."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class FileUploadResponse(BaseModel):
"""Response after a successful file upload."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
vehicle_id: uuid.UUID
original_filename: str
stored_filename: str
file_path: str
mime_type: str
file_size: int
thumbnail_path: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class FileResponse(BaseModel):
"""Full file metadata response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
vehicle_id: uuid.UUID
original_filename: str
stored_filename: str
file_path: str
mime_type: str
file_size: int
thumbnail_path: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class FileListResponse(BaseModel):
"""Paginated file list response."""
items: list[FileResponse]
total: int
page: int
page_size: int
class FileDeleteResponse(BaseModel):
"""Response after deleting a file."""
message: str = "File deleted"
id: uuid.UUID
+234
View File
@@ -0,0 +1,234 @@
"""File service: upload, retrieve, list, delete, and validation for vehicle files.
All file operations use UPLOAD_DIR from config for storage.
Supports images (jpg, png, webp), documents (pdf, doc, docx), and scans (jpg, png).
Enforces MAX_FILE_SIZE_MB from config (default 50MB, but endpoint enforces 20MB).
"""
from __future__ import annotations
import logging
import os
import uuid
from pathlib import Path
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.file import File
from app.utils.thumbnails import generate_thumbnail, is_image_mime_type
logger = logging.getLogger(__name__)
# Allowed MIME types for upload
ALLOWED_MIME_TYPES = {
# Images
"image/jpeg",
"image/png",
"image/webp",
# Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
# Map MIME types to file extensions for validation
MIME_TO_EXTENSIONS = {
"image/jpeg": {"jpg", "jpeg"},
"image/png": {"png"},
"image/webp": {"webp"},
"application/pdf": {"pdf"},
"application/msword": {"doc"},
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {"docx"},
}
def validate_mime_type(mime_type: str, filename: str) -> bool:
"""Validate that the MIME type is allowed and matches the file extension.
Args:
mime_type: The MIME type from the upload.
filename: The original filename to check extension consistency.
Returns:
True if the MIME type is allowed and extension matches.
"""
mime_type = mime_type.lower().strip()
if mime_type not in ALLOWED_MIME_TYPES:
return False
# Check extension consistency
ext = Path(filename).suffix.lower().lstrip(".")
allowed_extensions = MIME_TO_EXTENSIONS.get(mime_type, set())
if ext not in allowed_extensions:
return False
return True
def validate_file_size(file_size: int, max_size_mb: int = 20) -> bool:
"""Validate that the file size is within the allowed limit.
Args:
file_size: File size in bytes.
max_size_mb: Maximum file size in MB (default 20MB for uploads).
Returns:
True if the file size is within the limit.
"""
max_size_bytes = max_size_mb * 1024 * 1024
return file_size <= max_size_bytes
async def upload_file(
db: AsyncSession,
vehicle_id: uuid.UUID,
file_content: bytes,
original_filename: str,
mime_type: str,
) -> File:
"""Upload a file for a vehicle.
Validates MIME type and file size, stores the file in UPLOAD_DIR,
generates a thumbnail for images, and creates a DB record.
Raises:
ValueError: If MIME type or file size validation fails.
"""
# Validate MIME type
if not validate_mime_type(mime_type, original_filename):
raise ValueError(f"Unsupported MIME type: {mime_type} for file: {original_filename}")
# Validate file size (20MB limit for uploads)
file_size = len(file_content)
if not validate_file_size(file_size, max_size_mb=20):
raise ValueError(f"File size {file_size} bytes exceeds 20MB limit")
# Generate stored filename
ext = Path(original_filename).suffix.lower()
stored_filename = f"{uuid.uuid4().hex}{ext}"
# Create vehicle-specific upload directory
upload_dir = Path(settings.UPLOAD_DIR) / str(vehicle_id)
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / stored_filename
# Write file to disk
with open(file_path, "wb") as f:
f.write(file_content)
logger.info("File uploaded: %s -> %s", original_filename, file_path)
# Generate thumbnail for images
thumbnail_path = None
if is_image_mime_type(mime_type):
thumbnail_dir = upload_dir / "thumbnails"
thumbnail_path = generate_thumbnail(
source_path=file_path,
thumbnail_dir=thumbnail_dir,
stored_filename=stored_filename,
)
# Create DB record
file_record = File(
vehicle_id=vehicle_id,
original_filename=original_filename,
stored_filename=stored_filename,
file_path=str(file_path),
mime_type=mime_type,
file_size=file_size,
thumbnail_path=thumbnail_path,
)
db.add(file_record)
await db.flush()
await db.refresh(file_record)
return file_record
async def get_file(
db: AsyncSession,
vehicle_id: uuid.UUID,
file_id: uuid.UUID,
) -> Optional[File]:
"""Get a single file by ID and vehicle ID."""
stmt = select(File).where(
File.id == file_id,
File.vehicle_id == vehicle_id,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_files(
db: AsyncSession,
vehicle_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
) -> tuple[list[File], int]:
"""List files for a vehicle with pagination.
Returns (files, total_count).
"""
# Count total files for this vehicle
count_stmt = select(func.count(File.id)).where(
File.vehicle_id == vehicle_id
)
count_result = await db.execute(count_stmt)
total = count_result.scalar_one()
# Fetch paginated files
offset = (page - 1) * page_size
data_stmt = (
select(File)
.where(File.vehicle_id == vehicle_id)
.order_by(File.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(data_stmt)
files = list(result.scalars().all())
return files, total
async def delete_file(
db: AsyncSession,
vehicle_id: uuid.UUID,
file_id: uuid.UUID,
) -> Optional[File]:
"""Delete a file: remove from disk and DB.
Returns the deleted file record, or None if not found.
"""
file_record = await get_file(db, vehicle_id, file_id)
if file_record is None:
return None
# Remove file from disk
file_path = Path(file_record.file_path)
if file_path.exists():
try:
file_path.unlink()
logger.info("File deleted from disk: %s", file_path)
except OSError as exc:
logger.error("Failed to delete file from disk: %s", exc)
# Remove thumbnail from disk if it exists
if file_record.thumbnail_path:
thumb_path = Path(file_record.thumbnail_path)
if thumb_path.exists():
try:
thumb_path.unlink()
logger.info("Thumbnail deleted from disk: %s", thumb_path)
except OSError as exc:
logger.error("Failed to delete thumbnail from disk: %s", exc)
# Remove from DB
await db.delete(file_record)
await db.flush()
return file_record
+88
View File
@@ -0,0 +1,88 @@
"""Thumbnail generation utilities using Pillow.
Generates 200x200 thumbnails for image files (jpg, png, webp).
Non-image files return None (no thumbnail).
"""
import logging
from pathlib import Path
from typing import Optional
from PIL import Image, ImageOps
logger = logging.getLogger(__name__)
THUMBNAIL_SIZE = (200, 200)
IMAGE_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
}
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
def is_image_mime_type(mime_type: str) -> bool:
"""Check if the given MIME type is a supported image type."""
return mime_type.lower() in IMAGE_MIME_TYPES
def generate_thumbnail(
source_path: str | Path,
thumbnail_dir: str | Path,
stored_filename: str,
) -> Optional[str]:
"""Generate a 200x200 thumbnail for an image file.
Args:
source_path: Path to the original image file.
thumbnail_dir: Directory where thumbnails are stored.
stored_filename: The stored filename of the original file.
Returns:
Relative path to the thumbnail file, or None if the file is not
a supported image or thumbnail generation fails.
"""
source_path = Path(source_path)
thumbnail_dir = Path(thumbnail_dir)
# Check if the file extension is a supported image type
ext = source_path.suffix.lower().lstrip(".")
if ext not in IMAGE_EXTENSIONS:
return None
try:
thumbnail_dir.mkdir(parents=True, exist_ok=True)
thumbnail_name = f"thumb_{stored_filename}"
# Use .jpg for thumbnails to save space
if ext in ("jpg", "jpeg"):
thumbnail_name = f"thumb_{Path(stored_filename).stem}.jpg"
elif ext == "png":
thumbnail_name = f"thumb_{Path(stored_filename).stem}.png"
elif ext == "webp":
thumbnail_name = f"thumb_{Path(stored_filename).stem}.webp"
thumbnail_path = thumbnail_dir / thumbnail_name
with Image.open(source_path) as img:
# Convert to RGB if necessary (e.g., for RGBA/P mode images)
if img.mode in ("RGBA", "P", "LA"):
# Create a white background for transparency
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# Use ImageOps.fit for a centered crop to exact thumbnail size
thumbnail = ImageOps.fit(img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS)
thumbnail.save(thumbnail_path, quality=85, optimize=True)
logger.info("Thumbnail generated: %s", thumbnail_path)
return str(thumbnail_path)
except Exception as exc:
logger.error("Failed to generate thumbnail for %s: %s", source_path, exc)
return None