feat: add core application constants, ownership verification, and repository utilities

- Introduced application-wide constants for file uploads, image processing, pagination, and authentication in `constants.py`.
- Implemented synchronous and asynchronous board ownership verification functions in `ownership.py`.
- Created a base repository class with common CRUD operations in `repository.py`.
- Added standard response utilities for error and success messages in `responses.py`.
- Refactored image validation to utilize constants for file size and MIME types.
- Enhanced frontend components with consistent styling and validation utilities for forms.
- Established global styles for buttons, forms, loading indicators, and messages to ensure a cohesive UI experience.
This commit is contained in:
Danilo Reyes
2025-11-02 13:44:10 -06:00
parent ca81729c50
commit f85ae4d417
17 changed files with 1163 additions and 237 deletions

View File

@@ -0,0 +1,38 @@
"""Application-wide constants."""
# File upload limits
MAX_IMAGE_SIZE = 52_428_800 # 50MB in bytes
MAX_ZIP_SIZE = 209_715_200 # 200MB in bytes
# Image processing
MAX_IMAGE_DIMENSION = 10_000 # Max width or height in pixels
THUMBNAIL_SIZES = {
"low": 800, # For slow connections (<1 Mbps)
"medium": 1600, # For medium connections (1-5 Mbps)
"high": 3200, # For fast connections (>5 Mbps)
}
# Pagination defaults
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 100
# Board limits
MAX_BOARD_TITLE_LENGTH = 255
MAX_BOARD_DESCRIPTION_LENGTH = 1000
MAX_IMAGES_PER_BOARD = 1000
# Authentication
TOKEN_EXPIRE_HOURS = 168 # 7 days
PASSWORD_MIN_LENGTH = 8
# Supported image formats
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
}
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}

View File

@@ -0,0 +1,69 @@
"""Ownership verification utilities."""
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.database.models.board import Board
def verify_board_ownership_sync(db: Session, board_id: UUID, user_id: UUID) -> Board:
"""
Verify board ownership (synchronous).
Args:
db: Database session
board_id: Board UUID
user_id: User UUID
Returns:
Board instance if owned by user
Raises:
HTTPException: 404 if board not found or not owned by user
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
board = db.execute(stmt).scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
return board
async def verify_board_ownership_async(db: AsyncSession, board_id: UUID, user_id: UUID) -> Board:
"""
Verify board ownership (asynchronous).
Args:
db: Async database session
board_id: Board UUID
user_id: User UUID
Returns:
Board instance if owned by user
Raises:
HTTPException: 404 if board not found or not owned by user
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
result = await db.execute(stmt)
board = result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
return board

View File

@@ -0,0 +1,119 @@
"""Base repository with common database operations."""
from typing import TypeVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
# Type variable for model classes
ModelType = TypeVar("ModelType")
class BaseRepository[ModelType]:
"""Base repository with common CRUD operations."""
def __init__(self, model: type[ModelType], db: Session | AsyncSession):
"""
Initialize repository.
Args:
model: SQLAlchemy model class
db: Database session (sync or async)
"""
self.model = model
self.db = db
def get_by_id_sync(self, id: UUID) -> ModelType | None:
"""
Get entity by ID (synchronous).
Args:
id: Entity UUID
Returns:
Entity if found, None otherwise
"""
return self.db.query(self.model).filter(self.model.id == id).first()
async def get_by_id_async(self, id: UUID) -> ModelType | None:
"""
Get entity by ID (asynchronous).
Args:
id: Entity UUID
Returns:
Entity if found, None otherwise
"""
stmt = select(self.model).where(self.model.id == id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
def count_sync(self, **filters) -> int:
"""
Count entities with optional filters (synchronous).
Args:
**filters: Column filters (column_name=value)
Returns:
Count of matching entities
"""
query = self.db.query(func.count(self.model.id))
for key, value in filters.items():
query = query.filter(getattr(self.model, key) == value)
return query.scalar()
async def count_async(self, **filters) -> int:
"""
Count entities with optional filters (asynchronous).
Args:
**filters: Column filters (column_name=value)
Returns:
Count of matching entities
"""
stmt = select(func.count(self.model.id))
for key, value in filters.items():
stmt = stmt.where(getattr(self.model, key) == value)
result = await self.db.execute(stmt)
return result.scalar_one()
def delete_sync(self, id: UUID) -> bool:
"""
Delete entity by ID (synchronous).
Args:
id: Entity UUID
Returns:
True if deleted, False if not found
"""
entity = self.get_by_id_sync(id)
if not entity:
return False
self.db.delete(entity)
self.db.commit()
return True
async def delete_async(self, id: UUID) -> bool:
"""
Delete entity by ID (asynchronous).
Args:
id: Entity UUID
Returns:
True if deleted, False if not found
"""
entity = await self.get_by_id_async(id)
if not entity:
return False
await self.db.delete(entity)
await self.db.commit()
return True

View File

@@ -0,0 +1,75 @@
"""Standard response utilities."""
from typing import Any
from fastapi import status
class ErrorResponse:
"""Standard error response formats."""
@staticmethod
def not_found(resource: str = "Resource") -> dict[str, Any]:
"""404 Not Found response."""
return {
"status_code": status.HTTP_404_NOT_FOUND,
"detail": f"{resource} not found",
}
@staticmethod
def forbidden(message: str = "Access denied") -> dict[str, Any]:
"""403 Forbidden response."""
return {
"status_code": status.HTTP_403_FORBIDDEN,
"detail": message,
}
@staticmethod
def unauthorized(message: str = "Authentication required") -> dict[str, Any]:
"""401 Unauthorized response."""
return {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": message,
"headers": {"WWW-Authenticate": "Bearer"},
}
@staticmethod
def bad_request(message: str) -> dict[str, Any]:
"""400 Bad Request response."""
return {
"status_code": status.HTTP_400_BAD_REQUEST,
"detail": message,
}
@staticmethod
def conflict(message: str) -> dict[str, Any]:
"""409 Conflict response."""
return {
"status_code": status.HTTP_409_CONFLICT,
"detail": message,
}
class SuccessResponse:
"""Standard success response formats."""
@staticmethod
def created(data: dict[str, Any], message: str = "Created successfully") -> dict[str, Any]:
"""201 Created response."""
return {
"message": message,
"data": data,
}
@staticmethod
def ok(data: dict[str, Any] | None = None, message: str = "Success") -> dict[str, Any]:
"""200 OK response."""
response = {"message": message}
if data:
response["data"] = data
return response
@staticmethod
def no_content() -> None:
"""204 No Content response."""
return None

View File

@@ -3,21 +3,11 @@
import magic
from fastapi import HTTPException, UploadFile, status
# Maximum file size: 50MB
MAX_FILE_SIZE = 52_428_800
# Allowed MIME types
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
}
# Allowed file extensions
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
from app.core.constants import (
ALLOWED_EXTENSIONS,
ALLOWED_MIME_TYPES,
MAX_IMAGE_SIZE,
)
async def validate_image_file(file: UploadFile) -> bytes:
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
if file_size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
if file_size > MAX_FILE_SIZE:
if file_size > MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / 1_048_576:.1f}MB",
detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB",
)
# Validate file extension