diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 5461ed5..8e02f34 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,14 +7,14 @@ from app.auth.jwt import create_access_token from app.auth.repository import UserRepository from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse from app.auth.security import validate_password_strength, verify_password -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.user import User router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -def register_user(user_data: UserCreate, db: Session = Depends(get_db)): +def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)): """ Register a new user. @@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)): @router.post("/login", response_model=TokenResponse) -def login_user(login_data: UserLogin, db: Session = Depends(get_db)): +def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)): """ Login user and return JWT token. diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 2c94049..17a5261 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -3,9 +3,10 @@ from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status -from sqlalchemy.orm import Session +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user_async, get_db from app.database.models.board import Board from app.database.models.user import User from app.images.processing import generate_thumbnails @@ -30,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"]) @router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED) async def upload_image( file: UploadFile = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -64,7 +65,7 @@ async def upload_image( checksum = calculate_checksum(contents) # Create metadata - metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths} + image_metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths} # Create database record repo = ImageRepository(db) @@ -76,7 +77,7 @@ async def upload_image( mime_type=mime_type, width=width, height=height, - metadata=metadata, + image_metadata=image_metadata, ) return image @@ -85,7 +86,7 @@ async def upload_image( @router.post("/upload-zip", response_model=list[ImageUploadResponse]) async def upload_zip( file: UploadFile = File(...), - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -120,7 +121,7 @@ async def upload_zip( checksum = calculate_checksum(contents) # Create metadata - metadata = { + img_metadata = { "format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths, @@ -135,7 +136,7 @@ async def upload_zip( mime_type=mime_type, width=width, height=height, - metadata=metadata, + image_metadata=img_metadata, ) uploaded_images.append(image) @@ -155,7 +156,7 @@ async def upload_zip( async def get_image_library( page: int = 1, page_size: int = 50, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -173,7 +174,7 @@ async def get_image_library( @router.get("/{image_id}", response_model=ImageResponse) async def get_image( image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """Get image by ID.""" @@ -193,7 +194,7 @@ async def get_image( @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_image( image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -223,8 +224,8 @@ async def delete_image( from app.images.upload import delete_image_from_storage await delete_image_from_storage(image.storage_path) - if "thumbnails" in image.metadata: - await delete_thumbnails(image.metadata["thumbnails"]) + if "thumbnails" in image.image_metadata: + await delete_thumbnails(image.image_metadata["thumbnails"]) # Delete from database await repo.delete_image(image_id) @@ -234,7 +235,7 @@ async def delete_image( async def add_image_to_board( board_id: UUID, data: BoardImageCreate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -243,8 +244,6 @@ async def add_image_to_board( The image must already be uploaded and owned by the current user. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -284,7 +283,7 @@ async def update_board_image( board_id: UUID, image_id: UUID, data: BoardImageUpdate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -294,8 +293,6 @@ async def update_board_image( Only provided fields are updated. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -329,7 +326,7 @@ async def update_board_image( async def remove_image_from_board( board_id: UUID, image_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -339,8 +336,6 @@ async def remove_image_from_board( The image remains in the user's library. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -362,7 +357,7 @@ async def remove_image_from_board( async def bulk_update_board_images( board_id: UUID, data: BulkImageUpdate, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -371,8 +366,6 @@ async def bulk_update_board_images( Applies the same changes to all specified images. Useful for multi-selection operations. """ # Verify board ownership - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() @@ -438,7 +431,7 @@ async def bulk_update_board_images( @router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse]) async def get_board_images( board_id: UUID, - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): """ @@ -447,8 +440,6 @@ async def get_board_images( Used for loading board contents in the canvas. """ # Verify board access (owner or shared link - for now just owner) - from sqlalchemy import select - board_result = await db.execute(select(Board).where(Board.id == board_id)) board = board_result.scalar_one_or_none() diff --git a/backend/app/api/sharing.py b/backend/app/api/sharing.py index ba15917..e0b7daa 100644 --- a/backend/app/api/sharing.py +++ b/backend/app/api/sharing.py @@ -1,6 +1,6 @@ """Board sharing API endpoints.""" -from datetime import datetime +from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status @@ -54,7 +54,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie ) # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.utcnow(): + if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Share link has expired", @@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.utcnow() + share_link.last_accessed_at = datetime.now(timezone.utc) db.commit() return share_link diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py index 6bd5411..6ebc1df 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 datetime import datetime, timedelta, timezone from uuid import UUID from jose import JWTError, jwt @@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No Encoded JWT token string """ if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + 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.now(timezone.utc), "type": "access"} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return encoded_jwt diff --git a/backend/app/boards/sharing.py b/backend/app/boards/sharing.py index cbf1e81..f6fc621 100644 --- a/backend/app/boards/sharing.py +++ b/backend/app/boards/sharing.py @@ -2,7 +2,7 @@ import secrets import string -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.orm import Session @@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None: return None # Check expiration - if share_link.expires_at and share_link.expires_at < datetime.utcnow(): + if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): return None # Update access tracking share_link.access_count += 1 - share_link.last_accessed_at = datetime.utcnow() + share_link.last_accessed_at = datetime.now(timezone.utc) db.commit() return share_link diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 7e76934..04ef5ce 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -5,24 +5,50 @@ from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.auth.jwt import decode_access_token from app.database.models.user import User from app.database.session import get_db -# Database session dependency -DatabaseSession = Annotated[Session, Depends(get_db)] +# For backwards compatibility with synchronous code +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# Sync engine for synchronous endpoints +_sync_engine = create_engine( + str(settings.DATABASE_URL), + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, +) +_SyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_sync_engine) + + +def get_db_sync(): + """Synchronous database session dependency.""" + db = _SyncSessionLocal() + try: + yield db + finally: + db.close() + + +# Database session dependency (async) +DatabaseSession = Annotated[AsyncSession, Depends(get_db)] # Security scheme for JWT Bearer token security = HTTPBearer() def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) + credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync) ) -> User: """ - Get current authenticated user from JWT token. + Get current authenticated user from JWT token (synchronous version). Args: credentials: HTTP Authorization Bearer token @@ -63,7 +89,7 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) from None - # Get user from database + # Get user from database (sync) user = db.query(User).filter(User.id == user_id).first() if user is None: @@ -77,3 +103,65 @@ def get_current_user( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated") return user + + +async def get_current_user_async( + credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db) +) -> User: + """ + Get current authenticated user from JWT token (asynchronous version). + + Args: + credentials: HTTP Authorization Bearer token + db: Async 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: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = UUID(user_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid user ID in token", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + + # Get user from database (async) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + 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 diff --git a/backend/app/database/base.py b/backend/app/database/base.py index 2118370..a1a389f 100644 --- a/backend/app/database/base.py +++ b/backend/app/database/base.py @@ -1,6 +1,6 @@ """Base model for all database models.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any from uuid import uuid4 @@ -22,7 +22,7 @@ class Base(DeclarativeBase): # Common columns for all models id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) - created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at: Any = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) def dict(self) -> dict[str, Any]: """Convert model to dictionary.""" diff --git a/backend/app/database/models/board.py b/backend/app/database/models/board.py index 0fac153..d393241 100644 --- a/backend/app/database/models/board.py +++ b/backend/app/database/models/board.py @@ -1,6 +1,6 @@ """Board database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -42,9 +42,9 @@ class Board(Base): default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/database/models/board_image.py b/backend/app/database/models/board_image.py index a996e83..70822b6 100644 --- a/backend/app/database/models/board_image.py +++ b/backend/app/database/models/board_image.py @@ -1,6 +1,6 @@ """BoardImage database model - junction table for boards and images.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -52,9 +52,9 @@ class BoardImage(Base): PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) # Relationships diff --git a/backend/app/database/models/comment.py b/backend/app/database/models/comment.py index e1b145e..715de5c 100644 --- a/backend/app/database/models/comment.py +++ b/backend/app/database/models/comment.py @@ -1,7 +1,7 @@ """Comment model for board annotations.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import JSONB, UUID @@ -21,7 +21,7 @@ class Comment(Base): author_name = Column(String(100), nullable=False) content = Column(Text, nullable=False) position = Column(JSONB, nullable=True) # Optional canvas position reference - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) is_deleted = Column(Boolean, nullable=False, default=False) # Relationships diff --git a/backend/app/database/models/group.py b/backend/app/database/models/group.py index fced044..034fd7d 100644 --- a/backend/app/database/models/group.py +++ b/backend/app/database/models/group.py @@ -1,6 +1,6 @@ """Group database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -33,9 +33,9 @@ class Group(Base): color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB annotation: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow + DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) ) # Relationships diff --git a/backend/app/database/models/image.py b/backend/app/database/models/image.py index 5bfa442..7e9d5f9 100644 --- a/backend/app/database/models/image.py +++ b/backend/app/database/models/image.py @@ -1,6 +1,6 @@ """Image database model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING from uuid import UUID, uuid4 @@ -38,7 +38,7 @@ class Image(Base): height: Mapped[int] = mapped_column(Integer, nullable=False) image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Relationships diff --git a/backend/app/database/models/share_link.py b/backend/app/database/models/share_link.py index 34ada78..5dd0a45 100644 --- a/backend/app/database/models/share_link.py +++ b/backend/app/database/models/share_link.py @@ -1,7 +1,7 @@ """ShareLink model for board sharing functionality.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID @@ -19,7 +19,7 @@ class ShareLink(Base): board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) token = Column(String(64), unique=True, nullable=False, index=True) permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) expires_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True) access_count = Column(Integer, nullable=False, default=0) diff --git a/backend/app/database/models/user.py b/backend/app/database/models/user.py index ebfec48..c76efed 100644 --- a/backend/app/database/models/user.py +++ b/backend/app/database/models/user.py @@ -1,7 +1,7 @@ """User model for authentication and ownership.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Boolean, Column, DateTime, String from sqlalchemy.dialects.postgresql import UUID @@ -18,8 +18,8 @@ class User(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) is_active = Column(Boolean, nullable=False, default=True) # Relationships diff --git a/backend/app/database/session.py b/backend/app/database/session.py index cb299d6..85bb8b6 100644 --- a/backend/app/database/session.py +++ b/backend/app/database/session.py @@ -1,27 +1,33 @@ """Database session management.""" -from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from app.core.config import settings -# Create SQLAlchemy engine -engine = create_engine( - str(settings.DATABASE_URL), +# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://) +async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://") + +# Create async SQLAlchemy engine +engine = create_async_engine( + async_database_url, pool_size=settings.DATABASE_POOL_SIZE, max_overflow=settings.DATABASE_MAX_OVERFLOW, pool_pre_ping=True, # Verify connections before using echo=settings.DEBUG, # Log SQL queries in debug mode ) -# Create session factory -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# Create async session factory +SessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + autocommit=False, + autoflush=False, + expire_on_commit=False, +) -def get_db(): - """Dependency for getting database session.""" - db = SessionLocal() - try: - yield db - finally: - db.close() +async def get_db(): + """Dependency for getting async database session.""" + async with SessionLocal() as session: + yield session diff --git a/backend/app/images/repository.py b/backend/app/images/repository.py index 14b321e..f7c5b40 100644 --- a/backend/app/images/repository.py +++ b/backend/app/images/repository.py @@ -3,7 +3,8 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy.orm import Session +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.database.models.board_image import BoardImage from app.database.models.image import Image @@ -12,11 +13,11 @@ from app.database.models.image import Image class ImageRepository: """Repository for image database operations.""" - def __init__(self, db: Session): + def __init__(self, db: AsyncSession): """Initialize repository with database session.""" self.db = db - def create_image( + async def create_image( self, user_id: UUID, filename: str, @@ -25,7 +26,7 @@ class ImageRepository: mime_type: str, width: int, height: int, - metadata: dict, + image_metadata: dict, ) -> Image: """Create new image record.""" image = Image( @@ -36,59 +37,68 @@ class ImageRepository: mime_type=mime_type, width=width, height=height, - image_metadata=metadata, + image_metadata=image_metadata, ) self.db.add(image) - self.db.commit() - self.db.refresh(image) + await self.db.commit() + await self.db.refresh(image) return image - def get_image_by_id(self, image_id: UUID) -> Image | None: + async def get_image_by_id(self, image_id: UUID) -> Image | None: """Get image by ID.""" - return self.db.query(Image).filter(Image.id == image_id).first() + result = await self.db.execute(select(Image).where(Image.id == image_id)) + return result.scalar_one_or_none() - def get_user_images( + async def get_user_images( self, user_id: UUID, limit: int = 50, offset: int = 0 ) -> tuple[Sequence[Image], int]: """Get all images for a user with pagination.""" - total = self.db.query(Image).filter(Image.user_id == user_id).count() - images = ( - self.db.query(Image) - .filter(Image.user_id == user_id) + from sqlalchemy import func + + # Get total count efficiently + count_result = await self.db.execute( + select(func.count(Image.id)).where(Image.user_id == user_id) + ) + total = count_result.scalar_one() + + # Get paginated images + result = await self.db.execute( + select(Image) + .where(Image.user_id == user_id) .order_by(Image.created_at.desc()) .limit(limit) .offset(offset) - .all() ) + images = result.scalars().all() return images, total - def delete_image(self, image_id: UUID) -> bool: + async def delete_image(self, image_id: UUID) -> bool: """Delete image record.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if not image: return False - self.db.delete(image) - self.db.commit() + await self.db.delete(image) + await self.db.commit() return True - def increment_reference_count(self, image_id: UUID) -> None: + async def increment_reference_count(self, image_id: UUID) -> None: """Increment reference count for image.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if image: image.reference_count += 1 - self.db.commit() + await self.db.commit() - def decrement_reference_count(self, image_id: UUID) -> int: + async def decrement_reference_count(self, image_id: UUID) -> int: """Decrement reference count for image.""" - image = self.get_image_by_id(image_id) + image = await self.get_image_by_id(image_id) if image and image.reference_count > 0: image.reference_count -= 1 - self.db.commit() + await self.db.commit() return image.reference_count return 0 - def add_image_to_board( + async def add_image_to_board( self, board_id: UUID, image_id: UUID, @@ -107,36 +117,68 @@ class ImageRepository: self.db.add(board_image) # Increment reference count - self.increment_reference_count(image_id) + await self.increment_reference_count(image_id) - self.db.commit() - self.db.refresh(board_image) + await self.db.commit() + await self.db.refresh(board_image) return board_image - def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: + async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: """Get all images for a board, ordered by z-order.""" - return ( - self.db.query(BoardImage) - .filter(BoardImage.board_id == board_id) + result = await self.db.execute( + select(BoardImage) + .where(BoardImage.board_id == board_id) .order_by(BoardImage.z_order.asc()) - .all() ) + return result.scalars().all() - def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: - """Remove image from board.""" - board_image = ( - self.db.query(BoardImage) - .filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id) - .first() + async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None: + """Get a specific board image.""" + result = await self.db.execute( + select(BoardImage) + .where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) ) + return result.scalar_one_or_none() + + async def update_board_image( + self, + board_id: UUID, + image_id: UUID, + position: dict | None = None, + transformations: dict | None = None, + z_order: int | None = None, + group_id: UUID | None = None, + ) -> BoardImage | None: + """Update board image position, transformations, z-order, or group.""" + board_image = await self.get_board_image(board_id, image_id) + + if not board_image: + return None + + if position is not None: + board_image.position = position + if transformations is not None: + board_image.transformations = transformations + if z_order is not None: + board_image.z_order = z_order + if group_id is not None: + board_image.group_id = group_id + + await self.db.commit() + await self.db.refresh(board_image) + return board_image + + async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: + """Remove image from board.""" + board_image = await self.get_board_image(board_id, image_id) if not board_image: return False - self.db.delete(board_image) + await self.db.delete(board_image) # Decrement reference count - self.decrement_reference_count(image_id) + await self.decrement_reference_count(image_id) - self.db.commit() + await self.db.commit() return True diff --git a/backend/app/images/schemas.py b/backend/app/images/schemas.py index dfa12c3..bb91e35 100644 --- a/backend/app/images/schemas.py +++ b/backend/app/images/schemas.py @@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel): mime_type: str width: int height: int - metadata: dict[str, Any] + metadata: dict[str, Any] = Field(..., alias="image_metadata") created_at: datetime class Config: """Pydantic config.""" from_attributes = True + populate_by_name = True class ImageResponse(BaseModel): @@ -46,7 +47,7 @@ class ImageResponse(BaseModel): mime_type: str width: int height: int - metadata: dict[str, Any] + metadata: dict[str, Any] = Field(..., alias="image_metadata") created_at: datetime reference_count: int @@ -54,6 +55,7 @@ class ImageResponse(BaseModel): """Pydantic config.""" from_attributes = True + populate_by_name = True class BoardImageCreate(BaseModel): diff --git a/flake.nix b/flake.nix index 10809a2..4b1c4a2 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,8 @@ alembic pydantic pydantic-settings # Settings management - psycopg2 # PostgreSQL driver + psycopg2 # PostgreSQL driver (sync) + asyncpg # PostgreSQL driver (async) # Auth & Security python-jose passlib @@ -88,6 +89,7 @@ # Development tools git direnv + tmux ]; shellHook = '' @@ -105,6 +107,7 @@ echo " Status: ./scripts/dev-services.sh status" echo "" echo "šŸ“š Quick Commands:" + echo " Dev (tmux): nix run .#dev" echo " Backend: cd backend && uvicorn app.main:app --reload" echo " Frontend: cd frontend && npm run dev" echo " Database: psql -h localhost -U webref webref" @@ -131,6 +134,7 @@ type = "app"; program = "${pkgs.writeShellScript "help" '' echo "Available commands:" + echo " nix run .#dev - Start backend + frontend in tmux" echo " nix run .#lint - Run all linting checks" echo " nix run .#lint-backend - Run backend linting only" echo " nix run .#lint-frontend - Run frontend linting only" @@ -138,6 +142,76 @@ ''}"; }; + # Development runner with tmux + dev = { + type = "app"; + program = "${pkgs.writeShellScript "dev-tmux" '' + set -e + + # Check if we're in the project root + if [ ! -d "backend" ] || [ ! -d "frontend" ]; then + echo "āŒ Error: Not in project root directory" + echo "Please run this command from the webref project root" + exit 1 + fi + + # Check if frontend dependencies are installed + if [ ! -d "frontend/node_modules" ]; then + echo "šŸ“¦ Installing frontend dependencies..." + cd frontend + ${pkgs.nodejs}/bin/npm install + cd .. + fi + + # Set environment variables + export DATABASE_URL="postgresql://webref@localhost:5432/webref" + export MINIO_ENDPOINT="localhost:9000" + export MINIO_ACCESS_KEY="minioadmin" + export MINIO_SECRET_KEY="minioadmin" + export PYTHONPATH="$PWD/backend:$PYTHONPATH" + export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH" + + # Session name + SESSION_NAME="webref-dev" + + # Kill existing session if it exists + ${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME + + echo "šŸš€ Starting development environment in tmux..." + echo "" + echo "šŸ“‹ Tmux Controls:" + echo " Switch panes: Ctrl+b → arrow keys" + echo " Scroll mode: Ctrl+b → [" + echo " Exit scroll: q" + echo " Detach session: Ctrl+b → d" + echo " Kill session: Ctrl+b → :kill-session" + echo "" + echo "Starting in 2 seconds..." + sleep 2 + + # Create new tmux session with backend + ${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \ + "printf '\nšŸ Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'" + + # Split window vertically and run frontend + ${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \ + "printf '\n⚔ Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'" + + # Set pane titles + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)" + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)" + + # Balance panes + ${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal + + # Focus on backend pane + ${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 + + # Attach to session + ${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME" + ''}"; + }; + # Unified linting - calls both backend and frontend lints lint = { type = "app";