001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
32 changed files with 470 additions and 171 deletions
Showing only changes of commit b55ac51fe2 - Show all commits

View File

@@ -59,6 +59,56 @@ npm run dev
- Backend API Docs: http://localhost:8000/docs - Backend API Docs: http://localhost:8000/docs
- Backend Health: http://localhost:8000/health - Backend Health: http://localhost:8000/health
## Code Quality & Linting
### Unified Linting (All Languages)
```bash
# Check all code (Python + TypeScript/Svelte)
./scripts/lint.sh
# OR using nix:
nix run .#lint
# Auto-fix all issues
nix run .#lint-fix
```
### Git Hooks (Automatic)
Install git hooks to run linting automatically:
```bash
./scripts/install-hooks.sh
```
This installs:
- **pre-commit**: Runs linting before each commit
- **pre-push**: Runs tests before push (optional)
To skip hooks when committing:
```bash
git commit --no-verify
```
### Manual Linting
**Backend (Python):**
```bash
cd backend
ruff check app/ # Check for issues
ruff check --fix app/ # Auto-fix issues
ruff format app/ # Format code
```
**Frontend (TypeScript/Svelte):**
```bash
cd frontend
npm run lint # ESLint check
npm run check # TypeScript check
npx prettier --check src/ # Prettier check
npx prettier --write src/ # Auto-format
```
## Project Structure ## Project Structure
``` ```

View File

@@ -1,4 +1,3 @@
"""Reference Board Viewer - Backend API.""" """Reference Board Viewer - Backend API."""
__version__ = "1.0.0" __version__ = "1.0.0"

View File

