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
@@ -31,18 +32,12 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_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)
@@ -80,19 +75,12 @@ def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
# 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)
@@ -107,4 +95,3 @@ def get_current_user_info(current_user: User = Depends(get_current_user)):
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,7 +8,7 @@ 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.
@@ -25,19 +25,13 @@ def create_access_token(user_id: UUID, email: str, expires_delta: Optional[timed
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.
@@ -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
@@ -36,10 +34,7 @@ class UserRepository:
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()
@@ -47,7 +42,7 @@ class UserRepository:
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.
@@ -60,7 +55,7 @@ class UserRepository:
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.
@@ -84,4 +79,3 @@ class UserRepository:
""" """
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
@@ -62,4 +64,3 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
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,8 +19,7 @@ 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.
@@ -62,7 +61,7 @@ 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()
@@ -75,10 +74,6 @@ def get_current_user(
) )
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"
)
return user 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

@@ -17,9 +17,7 @@ def setup_logging() -> None:
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
@@ -31,4 +29,3 @@ def setup_logging() -> None:
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