001-reference-board-viewer #2

Merged
jawz merged 4 commits from 001-reference-board-viewer into main 2025-11-02 19:13:52 -06:00
18 changed files with 328 additions and 125 deletions
Showing only changes of commit 209b6d9f18 - Show all commits

View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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";