@@ -1,4 +1,5 @@
"""Authentication endpoints.""" """Authentication endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,37 +17,31 @@ router = APIRouter(prefix="/auth", tags=["auth"])
def register_user(user_data: UserCreate, db: Session = Depends(get_db)): def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
""" """
Register a new user. Register a new user.
Args: Args:
user_data: User registration data user_data: User registration data
db: Database session db: Database session
Returns: Returns:
Created user information Created user information
Raises: Raises:
HTTPException: If email already exists or password is weak HTTPException: If email already exists or password is weak
""" """
repo = UserRepository(db) repo = UserRepository(db)
# Check if email already exists # Check if email already exists
if repo.email_exists(user_data.email): if repo.email_exists(user_data.email):
raise HTTPException( raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered"
)
# Validate password strength # Validate password strength
is_valid, error_message = validate_password_strength(user_data.password) is_valid, error_message = validate_password_strength(user_data.password)
if not is_valid: if not is_valid:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message)
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message
)
# Create user # Create user
user = repo.create_user(email=user_data.email, password=user_data.password) user = repo.create_user(email=user_data.email, password=user_data.password)
return UserResponse.model_validate(user) return UserResponse.model_validate(user)
@@ -54,22 +49,22 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
def login_user(login_data: UserLogin, db: Session = Depends(get_db)): def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
""" """
Login user and return JWT token. Login user and return JWT token.
Args: Args:
login_data: Login credentials login_data: Login credentials
db: Database session db: Database session
Returns: Returns:
JWT access token and user information JWT access token and user information
Raises: Raises:
HTTPException: If credentials are invalid HTTPException: If credentials are invalid
""" """
repo = UserRepository(db) repo = UserRepository(db)
# Get user by email # Get user by email
user = repo.get_user_by_email(login_data.email) user = repo.get_user_by_email(login_data.email)
# Verify user exists and password is correct # Verify user exists and password is correct
if not user or not verify_password(login_data.password, user.password_hash): if not user or not verify_password(login_data.password, user.password_hash):
raise HTTPException( raise HTTPException(
@@ -77,34 +72,26 @@ def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
detail="Incorrect email or password", detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Check if user is active # Check if user is active
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
# Create access token # Create access token
access_token = create_access_token(user_id=user.id, email=user.email) access_token = create_access_token(user_id=user.id, email=user.email)
return TokenResponse( return TokenResponse(access_token=access_token, token_type="bearer", user=UserResponse.model_validate(user))
access_token=access_token,
token_type="bearer",
user=UserResponse.model_validate(user)
)
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
def get_current_user_info(current_user: User = Depends(get_current_user)): def get_current_user_info(current_user: User = Depends(get_current_user)):
""" """
Get current authenticated user information. Get current authenticated user information.
Args: Args:
current_user: Current authenticated user (from JWT) current_user: Current authenticated user (from JWT)
Returns: Returns:
Current user information Current user information
""" """
return UserResponse.model_validate(current_user) return UserResponse.model_validate(current_user)

View File

@@ -1,2 +1 @@
"""Authentication module.""" """Authentication module."""

View File

@@ -1,6 +1,6 @@
"""JWT token generation and validation.""" """JWT token generation and validation."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID from uuid import UUID
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -8,15 +8,15 @@ from jose import JWTError, jwt
from app.core.config import settings from app.core.config import settings
def create_access_token(user_id: UUID, email: str, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | None = None) -> str:
""" """
Create a new JWT access token. Create a new JWT access token.
Args: Args:
user_id: User's UUID user_id: User's UUID
email: User's email address email: User's email address
expires_delta: Optional custom expiration time expires_delta: Optional custom expiration time
Returns: Returns:
Encoded JWT token string Encoded JWT token string
""" """
@@ -24,26 +24,20 @@ def create_access_token(user_id: UUID, email: str, expires_delta: Optional[timed
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
else: else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = { to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.utcnow(), "type": "access"}
"sub": str(user_id),
"email": email,
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]: def decode_access_token(token: str) -> dict | None:
""" """
Decode and validate a JWT access token. Decode and validate a JWT access token.
Args: Args:
token: JWT token string to decode token: JWT token string to decode
Returns: Returns:
Decoded token payload if valid, None otherwise Decoded token payload if valid, None otherwise
""" """
@@ -52,4 +46,3 @@ def decode_access_token(token: str) -> Optional[dict]:
return payload return payload
except JWTError: except JWTError:
return None return None

View File

@@ -1,9 +1,7 @@
"""User repository for database operations.""" """User repository for database operations."""
from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.security import hash_password from app.auth.security import hash_password
@@ -16,7 +14,7 @@ class UserRepository:
def __init__(self, db: Session): def __init__(self, db: Session):
""" """
Initialize repository. Initialize repository.
Args: Args:
db: Database session db: Database session
""" """
@@ -25,48 +23,45 @@ class UserRepository:
def create_user(self, email: str, password: str) -> User: def create_user(self, email: str, password: str) -> User:
""" """
Create a new user. Create a new user.
Args: Args:
email: User email (will be lowercased) email: User email (will be lowercased)
password: Plain text password (will be hashed) password: Plain text password (will be hashed)
Returns: Returns:
Created user instance Created user instance
""" """
email = email.lower() email = email.lower()
password_hash = hash_password(password) password_hash = hash_password(password)
user = User( user = User(email=email, password_hash=password_hash)
email=email,
password_hash=password_hash
)
self.db.add(user) self.db.add(user)
self.db.commit() self.db.commit()
self.db.refresh(user) self.db.refresh(user)
return user return user
def get_user_by_email(self, email: str) -> Optional[User]: def get_user_by_email(self, email: str) -> User | None:
""" """
Get user by email address. Get user by email address.
Args: Args:
email: User email to search for email: User email to search for
Returns: Returns:
User if found, None otherwise User if found, None otherwise
""" """
email = email.lower() email = email.lower()
return self.db.query(User).filter(User.email == email).first() return self.db.query(User).filter(User.email == email).first()
def get_user_by_id(self, user_id: UUID) -> Optional[User]: def get_user_by_id(self, user_id: UUID) -> User | None:
""" """
Get user by ID. Get user by ID.
Args: Args:
user_id: User UUID user_id: User UUID
Returns: Returns:
User if found, None otherwise User if found, None otherwise
""" """
@@ -75,13 +70,12 @@ class UserRepository:
def email_exists(self, email: str) -> bool: def email_exists(self, email: str) -> bool:
""" """
Check if email already exists. Check if email already exists.
Args: Args:
email: Email to check email: Email to check
Returns: Returns:
True if email exists, False otherwise True if email exists, False otherwise
""" """
email = email.lower() email = email.lower()
return self.db.query(User).filter(User.email == email).first() is not None return self.db.query(User).filter(User.email == email).first() is not None

View File

@@ -1,6 +1,6 @@
"""Authentication schemas for request/response validation.""" """Authentication schemas for request/response validation."""
from datetime import datetime from datetime import datetime
from typing import Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@@ -42,4 +42,3 @@ class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: UserResponse user: UserResponse

View File

@@ -1,5 +1,7 @@
"""Password hashing utilities using passlib.""" """Password hashing utilities using passlib."""
import re import re
from passlib.context import CryptContext from passlib.context import CryptContext
# Create password context for hashing and verification # Create password context for hashing and verification
@@ -9,10 +11,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
""" """
Hash a password using bcrypt. Hash a password using bcrypt.
Args: Args:
password: Plain text password password: Plain text password
Returns: Returns:
Hashed password string Hashed password string
""" """
@@ -22,11 +24,11 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
""" """
Verify a plain password against a hashed password. Verify a plain password against a hashed password.
Args: Args:
plain_password: Plain text password to verify plain_password: Plain text password to verify
hashed_password: Hashed password from database hashed_password: Hashed password from database
Returns: Returns:
True if password matches, False otherwise True if password matches, False otherwise
""" """
@@ -36,30 +38,29 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def validate_password_strength(password: str) -> tuple[bool, str]: def validate_password_strength(password: str) -> tuple[bool, str]:
""" """
Validate password meets complexity requirements. Validate password meets complexity requirements.
Requirements: Requirements:
- At least 8 characters - At least 8 characters
- At least 1 uppercase letter - At least 1 uppercase letter
- At least 1 lowercase letter - At least 1 lowercase letter
- At least 1 number - At least 1 number
Args: Args:
password: Plain text password to validate password: Plain text password to validate
Returns: Returns:
Tuple of (is_valid, error_message) Tuple of (is_valid, error_message)
""" """
if len(password) < 8: if len(password) < 8:
return False, "Password must be at least 8 characters long" return False, "Password must be at least 8 characters long"
if not re.search(r"[A-Z]", password): if not re.search(r"[A-Z]", password):
return False, "Password must contain at least one uppercase letter" return False, "Password must contain at least one uppercase letter"
if not re.search(r"[a-z]", password): if not re.search(r"[a-z]", password):
return False, "Password must contain at least one lowercase letter" return False, "Password must contain at least one lowercase letter"
if not re.search(r"\d", password): if not re.search(r"\d", password):
return False, "Password must contain at least one number" return False, "Password must contain at least one number"
return True, ""
return True, ""

View File

@@ -1,2 +1 @@
"""Core application modules.""" """Core application modules."""

View File

@@ -90,4 +90,3 @@ def get_settings() -> Settings:
# Export settings instance # Export settings instance
settings = get_settings() settings = get_settings()

View File

@@ -1,6 +1,6 @@
"""Dependency injection utilities.""" """Dependency injection utilities."""
from typing import Annotated, Generator from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -19,33 +19,32 @@ security = HTTPBearer()
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
db: Session = Depends(get_db)
) -> User: ) -> User:
""" """
Get current authenticated user from JWT token. Get current authenticated user from JWT token.
Args: Args:
credentials: HTTP Authorization Bearer token credentials: HTTP Authorization Bearer token
db: Database session db: Database session
Returns: Returns:
Current authenticated user Current authenticated user
Raises: Raises:
HTTPException: If token is invalid or user not found HTTPException: If token is invalid or user not found
""" """
# Decode token # Decode token
token = credentials.credentials token = credentials.credentials
payload = decode_access_token(token) payload = decode_access_token(token)
if payload is None: if payload is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials", detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Extract user ID from token # Extract user ID from token
user_id_str: str = payload.get("sub") user_id_str: str = payload.get("sub")
if user_id_str is None: if user_id_str is None:
@@ -54,7 +53,7 @@ def get_current_user(
detail="Invalid token payload", detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
user_id = UUID(user_id_str) user_id = UUID(user_id_str)
except ValueError: except ValueError:
@@ -62,23 +61,19 @@ def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid user ID in token", detail="Invalid user ID in token",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) ) from None
# Get user from database # Get user from database
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
return user
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user

View File

@@ -65,4 +65,3 @@ class UnsupportedFileTypeError(WebRefException):
def __init__(self, file_type: str, allowed_types: list[str]): def __init__(self, file_type: str, allowed_types: list[str]):
message = f"File type '{file_type}' not supported. Allowed types: {', '.join(allowed_types)}" message = f"File type '{file_type}' not supported. Allowed types: {', '.join(allowed_types)}"
super().__init__(message, status_code=415) super().__init__(message, status_code=415)

View File

@@ -8,27 +8,24 @@ from app.core.config import settings
def setup_logging() -> None: def setup_logging() -> None:
"""Configure application logging.""" """Configure application logging."""
# Get log level from settings # Get log level from settings
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
# Configure root logger # Configure root logger
logging.basicConfig( logging.basicConfig(
level=log_level, level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
handlers=[ handlers=[logging.StreamHandler(sys.stdout)],
logging.StreamHandler(sys.stdout)
],
) )
# Set library log levels # Set library log levels
logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.INFO) logging.getLogger("uvicorn.access").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("boto3").setLevel(logging.WARNING) logging.getLogger("boto3").setLevel(logging.WARNING)
logging.getLogger("botocore").setLevel(logging.WARNING) logging.getLogger("botocore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Logging configured with level: {settings.LOG_LEVEL}") logger.info(f"Logging configured with level: {settings.LOG_LEVEL}")

