diff --git a/README.md b/README.md index 5a7c085..6d1afbe 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,56 @@ npm run dev - Backend API Docs: http://localhost:8000/docs - 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 ``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 18d182b..5211a77 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,3 @@ """Reference Board Viewer - Backend API.""" __version__ = "1.0.0" - diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e14074d..5461ed5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,4 +1,5 @@ """Authentication endpoints.""" + from fastapi import APIRouter, Depends, HTTPException, status 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)): """ Register a new user. - + Args: user_data: User registration data db: Database session - + Returns: Created user information - + Raises: HTTPException: If email already exists or password is weak """ repo = UserRepository(db) - + # Check if email already exists if repo.email_exists(user_data.email): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Email already registered" - ) - + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + # Validate password strength is_valid, error_message = validate_password_strength(user_data.password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_message - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message) + # Create user user = repo.create_user(email=user_data.email, password=user_data.password) - + 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)): """ Login user and return JWT token. - + Args: login_data: Login credentials db: Database session - + Returns: JWT access token and user information - + Raises: HTTPException: If credentials are invalid """ repo = UserRepository(db) - + # Get user by email user = repo.get_user_by_email(login_data.email) - + # Verify user exists and password is correct if not user or not verify_password(login_data.password, user.password_hash): raise HTTPException( @@ -77,34 +72,26 @@ def login_user(login_data: UserLogin, db: Session = Depends(get_db)): detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) - + # Check if user is active if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User account is deactivated" - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated") + # Create access token access_token = create_access_token(user_id=user.id, email=user.email) - - return TokenResponse( - access_token=access_token, - token_type="bearer", - user=UserResponse.model_validate(user) - ) + + return TokenResponse(access_token=access_token, token_type="bearer", user=UserResponse.model_validate(user)) @router.get("/me", response_model=UserResponse) def get_current_user_info(current_user: User = Depends(get_current_user)): """ Get current authenticated user information. - + Args: current_user: Current authenticated user (from JWT) - + Returns: Current user information """ return UserResponse.model_validate(current_user) - diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index 3c6f19a..7db5be4 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -1,2 +1 @@ """Authentication module.""" - diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py index c995aed..6bd5411 100644 --- a/backend/app/auth/jwt.py +++ b/backend/app/auth/jwt.py @@ -1,6 +1,6 @@ """JWT token generation and validation.""" + from datetime import datetime, timedelta -from typing import Optional from uuid import UUID from jose import JWTError, jwt @@ -8,15 +8,15 @@ from jose import JWTError, jwt 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. - + Args: user_id: User's UUID email: User's email address expires_delta: Optional custom expiration time - + Returns: 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 else: expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode = { - "sub": str(user_id), - "email": email, - "exp": expire, - "iat": datetime.utcnow(), - "type": "access" - } - + + to_encode = {"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) 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. - + Args: token: JWT token string to decode - + Returns: Decoded token payload if valid, None otherwise """ @@ -52,4 +46,3 @@ def decode_access_token(token: str) -> Optional[dict]: return payload except JWTError: return None - diff --git a/backend/app/auth/repository.py b/backend/app/auth/repository.py index 13d2558..f682e0f 100644 --- a/backend/app/auth/repository.py +++ b/backend/app/auth/repository.py @@ -1,9 +1,7 @@ """User repository for database operations.""" -from typing import Optional + from uuid import UUID -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.auth.security import hash_password @@ -16,7 +14,7 @@ class UserRepository: def __init__(self, db: Session): """ Initialize repository. - + Args: db: Database session """ @@ -25,48 +23,45 @@ class UserRepository: def create_user(self, email: str, password: str) -> User: """ Create a new user. - + Args: email: User email (will be lowercased) password: Plain text password (will be hashed) - + Returns: Created user instance """ email = email.lower() password_hash = hash_password(password) - - user = User( - email=email, - password_hash=password_hash - ) - + + user = User(email=email, password_hash=password_hash) + self.db.add(user) self.db.commit() self.db.refresh(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. - + Args: email: User email to search for - + Returns: User if found, None otherwise """ email = email.lower() 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. - + Args: user_id: User UUID - + Returns: User if found, None otherwise """ @@ -75,13 +70,12 @@ class UserRepository: def email_exists(self, email: str) -> bool: """ Check if email already exists. - + Args: email: Email to check - + Returns: True if email exists, False otherwise """ email = email.lower() return self.db.query(User).filter(User.email == email).first() is not None - diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index dddb971..ef0cab6 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -1,6 +1,6 @@ """Authentication schemas for request/response validation.""" + from datetime import datetime -from typing import Optional from uuid import UUID from pydantic import BaseModel, EmailStr, Field @@ -42,4 +42,3 @@ class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserResponse - diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 22c049b..c0eafc1 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -1,5 +1,7 @@ """Password hashing utilities using passlib.""" + import re + from passlib.context import CryptContext # Create password context for hashing and verification @@ -9,10 +11,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def hash_password(password: str) -> str: """ Hash a password using bcrypt. - + Args: password: Plain text password - + Returns: Hashed password string """ @@ -22,11 +24,11 @@ def hash_password(password: str) -> str: def verify_password(plain_password: str, hashed_password: str) -> bool: """ Verify a plain password against a hashed password. - + Args: plain_password: Plain text password to verify hashed_password: Hashed password from database - + Returns: 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]: """ Validate password meets complexity requirements. - + Requirements: - At least 8 characters - At least 1 uppercase letter - At least 1 lowercase letter - At least 1 number - + Args: password: Plain text password to validate - + Returns: Tuple of (is_valid, error_message) """ if len(password) < 8: return False, "Password must be at least 8 characters long" - + if not re.search(r"[A-Z]", password): return False, "Password must contain at least one uppercase letter" - + if not re.search(r"[a-z]", password): return False, "Password must contain at least one lowercase letter" - + if not re.search(r"\d", password): return False, "Password must contain at least one number" - - return True, "" + return True, "" diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py index 3dbf255..6ee6af5 100644 --- a/backend/app/core/__init__.py +++ b/backend/app/core/__init__.py @@ -1,2 +1 @@ """Core application modules.""" - diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6741b93..cfbc3bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -90,4 +90,3 @@ def get_settings() -> Settings: # Export settings instance settings = get_settings() - diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 5f4deea..7e76934 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,6 +1,6 @@ """Dependency injection utilities.""" -from typing import Annotated, Generator +from typing import Annotated from uuid import UUID from fastapi import Depends, HTTPException, status @@ -19,33 +19,32 @@ security = HTTPBearer() def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db) + credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) ) -> User: """ Get current authenticated user from JWT token. - + Args: credentials: HTTP Authorization Bearer token db: Database session - + Returns: Current authenticated user - + Raises: HTTPException: If token is invalid or user not found """ # Decode token token = credentials.credentials payload = decode_access_token(token) - + if payload is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) - + # Extract user ID from token user_id_str: str = payload.get("sub") if user_id_str is None: @@ -54,7 +53,7 @@ def get_current_user( detail="Invalid token payload", headers={"WWW-Authenticate": "Bearer"}, ) - + try: user_id = UUID(user_id_str) except ValueError: @@ -62,23 +61,19 @@ def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user ID in token", headers={"WWW-Authenticate": "Bearer"}, - ) - + ) from None + # Get user from database user = db.query(User).filter(User.id == user_id).first() - + if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", 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 diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index beb249e..bd6f6d4 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -65,4 +65,3 @@ class UnsupportedFileTypeError(WebRefException): 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) - diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index e277c68..e661f29 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -8,27 +8,24 @@ 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) - ], + 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}") - diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 3d7a6a8..917677f 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -2,7 +2,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.trustedhost import TrustedHostMiddleware from app.core.config import settings @@ -26,4 +25,3 @@ def setup_middleware(app: FastAPI) -> None: # TrustedHostMiddleware, # allowed_hosts=["yourdomain.com", "*.yourdomain.com"] # ) - diff --git a/backend/app/core/schemas.py b/backend/app/core/schemas.py index af90fa3..79509e8 100644 --- a/backend/app/core/schemas.py +++ b/backend/app/core/schemas.py @@ -10,13 +10,7 @@ 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": {} - } - ) + model_config = ConfigDict(from_attributes=True, populate_by_name=True, json_schema_extra={"example": {}}) class TimestampSchema(BaseSchema): @@ -61,4 +55,3 @@ class PaginatedResponse(BaseSchema): items: list[Any] = Field(..., description="List of items") pagination: PaginationSchema = Field(..., description="Pagination metadata") - diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index bd6f9e7..c71772c 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -116,4 +116,3 @@ class StorageClient: # Global storage client instance storage_client = StorageClient() - diff --git a/backend/app/database/__init__.py b/backend/app/database/__init__.py index 25bbef1..d974e2a 100644 --- a/backend/app/database/__init__.py +++ b/backend/app/database/__init__.py @@ -1,2 +1 @@ """Database models and session management.""" - diff --git a/backend/app/database/base.py b/backend/app/database/base.py index 924fb14..2118370 100644 --- a/backend/app/database/base.py +++ b/backend/app/database/base.py @@ -14,10 +14,10 @@ class Base(DeclarativeBase): # Generate __tablename__ automatically from class name @declared_attr.directive - def __tablename__(cls) -> str: + def __tablename__(self) -> str: """Generate table name from class name.""" # 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("_") # Common columns for all models @@ -27,4 +27,3 @@ class Base(DeclarativeBase): def dict(self) -> dict[str, Any]: """Convert model to dictionary.""" return {c.name: getattr(self, c.name) for c in self.__table__.columns} - diff --git a/backend/app/database/models/__init__.py b/backend/app/database/models/__init__.py index 9456706..d32b801 100644 --- a/backend/app/database/models/__init__.py +++ b/backend/app/database/models/__init__.py @@ -1,11 +1,12 @@ """Database models.""" -from app.database.models.user import User + 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.group import Group -from app.database.models.share_link import ShareLink 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__ = [ "User", diff --git a/backend/app/database/models/board.py b/backend/app/database/models/board.py index 532404c..055926b 100644 --- a/backend/app/database/models/board.py +++ b/backend/app/database/models/board.py @@ -1,6 +1,8 @@ """Board model for reference boards.""" + import uuid from datetime import datetime + from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import JSONB, UUID 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) title = Column(String(255), nullable=False) description = Column(Text, nullable=True) - viewport_state = Column( - JSONB, - nullable=False, - default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0} - ) + viewport_state = Column(JSONB, nullable=False, default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}) created_at = Column(DateTime, nullable=False, default=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) is_deleted = Column(Boolean, nullable=False, default=False) @@ -35,4 +33,3 @@ class Board(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/board_image.py b/backend/app/database/models/board_image.py index 1ee43f1..57db565 100644 --- a/backend/app/database/models/board_image.py +++ b/backend/app/database/models/board_image.py @@ -1,6 +1,8 @@ """BoardImage junction model.""" + import uuid from datetime import datetime + from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import relationship @@ -26,17 +28,15 @@ class BoardImage(Base): "opacity": 1.0, "flipped_h": False, "flipped_v": False, - "greyscale": False - } + "greyscale": False, + }, ) 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) created_at = Column(DateTime, nullable=False, default=datetime.utcnow) updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) - __table_args__ = ( - UniqueConstraint("board_id", "image_id", name="uq_board_image"), - ) + __table_args__ = (UniqueConstraint("board_id", "image_id", name="uq_board_image"),) # Relationships board = relationship("Board", back_populates="board_images") @@ -45,4 +45,3 @@ class BoardImage(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/comment.py b/backend/app/database/models/comment.py index 59fb8c4..6246777 100644 --- a/backend/app/database/models/comment.py +++ b/backend/app/database/models/comment.py @@ -1,6 +1,8 @@ """Comment model for board comments.""" + import uuid from datetime import datetime + from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import relationship @@ -15,7 +17,9 @@ class Comment(Base): 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) - 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) content = Column(Text, nullable=False) position = Column(JSONB, nullable=True) # Optional canvas position @@ -28,4 +32,3 @@ class Comment(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/group.py b/backend/app/database/models/group.py index 9c79326..a9a9387 100644 --- a/backend/app/database/models/group.py +++ b/backend/app/database/models/group.py @@ -1,6 +1,8 @@ """Group model for image grouping.""" + import uuid from datetime import datetime + from sqlalchemy import Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -27,4 +29,3 @@ class Group(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py index c8c0a34..1e37e53 100644 --- a/backend/app/database/models/image.py +++ b/backend/app/database/models/image.py @@ -1,6 +1,8 @@ """Image model for uploaded images.""" + import uuid from datetime import datetime + from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import relationship @@ -31,4 +33,3 @@ class Image(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/share_link.py b/backend/app/database/models/share_link.py index d21da9b..3bf5cbb 100644 --- a/backend/app/database/models/share_link.py +++ b/backend/app/database/models/share_link.py @@ -1,6 +1,8 @@ """ShareLink model for board sharing.""" + import uuid from datetime import datetime + from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -29,4 +31,3 @@ class ShareLink(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/models/user.py b/backend/app/database/models/user.py index 9e16680..ebfec48 100644 --- a/backend/app/database/models/user.py +++ b/backend/app/database/models/user.py @@ -1,6 +1,8 @@ """User model for authentication and ownership.""" + import uuid from datetime import datetime + from sqlalchemy import Boolean, Column, DateTime, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -26,4 +28,3 @@ class User(Base): def __repr__(self) -> str: return f"" - diff --git a/backend/app/database/session.py b/backend/app/database/session.py index cf9b02b..cb299d6 100644 --- a/backend/app/database/session.py +++ b/backend/app/database/session.py @@ -25,4 +25,3 @@ def get_db(): yield db finally: db.close() - diff --git a/backend/app/main.py b/backend/app/main.py index 29102e4..887aad1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ import logging from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from app.api import auth from app.core.config import settings from app.core.errors import WebRefException from app.core.logging import setup_logging @@ -81,7 +82,6 @@ async def root(): # API routers -from app.api import auth app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") # Additional routers will be added in subsequent phases # from app.api import boards, images @@ -101,4 +101,3 @@ async def startup_event(): async def shutdown_event(): """Application shutdown tasks.""" logger.info(f"Shutting down {settings.APP_NAME}") - diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b703974..5af083a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -33,10 +33,6 @@ 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", @@ -46,16 +42,24 @@ exclude = [ "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]+?))$" +# Line length (slightly longer for SQLAlchemy models) +line-length = 120 # Target Python 3.12 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 "tests/*" = ["S101"] # Allow assert in tests diff --git a/flake.nix b/flake.nix index d25dc9b..b8c92bf 100644 --- a/flake.nix +++ b/flake.nix @@ -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) packages = { # Backend package diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..e72113f --- /dev/null +++ b/scripts/install-hooks.sh @@ -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 "" + diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..5210874 --- /dev/null +++ b/scripts/lint.sh @@ -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 +