Add initial project configuration and setup for Reference Board Viewer application. Include EditorConfig for consistent coding styles, pre-commit hooks for linting and formatting, Docker Compose for local development with PostgreSQL and MinIO, and a Nix flake for development environment management. Establish CI/CD pipeline for automated testing and deployment.
This commit is contained in:
115
backend/alembic.ini
Normal file
115
backend/alembic.ini
Normal file
@@ -0,0 +1,115 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# format using "ruff" - use the exec runner, execute a binary
|
||||
hooks = ruff
|
||||
ruff.type = exec
|
||||
ruff.executable = ruff
|
||||
ruff.options = format REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
92
backend/alembic/env.py
Normal file
92
backend/alembic/env.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Import all models here for autogenerate to detect them
|
||||
from app.database.base import Base # noqa
|
||||
from app.database.models import * # noqa
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Get database URL from environment or config
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
27
backend/alembic/script.py.mako
Normal file
27
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
4
backend/app/__init__.py
Normal file
4
backend/app/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Reference Board Viewer - Backend API."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
2
backend/app/api/__init__.py
Normal file
2
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API endpoints."""
|
||||
|
||||
2
backend/app/core/__init__.py
Normal file
2
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core application modules."""
|
||||
|
||||
93
backend/app/core/config.py
Normal file
93
backend/app/core/config.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Application configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from pydantic import PostgresDsn, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "Reference Board Viewer"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: PostgresDsn
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 0
|
||||
|
||||
# JWT Authentication
|
||||
SECRET_KEY: str
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# MinIO Storage
|
||||
MINIO_ENDPOINT: str
|
||||
MINIO_ACCESS_KEY: str
|
||||
MINIO_SECRET_KEY: str
|
||||
MINIO_BUCKET: str = "webref"
|
||||
MINIO_SECURE: bool = False
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
@field_validator("CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: Any) -> list[str]:
|
||||
"""Parse CORS origins from string or list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE: int = 52428800 # 50MB
|
||||
MAX_BATCH_SIZE: int = 524288000 # 500MB
|
||||
ALLOWED_MIME_TYPES: list[str] = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]
|
||||
|
||||
@field_validator("ALLOWED_MIME_TYPES", mode="before")
|
||||
@classmethod
|
||||
def parse_mime_types(cls, v: Any) -> list[str]:
|
||||
"""Parse MIME types from string or list."""
|
||||
if isinstance(v, str):
|
||||
return [mime.strip() for mime in v.split(",")]
|
||||
return v
|
||||
|
||||
# Performance
|
||||
REQUEST_TIMEOUT: int = 30
|
||||
MAX_CONCURRENT_UPLOADS: int = 10
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
PASSWORD_MIN_LENGTH: int = 8
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached application settings."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Export settings instance
|
||||
settings = get_settings()
|
||||
|
||||
12
backend/app/core/deps.py
Normal file
12
backend/app/core/deps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Dependency injection utilities."""
|
||||
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.session import get_db
|
||||
|
||||
# Database session dependency
|
||||
DatabaseSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
68
backend/app/core/errors.py
Normal file
68
backend/app/core/errors.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Custom exception classes."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class WebRefException(Exception):
|
||||
"""Base exception for all custom exceptions."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500, details: dict[str, Any] | None = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ValidationError(WebRefException):
|
||||
"""Validation error."""
|
||||
|
||||
def __init__(self, message: str, details: dict[str, Any] | None = None):
|
||||
super().__init__(message, status_code=422, details=details)
|
||||
|
||||
|
||||
class AuthenticationError(WebRefException):
|
||||
"""Authentication error."""
|
||||
|
||||
def __init__(self, message: str = "Authentication failed"):
|
||||
super().__init__(message, status_code=401)
|
||||
|
||||
|
||||
class AuthorizationError(WebRefException):
|
||||
"""Authorization error."""
|
||||
|
||||
def __init__(self, message: str = "Insufficient permissions"):
|
||||
super().__init__(message, status_code=403)
|
||||
|
||||
|
||||
class NotFoundError(WebRefException):
|
||||
"""Resource not found error."""
|
||||
|
||||
def __init__(self, resource: str, resource_id: str | None = None):
|
||||
message = f"{resource} not found"
|
||||
if resource_id:
|
||||
message = f"{resource} with id {resource_id} not found"
|
||||
super().__init__(message, status_code=404)
|
||||
|
||||
|
||||
class ConflictError(WebRefException):
|
||||
"""Resource conflict error."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message, status_code=409)
|
||||
|
||||
|
||||
class FileTooLargeError(WebRefException):
|
||||
"""File size exceeds limit."""
|
||||
|
||||
def __init__(self, max_size: int):
|
||||
message = f"File size exceeds maximum allowed size of {max_size} bytes"
|
||||
super().__init__(message, status_code=413)
|
||||
|
||||
|
||||
class UnsupportedFileTypeError(WebRefException):
|
||||
"""Unsupported file type."""
|
||||
|
||||
def __init__(self, file_type: str, allowed_types: list[str]):
|
||||
message = f"File type '{file_type}' not supported. Allowed types: {', '.join(allowed_types)}"
|
||||
super().__init__(message, status_code=415)
|
||||
|
||||
34
backend/app/core/logging.py
Normal file
34
backend/app/core/logging.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
|
||||
# Get log level from settings
|
||||
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
],
|
||||
)
|
||||
|
||||
# Set library log levels
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
logging.getLogger("boto3").setLevel(logging.WARNING)
|
||||
logging.getLogger("botocore").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging configured with level: {settings.LOG_LEVEL}")
|
||||
|
||||
29
backend/app/core/middleware.py
Normal file
29
backend/app/core/middleware.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""CORS and other middleware configuration."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def setup_middleware(app: FastAPI) -> None:
|
||||
"""Configure application middleware."""
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Security headers (optional, add more as needed)
|
||||
# Note: TrustedHostMiddleware not added by default in dev
|
||||
# Uncomment for production:
|
||||
# app.add_middleware(
|
||||
# TrustedHostMiddleware,
|
||||
# allowed_hosts=["yourdomain.com", "*.yourdomain.com"]
|
||||
# )
|
||||
|
||||
64
backend/app/core/schemas.py
Normal file
64
backend/app/core/schemas.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Base Pydantic schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""Base schema with common configuration."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
json_schema_extra={
|
||||
"example": {}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TimestampSchema(BaseSchema):
|
||||
"""Schema with timestamp fields."""
|
||||
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime | None = Field(None, description="Last update timestamp")
|
||||
|
||||
|
||||
class IDSchema(BaseSchema):
|
||||
"""Schema with ID field."""
|
||||
|
||||
id: UUID = Field(..., description="Unique identifier")
|
||||
|
||||
|
||||
class ResponseSchema(BaseSchema):
|
||||
"""Generic response schema."""
|
||||
|
||||
message: str = Field(..., description="Response message")
|
||||
data: dict[str, Any] | None = Field(None, description="Response data")
|
||||
|
||||
|
||||
class ErrorSchema(BaseSchema):
|
||||
"""Error response schema."""
|
||||
|
||||
error: str = Field(..., description="Error message")
|
||||
details: dict[str, Any] | None = Field(None, description="Error details")
|
||||
status_code: int = Field(..., description="HTTP status code")
|
||||
|
||||
|
||||
class PaginationSchema(BaseSchema):
|
||||
"""Pagination metadata schema."""
|
||||
|
||||
total: int = Field(..., description="Total number of items")
|
||||
page: int = Field(..., description="Current page number")
|
||||
page_size: int = Field(..., description="Items per page")
|
||||
total_pages: int = Field(..., description="Total number of pages")
|
||||
|
||||
|
||||
class PaginatedResponse(BaseSchema):
|
||||
"""Paginated response schema."""
|
||||
|
||||
items: list[Any] = Field(..., description="List of items")
|
||||
pagination: PaginationSchema = Field(..., description="Pagination metadata")
|
||||
|
||||
119
backend/app/core/storage.py
Normal file
119
backend/app/core/storage.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""MinIO storage client utilities."""
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import BinaryIO
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StorageClient:
|
||||
"""MinIO storage client wrapper."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize MinIO client."""
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{'https' if settings.MINIO_SECURE else 'http'}://{settings.MINIO_ENDPOINT}",
|
||||
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.MINIO_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
self.bucket = settings.MINIO_BUCKET
|
||||
self._ensure_bucket_exists()
|
||||
|
||||
def _ensure_bucket_exists(self) -> None:
|
||||
"""Create bucket if it doesn't exist."""
|
||||
try:
|
||||
self.client.head_bucket(Bucket=self.bucket)
|
||||
except ClientError:
|
||||
logger.info(f"Creating bucket: {self.bucket}")
|
||||
self.client.create_bucket(Bucket=self.bucket)
|
||||
|
||||
def upload_file(self, file_data: BinaryIO, object_name: str, content_type: str) -> str:
|
||||
"""Upload file to MinIO.
|
||||
|
||||
Args:
|
||||
file_data: File data to upload
|
||||
object_name: S3 object name (path)
|
||||
content_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
str: Object URL
|
||||
|
||||
Raises:
|
||||
Exception: If upload fails
|
||||
"""
|
||||
try:
|
||||
self.client.upload_fileobj(
|
||||
file_data,
|
||||
self.bucket,
|
||||
object_name,
|
||||
ExtraArgs={"ContentType": content_type},
|
||||
)
|
||||
return f"{settings.MINIO_ENDPOINT}/{self.bucket}/{object_name}"
|
||||
except ClientError as e:
|
||||
logger.error(f"Failed to upload file {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def download_file(self, object_name: str) -> BytesIO:
|
||||
"""Download file from MinIO.
|
||||
|
||||
Args:
|
||||
object_name: S3 object name (path)
|
||||
|
||||
Returns:
|
||||
BytesIO: File data
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
try:
|
||||
file_data = BytesIO()
|
||||
self.client.download_fileobj(self.bucket, object_name, file_data)
|
||||
file_data.seek(0)
|
||||
return file_data
|
||||
except ClientError as e:
|
||||
logger.error(f"Failed to download file {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def delete_file(self, object_name: str) -> None:
|
||||
"""Delete file from MinIO.
|
||||
|
||||
Args:
|
||||
object_name: S3 object name (path)
|
||||
|
||||
Raises:
|
||||
Exception: If deletion fails
|
||||
"""
|
||||
try:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=object_name)
|
||||
except ClientError as e:
|
||||
logger.error(f"Failed to delete file {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def file_exists(self, object_name: str) -> bool:
|
||||
"""Check if file exists in MinIO.
|
||||
|
||||
Args:
|
||||
object_name: S3 object name (path)
|
||||
|
||||
Returns:
|
||||
bool: True if file exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
self.client.head_object(Bucket=self.bucket, Key=object_name)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
|
||||
# Global storage client instance
|
||||
storage_client = StorageClient()
|
||||
|
||||
2
backend/app/database/__init__.py
Normal file
2
backend/app/database/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Database models and session management."""
|
||||
|
||||
30
backend/app/database/base.py
Normal file
30
backend/app/database/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Base model for all database models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
|
||||
# Generate __tablename__ automatically from class name
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str:
|
||||
"""Generate table name from class name."""
|
||||
# Convert CamelCase to snake_case
|
||||
name = cls.__name__
|
||||
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
|
||||
|
||||
# Common columns for all models
|
||||
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def dict(self) -> dict[str, Any]:
|
||||
"""Convert model to dictionary."""
|
||||
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
|
||||
|
||||
5
backend/app/database/models/__init__.py
Normal file
5
backend/app/database/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Database models."""
|
||||
|
||||
# Import all models here for Alembic autogenerate
|
||||
# Models will be created in separate phases
|
||||
|
||||
28
backend/app/database/session.py
Normal file
28
backend/app/database/session.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Database session management."""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Create SQLAlchemy engine
|
||||
engine = create_engine(
|
||||
str(settings.DATABASE_URL),
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
echo=settings.DEBUG, # Log SQL queries in debug mode
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for getting database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
102
backend/app/main.py
Normal file
102
backend/app/main.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import WebRefException
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.middleware import setup_middleware
|
||||
|
||||
# Setup logging
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Reference Board Viewer - Web-based visual reference management",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
|
||||
)
|
||||
|
||||
# Setup middleware
|
||||
setup_middleware(app)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(WebRefException)
|
||||
async def webref_exception_handler(request: Request, exc: WebRefException):
|
||||
"""Handle custom WebRef exceptions."""
|
||||
logger.error(f"WebRef exception: {exc.message}", extra={"details": exc.details})
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": exc.message,
|
||||
"details": exc.details,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle unexpected exceptions."""
|
||||
logger.exception("Unexpected error occurred")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "Internal server error",
|
||||
"details": str(exc) if settings.DEBUG else {},
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/health", tags=["System"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.APP_VERSION,
|
||||
"app": settings.APP_NAME,
|
||||
}
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/", tags=["System"])
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME} API",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
|
||||
# API routers will be added here in subsequent phases
|
||||
# Example:
|
||||
# from app.api import auth, boards, images
|
||||
# app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}/auth", tags=["Auth"])
|
||||
# app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}/boards", tags=["Boards"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Application startup tasks."""
|
||||
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
||||
logger.info(f"Debug mode: {settings.DEBUG}")
|
||||
logger.info(f"API prefix: {settings.API_V1_PREFIX}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Application shutdown tasks."""
|
||||
logger.info(f"Shutting down {settings.APP_NAME}")
|
||||
|
||||
84
backend/pyproject.toml
Normal file
84
backend/pyproject.toml
Normal file
@@ -0,0 +1,84 @@
|
||||
[project]
|
||||
name = "webref-backend"
|
||||
version = "1.0.0"
|
||||
description = "Reference Board Viewer - Backend API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.13.0",
|
||||
"pydantic>=2.9.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"pillow>=11.0.0",
|
||||
"boto3>=1.35.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"ruff>=0.7.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
# Enable pycodestyle (`E`), Pyflakes (`F`), isort (`I`)
|
||||
select = ["E", "F", "I", "W", "N", "UP", "B", "C4", "SIM"]
|
||||
ignore = []
|
||||
|
||||
# Exclude common paths
|
||||
exclude = [
|
||||
".git",
|
||||
".ruff_cache",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"alembic/versions",
|
||||
]
|
||||
|
||||
# Same as Black.
|
||||
line-length = 100
|
||||
|
||||
# Allow unused variables when underscore-prefixed.
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
# Target Python 3.12
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # Allow unused imports in __init__.py
|
||||
"tests/*" = ["S101"] # Allow assert in tests
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
addopts = [
|
||||
"--strict-markers",
|
||||
"--tb=short",
|
||||
"--cov=app",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--cov-fail-under=80",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["tests/*", "alembic/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
precision = 2
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
|
||||
54
backend/pytest.ini
Normal file
54
backend/pytest.ini
Normal file
@@ -0,0 +1,54 @@
|
||||
[pytest]
|
||||
# Test discovery
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Output options
|
||||
addopts =
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=app
|
||||
--cov-report=term-missing:skip-covered
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-fail-under=80
|
||||
-v
|
||||
--color=yes
|
||||
|
||||
# Async support
|
||||
asyncio_mode = auto
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
unit: marks tests as unit tests
|
||||
auth: marks tests related to authentication
|
||||
boards: marks tests related to boards
|
||||
images: marks tests related to images
|
||||
upload: marks tests related to file uploads
|
||||
|
||||
# Coverage options
|
||||
[coverage:run]
|
||||
source = app
|
||||
omit =
|
||||
tests/*
|
||||
alembic/*
|
||||
app/__init__.py
|
||||
*/migrations/*
|
||||
|
||||
[coverage:report]
|
||||
precision = 2
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
if TYPE_CHECKING:
|
||||
@abstractmethod
|
||||
|
||||
Reference in New Issue
Block a user