View File

@@ -2,7 +2,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from app.core.config import settings from app.core.config import settings
@@ -26,4 +25,3 @@ def setup_middleware(app: FastAPI) -> None:
# TrustedHostMiddleware, # TrustedHostMiddleware,
# allowed_hosts=["yourdomain.com", "*.yourdomain.com"] # allowed_hosts=["yourdomain.com", "*.yourdomain.com"]
# ) # )

View File

@@ -10,13 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
class BaseSchema(BaseModel): class BaseSchema(BaseModel):
"""Base schema with common configuration.""" """Base schema with common configuration."""
model_config = ConfigDict( model_config = ConfigDict(from_attributes=True, populate_by_name=True, json_schema_extra={"example": {}})
from_attributes=True,
populate_by_name=True,
json_schema_extra={
"example": {}
}
)
class TimestampSchema(BaseSchema): class TimestampSchema(BaseSchema):
@@ -61,4 +55,3 @@ class PaginatedResponse(BaseSchema):
items: list[Any] = Field(..., description="List of items") items: list[Any] = Field(..., description="List of items")
pagination: PaginationSchema = Field(..., description="Pagination metadata") pagination: PaginationSchema = Field(..., description="Pagination metadata")

View File

@@ -116,4 +116,3 @@ class StorageClient:
# Global storage client instance # Global storage client instance
storage_client = StorageClient() storage_client = StorageClient()

View File

@@ -1,2 +1 @@
"""Database models and session management.""" """Database models and session management."""

View File

@@ -14,10 +14,10 @@ class Base(DeclarativeBase):
# Generate __tablename__ automatically from class name # Generate __tablename__ automatically from class name
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(self) -> str:
"""Generate table name from class name.""" """Generate table name from class name."""
# Convert CamelCase to snake_case # Convert CamelCase to snake_case
name = cls.__name__ name = self.__name__
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
# Common columns for all models # Common columns for all models
@@ -27,4 +27,3 @@ class Base(DeclarativeBase):
def dict(self) -> dict[str, Any]: def dict(self) -> dict[str, Any]:
"""Convert model to dictionary.""" """Convert model to dictionary."""
return {c.name: getattr(self, c.name) for c in self.__table__.columns} return {c.name: getattr(self, c.name) for c in self.__table__.columns}

View File

@@ -1,11 +1,12 @@
"""Database models.""" """Database models."""
from app.database.models.user import User
from app.database.models.board import Board from app.database.models.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.comment import Comment from app.database.models.comment import Comment
from app.database.models.group import Group
from app.database.models.image import Image
from app.database.models.share_link import ShareLink
from app.database.models.user import User
__all__ = [ __all__ = [
"User", "User",

View File

@@ -1,6 +1,8 @@
"""Board model for reference boards.""" """Board model for reference boards."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -17,11 +19,7 @@ class Board(Base):
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
viewport_state = Column( viewport_state = Column(JSONB, nullable=False, default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0})
JSONB,
nullable=False,
default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}
)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
is_deleted = Column(Boolean, nullable=False, default=False) is_deleted = Column(Boolean, nullable=False, default=False)
@@ -35,4 +33,3 @@ class Board(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Board(id={self.id}, title={self.title})>" return f"<Board(id={self.id}, title={self.title})>"

View File

@@ -1,6 +1,8 @@
"""BoardImage junction model.""" """BoardImage junction model."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -26,17 +28,15 @@ class BoardImage(Base):
"opacity": 1.0, "opacity": 1.0,
"flipped_h": False, "flipped_h": False,
"flipped_v": False, "flipped_v": False,
"greyscale": False "greyscale": False,
} },
) )
z_order = Column(Integer, nullable=False, default=0, index=True) z_order = Column(Integer, nullable=False, default=0, index=True)
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = ( __table_args__ = (UniqueConstraint("board_id", "image_id", name="uq_board_image"),)
UniqueConstraint("board_id", "image_id", name="uq_board_image"),
)
# Relationships # Relationships
board = relationship("Board", back_populates="board_images") board = relationship("Board", back_populates="board_images")
@@ -45,4 +45,3 @@ class BoardImage(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<BoardImage(board_id={self.board_id}, image_id={self.image_id})>" return f"<BoardImage(board_id={self.board_id}, image_id={self.image_id})>"

View File

@@ -1,6 +1,8 @@
"""Comment model for board comments.""" """Comment model for board comments."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -15,7 +17,9 @@ class Comment(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True) board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True) share_link_id = Column(
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
)
author_name = Column(String(100), nullable=False) author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
position = Column(JSONB, nullable=True) # Optional canvas position position = Column(JSONB, nullable=True) # Optional canvas position
@@ -28,4 +32,3 @@ class Comment(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Comment(id={self.id}, author={self.author_name})>" return f"<Comment(id={self.id}, author={self.author_name})>"

View File

@@ -1,6 +1,8 @@
"""Group model for image grouping.""" """Group model for image grouping."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, String, Text from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -27,4 +29,3 @@ class Group(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Group(id={self.id}, name={self.name})>" return f"<Group(id={self.id}, name={self.name})>"

View File

@@ -1,6 +1,8 @@
"""Image model for uploaded images.""" """Image model for uploaded images."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -31,4 +33,3 @@ class Image(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Image(id={self.id}, filename={self.filename})>" return f"<Image(id={self.id}, filename={self.filename})>"

View File

@@ -1,6 +1,8 @@
"""ShareLink model for board sharing.""" """ShareLink model for board sharing."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -29,4 +31,3 @@ class ShareLink(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<ShareLink(id={self.id}, token={self.token[:8]}...)>" return f"<ShareLink(id={self.id}, token={self.token[:8]}...)>"

View File

@@ -1,6 +1,8 @@
"""User model for authentication and ownership.""" """User model for authentication and ownership."""
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, String from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -26,4 +28,3 @@ class User(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<User(id={self.id}, email={self.email})>" return f"<User(id={self.id}, email={self.email})>"

View File

@@ -25,4 +25,3 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()

View File

@@ -5,6 +5,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api import auth
from app.core.config import settings from app.core.config import settings
from app.core.errors import WebRefException from app.core.errors import WebRefException
from app.core.logging import setup_logging from app.core.logging import setup_logging
@@ -81,7 +82,6 @@ async def root():
# API routers # API routers
from app.api import auth
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
# Additional routers will be added in subsequent phases # Additional routers will be added in subsequent phases
# from app.api import boards, images # from app.api import boards, images
@@ -101,4 +101,3 @@ async def startup_event():
async def shutdown_event(): async def shutdown_event():
"""Application shutdown tasks.""" """Application shutdown tasks."""
logger.info(f"Shutting down {settings.APP_NAME}") logger.info(f"Shutting down {settings.APP_NAME}")

View File

@@ -33,10 +33,6 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.ruff] [tool.ruff]
# Enable pycodestyle (`E`), Pyflakes (`F`), isort (`I`)
select = ["E", "F", "I", "W", "N", "UP", "B", "C4", "SIM"]
ignore = []
# Exclude common paths # Exclude common paths
exclude = [ exclude = [
".git", ".git",
@@ -46,16 +42,24 @@ exclude = [
"alembic/versions", "alembic/versions",
] ]
# Same as Black. # Line length (slightly longer for SQLAlchemy models)
line-length = 100 line-length = 120
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# Target Python 3.12 # Target Python 3.12
target-version = "py312" target-version = "py312"
[tool.ruff.per-file-ignores] [tool.ruff.lint]
# Enable pycodestyle (`E`), Pyflakes (`F`), isort (`I`)
select = ["E", "F", "I", "W", "N", "UP", "B", "C4", "SIM"]
ignore = [
"B008", # Allow Depends() in FastAPI function defaults
"N818", # Allow WebRefException without Error suffix
]
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # Allow unused imports in __init__.py "__init__.py" = ["F401"] # Allow unused imports in __init__.py
"tests/*" = ["S101"] # Allow assert in tests "tests/*" = ["S101"] # Allow assert in tests

View File

@@ -96,6 +96,67 @@
''; '';
}; };
# Apps - Scripts that can be run with `nix run`
apps = {
# Unified linting for all code
lint = {
type = "app";
program = "${pkgs.writeShellScript "lint" ''
set -e
cd ${self}
# Backend Python linting
echo "🔍 Linting backend Python code..."
cd backend
${pkgs.ruff}/bin/ruff check --no-cache app/
${pkgs.ruff}/bin/ruff format --check app/
cd ..
# Frontend linting (if node_modules exists)
if [ -d "frontend/node_modules" ]; then
echo ""
echo "🔍 Linting frontend TypeScript/Svelte code..."
cd frontend
npm run lint
npx prettier --check src/
npm run check
cd ..
else
echo " Frontend node_modules not found, run 'npm install' first"
fi
echo ""
echo " All linting checks passed!"
''}";
};
# Auto-fix linting issues
lint-fix = {
type = "app";
program = "${pkgs.writeShellScript "lint-fix" ''
set -e
cd ${self}
echo "🔧 Auto-fixing backend Python code..."
cd backend
${pkgs.ruff}/bin/ruff check --fix --no-cache app/
${pkgs.ruff}/bin/ruff format app/
cd ..
if [ -d "frontend/node_modules" ]; then
echo ""
echo "🔧 Auto-fixing frontend code..."
cd frontend
npx prettier --write src/
cd ..
fi
echo ""
echo " Auto-fix complete!"
''}";
};
};
# Package definitions (for production deployment) # Package definitions (for production deployment)
packages = { packages = {
# Backend package # Backend package

102
scripts/install-hooks.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Install git hooks for the project
set -e
HOOKS_DIR=".git/hooks"
SCRIPTS_DIR="scripts"
echo "Installing git hooks..."
echo ""
# Create hooks directory if it doesn't exist
mkdir -p "$HOOKS_DIR"
# Pre-commit hook
cat > "$HOOKS_DIR/pre-commit" << 'EOF'
#!/usr/bin/env bash
# Git pre-commit hook - runs linting before commit
echo "🔍 Running pre-commit linting..."
echo ""
# Try to use nix run if available, otherwise use script directly
if command -v nix &> /dev/null && [ -f "flake.nix" ]; then
# Use nix run for consistent environment
if ! nix run .#lint; then
echo ""
echo "❌ Linting failed. Fix errors or use --no-verify to skip."
echo " Auto-fix: nix run .#lint-fix"
exit 1
fi
else
# Fallback to script
if ! ./scripts/lint.sh; then
echo ""
echo "❌ Linting failed. Fix errors or use --no-verify to skip."
echo " Auto-fix: ./scripts/lint.sh --fix"
exit 1
fi
fi
echo ""
echo "✅ Pre-commit checks passed!"
EOF
chmod +x "$HOOKS_DIR/pre-commit"
echo "✓ Installed pre-commit hook"
# Pre-push hook (optional - runs tests)
cat > "$HOOKS_DIR/pre-push" << 'EOF'
#!/usr/bin/env bash
# Git pre-push hook - runs tests before push (optional)
# Comment out or remove if you want to push without running tests
echo "🧪 Running tests before push..."
echo ""
# Backend tests (if pytest is available)
if [ -d "backend" ] && command -v pytest &> /dev/null; then
cd backend
if ! pytest -xvs --tb=short; then
echo ""
echo "❌ Backend tests failed. Fix tests or use --no-verify to skip."
exit 1
fi
cd ..
fi
# Frontend tests (if npm test is available)
if [ -d "frontend/node_modules" ]; then
cd frontend
if ! npm test -- --run; then
echo ""
echo "❌ Frontend tests failed. Fix tests or use --no-verify to skip."
exit 1
fi
cd ..
fi
echo ""
echo "✅ All tests passed!"
EOF
chmod +x "$HOOKS_DIR/pre-push"
echo "✓ Installed pre-push hook (optional - runs tests)"
echo ""
echo "========================================="
echo "✅ Git hooks installed successfully!"
echo "========================================="
echo ""
echo "Hooks installed:"
echo " • pre-commit - Runs linting before commit"
echo " • pre-push - Runs tests before push (optional)"
echo ""
echo "To skip hooks when committing:"
echo " git commit --no-verify"
echo ""
echo "To uninstall:"
echo " rm .git/hooks/pre-commit .git/hooks/pre-push"
echo ""

131
scripts/lint.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Unified linting script for all project code
# Can be run manually or via git hooks
set -e
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
FAILED=0
# Detect if we're in nix shell
if ! command -v ruff &> /dev/null && command -v nix &> /dev/null; then
echo "🔄 Entering nix development environment..."
exec nix develop -c bash "$0" "$@"
fi
echo "========================================="
echo "🔍 Reference Board Viewer - Code Linting"
echo "========================================="
echo ""
# Backend Python linting
echo -e "${BLUE}📦 Backend (Python)${NC}"
echo "-----------------------------------"
if [ -d "backend" ]; then
cd backend
# Ruff check
echo -n " Ruff check... "
if ruff check app/ 2>&1 | grep -q "All checks passed!"; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}${NC}"
echo ""
ruff check app/
FAILED=1
fi
# Ruff format check
echo -n " Ruff format... "
if ruff format --check app/ > /dev/null 2>&1; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}${NC}"
echo ""
echo "Run: cd backend && ruff format app/"
FAILED=1
fi
cd ..
else
echo -e "${YELLOW} ⚠ Backend directory not found, skipping${NC}"
fi
echo ""
# Frontend linting
echo -e "${BLUE}🎨 Frontend (TypeScript/Svelte)${NC}"
echo "-----------------------------------"
if [ -d "frontend" ] && [ -f "frontend/package.json" ]; then
cd frontend
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW} ⚠ node_modules not found, run 'npm install' first${NC}"
cd ..
else
# ESLint
echo -n " ESLint... "
if npm run lint > /dev/null 2>&1; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}${NC}"
echo ""
npm run lint
FAILED=1
fi
# Prettier check
if [ -f ".prettierrc" ]; then
echo -n " Prettier... "
if npx prettier --check src/ > /dev/null 2>&1; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}${NC}"
echo ""
echo "Run: cd frontend && npx prettier --write src/"
FAILED=1
fi
fi
# TypeScript check
echo -n " TypeScript... "
if npm run check > /dev/null 2>&1; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}${NC}"
echo ""
npm run check
FAILED=1
fi
cd ..
fi
else
echo -e "${YELLOW} ⚠ Frontend directory not found, skipping${NC}"
fi
echo ""
echo "========================================="
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}✅ All linting checks passed!${NC}"
echo "========================================="
exit 0
else
echo -e "${RED}❌ Some linting checks failed${NC}"
echo "========================================="
echo ""
echo "To auto-fix issues:"
echo " Backend: cd backend && ruff check --fix app/ && ruff format app/"
echo " Frontend: cd frontend && npx prettier --write src/"
exit 1
fi