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.repository import UserRepository
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
from app.auth.security import validate_password_strength, verify_password 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 from app.database.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @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. 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) @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. Login user and return JWT token.

View File

@@ -3,9 +3,10 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status 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.board import Board
from app.database.models.user import User from app.database.models.user import User
from app.images.processing import generate_thumbnails 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) @router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_image( async def upload_image(
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -64,7 +65,7 @@ async def upload_image(
checksum = calculate_checksum(contents) checksum = calculate_checksum(contents)
# Create metadata # 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 # Create database record
repo = ImageRepository(db) repo = ImageRepository(db)
@@ -76,7 +77,7 @@ async def upload_image(
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=image_metadata,
) )
return image return image
@@ -85,7 +86,7 @@ async def upload_image(
@router.post("/upload-zip", response_model=list[ImageUploadResponse]) @router.post("/upload-zip", response_model=list[ImageUploadResponse])
async def upload_zip( async def upload_zip(
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -120,7 +121,7 @@ async def upload_zip(
checksum = calculate_checksum(contents) checksum = calculate_checksum(contents)
# Create metadata # Create metadata
metadata = { img_metadata = {
"format": mime_type.split("/")[1], "format": mime_type.split("/")[1],
"checksum": checksum, "checksum": checksum,
"thumbnails": thumbnail_paths, "thumbnails": thumbnail_paths,
@@ -135,7 +136,7 @@ async def upload_zip(
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=img_metadata,
) )
uploaded_images.append(image) uploaded_images.append(image)
@@ -155,7 +156,7 @@ async def upload_zip(
async def get_image_library( async def get_image_library(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -173,7 +174,7 @@ async def get_image_library(
@router.get("/{image_id}", response_model=ImageResponse) @router.get("/{image_id}", response_model=ImageResponse)
async def get_image( async def get_image(
image_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), db: AsyncSession = Depends(get_db),
): ):
"""Get image by ID.""" """Get image by ID."""
@@ -193,7 +194,7 @@ async def get_image(
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image( async def delete_image(
image_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), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -223,8 +224,8 @@ async def delete_image(
from app.images.upload import delete_image_from_storage from app.images.upload import delete_image_from_storage
await delete_image_from_storage(image.storage_path) await delete_image_from_storage(image.storage_path)
if "thumbnails" in image.metadata: if "thumbnails" in image.image_metadata:
await delete_thumbnails(image.metadata["thumbnails"]) await delete_thumbnails(image.image_metadata["thumbnails"])
# Delete from database # Delete from database
await repo.delete_image(image_id) await repo.delete_image(image_id)
@@ -234,7 +235,7 @@ async def delete_image(
async def add_image_to_board( async def add_image_to_board(
board_id: UUID, board_id: UUID,
data: BoardImageCreate, data: BoardImageCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), 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. The image must already be uploaded and owned by the current user.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()
@@ -284,7 +283,7 @@ async def update_board_image(
board_id: UUID, board_id: UUID,
image_id: UUID, image_id: UUID,
data: BoardImageUpdate, data: BoardImageUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -294,8 +293,6 @@ async def update_board_image(
Only provided fields are updated. Only provided fields are updated.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()
@@ -329,7 +326,7 @@ async def update_board_image(
async def remove_image_from_board( async def remove_image_from_board(
board_id: UUID, board_id: UUID,
image_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), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -339,8 +336,6 @@ async def remove_image_from_board(
The image remains in the user's library. The image remains in the user's library.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()
@@ -362,7 +357,7 @@ async def remove_image_from_board(
async def bulk_update_board_images( async def bulk_update_board_images(
board_id: UUID, board_id: UUID,
data: BulkImageUpdate, data: BulkImageUpdate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), 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. Applies the same changes to all specified images. Useful for multi-selection operations.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() 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]) @router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
async def get_board_images( async def get_board_images(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -447,8 +440,6 @@ async def get_board_images(
Used for loading board contents in the canvas. Used for loading board contents in the canvas.
""" """
# Verify board access (owner or shared link - for now just owner) # 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_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()

View File

@@ -1,6 +1,6 @@
"""Board sharing API endpoints.""" """Board sharing API endpoints."""
from datetime import datetime from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status 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 # 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired", detail="Share link has expired",
@@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow() share_link.last_accessed_at = datetime.now(timezone.utc)
db.commit() db.commit()
return share_link return share_link

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, timezone
from uuid import UUID from uuid import UUID
from jose import JWTError, jwt 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 Encoded JWT token string
""" """
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: 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) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt

View File

@@ -2,7 +2,7 @@
import secrets import secrets
import string import string
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
return None return None
# Check expiration # 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 return None
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow() share_link.last_accessed_at = datetime.now(timezone.utc)
db.commit() db.commit()
return share_link return share_link

View File

@@ -5,24 +5,50 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth.jwt import decode_access_token from app.auth.jwt import decode_access_token
from app.database.models.user import User from app.database.models.user import User
from app.database.session import get_db from app.database.session import get_db
# Database session dependency # For backwards compatibility with synchronous code
DatabaseSession = Annotated[Session, Depends(get_db)] 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 scheme for JWT Bearer token
security = HTTPBearer() security = HTTPBearer()
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
) -> User: ) -> User:
""" """
Get current authenticated user from JWT token. Get current authenticated user from JWT token (synchronous version).
Args: Args:
credentials: HTTP Authorization Bearer token credentials: HTTP Authorization Bearer token
@@ -63,7 +89,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from None ) from None
# Get user from database # Get user from database (sync)
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user is None: if user is None:
@@ -77,3 +103,65 @@ def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user 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.""" """Base model for all database models."""
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -22,7 +22,7 @@ class Base(DeclarativeBase):
# Common columns for all models # Common columns for all models
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) 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]: def dict(self) -> dict[str, Any]:
"""Convert model to dictionary.""" """Convert model to dictionary."""

View File

@@ -1,6 +1,6 @@
"""Board database model.""" """Board database model."""
from datetime import datetime from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -42,9 +42,9 @@ class Board(Base):
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, 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( 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) 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.""" """BoardImage database model - junction table for boards and images."""
from datetime import datetime from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -52,9 +52,9 @@ class BoardImage(Base):
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True 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( 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 # Relationships

View File

@@ -1,7 +1,7 @@
"""Comment model for board annotations.""" """Comment model for board annotations."""
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
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
@@ -21,7 +21,7 @@ class Comment(Base):
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 reference 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) is_deleted = Column(Boolean, nullable=False, default=False)
# Relationships # Relationships

View File

@@ -1,6 +1,6 @@
"""Group database model.""" """Group database model."""
from datetime import datetime from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -33,9 +33,9 @@ class Group(Base):
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
annotation: Mapped[str | None] = mapped_column(Text, nullable=True) 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( 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 # Relationships

View File

@@ -1,6 +1,6 @@
"""Image database model.""" """Image database model."""
from datetime import datetime from datetime import datetime, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -38,7 +38,7 @@ class Image(Base):
height: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False)
image_metadata: Mapped[dict] = mapped_column(JSONB, 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) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships # Relationships

View File

@@ -1,7 +1,7 @@
"""ShareLink model for board sharing functionality.""" """ShareLink model for board sharing functionality."""
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
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
@@ -19,7 +19,7 @@ class ShareLink(Base):
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True) token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' 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) expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0) access_count = Column(Integer, nullable=False, default=0)

View File

@@ -1,7 +1,7 @@
"""User model for authentication and ownership.""" """User model for authentication and ownership."""
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
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
@@ -18,8 +18,8 @@ class User(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)
email = Column(String(255), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) 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) is_active = Column(Boolean, nullable=False, default=True)
# Relationships # Relationships

View File

@@ -1,27 +1,33 @@
"""Database session management.""" """Database session management."""
from sqlalchemy import create_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.core.config import settings from app.core.config import settings
# Create SQLAlchemy engine # Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
engine = create_engine( async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
str(settings.DATABASE_URL),
# Create async SQLAlchemy engine
engine = create_async_engine(
async_database_url,
pool_size=settings.DATABASE_POOL_SIZE, pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW, max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True, # Verify connections before using pool_pre_ping=True, # Verify connections before using
echo=settings.DEBUG, # Log SQL queries in debug mode echo=settings.DEBUG, # Log SQL queries in debug mode
) )
# Create session factory # Create async session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
def get_db(): async def get_db():
"""Dependency for getting database session.""" """Dependency for getting async database session."""
db = SessionLocal() async with SessionLocal() as session:
try: yield session
yield db
finally:
db.close()

View File

@@ -3,7 +3,8 @@
from collections.abc import Sequence from collections.abc import Sequence
from uuid import UUID 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.board_image import BoardImage
from app.database.models.image import Image from app.database.models.image import Image
@@ -12,11 +13,11 @@ from app.database.models.image import Image
class ImageRepository: class ImageRepository:
"""Repository for image database operations.""" """Repository for image database operations."""
def __init__(self, db: Session): def __init__(self, db: AsyncSession):
"""Initialize repository with database session.""" """Initialize repository with database session."""
self.db = db self.db = db
def create_image( async def create_image(
self, self,
user_id: UUID, user_id: UUID,
filename: str, filename: str,
@@ -25,7 +26,7 @@ class ImageRepository:
mime_type: str, mime_type: str,
width: int, width: int,
height: int, height: int,
metadata: dict, image_metadata: dict,
) -> Image: ) -> Image:
"""Create new image record.""" """Create new image record."""
image = Image( image = Image(
@@ -36,59 +37,68 @@ class ImageRepository:
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
image_metadata=metadata, image_metadata=image_metadata,
) )
self.db.add(image) self.db.add(image)
self.db.commit() await self.db.commit()
self.db.refresh(image) await self.db.refresh(image)
return 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.""" """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 self, user_id: UUID, limit: int = 50, offset: int = 0
) -> tuple[Sequence[Image], int]: ) -> tuple[Sequence[Image], int]:
"""Get all images for a user with pagination.""" """Get all images for a user with pagination."""
total = self.db.query(Image).filter(Image.user_id == user_id).count() from sqlalchemy import func
images = (
self.db.query(Image) # Get total count efficiently
.filter(Image.user_id == user_id) 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()) .order_by(Image.created_at.desc())
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.all()
) )
images = result.scalars().all()
return images, total return images, total
def delete_image(self, image_id: UUID) -> bool: async def delete_image(self, image_id: UUID) -> bool:
"""Delete image record.""" """Delete image record."""
image = self.get_image_by_id(image_id) image = await self.get_image_by_id(image_id)
if not image: if not image:
return False return False
self.db.delete(image) await self.db.delete(image)
self.db.commit() await self.db.commit()
return True 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.""" """Increment reference count for image."""
image = self.get_image_by_id(image_id) image = await self.get_image_by_id(image_id)
if image: if image:
image.reference_count += 1 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.""" """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: if image and image.reference_count > 0:
image.reference_count -= 1 image.reference_count -= 1
self.db.commit() await self.db.commit()
return image.reference_count return image.reference_count
return 0 return 0
def add_image_to_board( async def add_image_to_board(
self, self,
board_id: UUID, board_id: UUID,
image_id: UUID, image_id: UUID,
@@ -107,36 +117,68 @@ class ImageRepository:
self.db.add(board_image) self.db.add(board_image)
# Increment reference count # Increment reference count
self.increment_reference_count(image_id) await self.increment_reference_count(image_id)
self.db.commit() await self.db.commit()
self.db.refresh(board_image) await self.db.refresh(board_image)
return 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.""" """Get all images for a board, ordered by z-order."""
return ( result = await self.db.execute(
self.db.query(BoardImage) select(BoardImage)
.filter(BoardImage.board_id == board_id) .where(BoardImage.board_id == board_id)
.order_by(BoardImage.z_order.asc()) .order_by(BoardImage.z_order.asc())
.all()
) )
return result.scalars().all()
def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
"""Remove image from board.""" """Get a specific board image."""
board_image = ( result = await self.db.execute(
self.db.query(BoardImage) select(BoardImage)
.filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id) .where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
.first()
) )
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: if not board_image:
return False return False
self.db.delete(board_image) await self.db.delete(board_image)
# Decrement reference count # 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 return True

View File

@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
mime_type: str mime_type: str
width: int width: int
height: int height: int
metadata: dict[str, Any] metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime created_at: datetime
class Config: class Config:
"""Pydantic config.""" """Pydantic config."""
from_attributes = True from_attributes = True
populate_by_name = True
class ImageResponse(BaseModel): class ImageResponse(BaseModel):
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
mime_type: str mime_type: str
width: int width: int
height: int height: int
metadata: dict[str, Any] metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime created_at: datetime
reference_count: int reference_count: int
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
"""Pydantic config.""" """Pydantic config."""
from_attributes = True from_attributes = True
populate_by_name = True
class BoardImageCreate(BaseModel): class BoardImageCreate(BaseModel):

View File

@@ -31,7 +31,8 @@
alembic alembic
pydantic pydantic
pydantic-settings # Settings management pydantic-settings # Settings management
psycopg2 # PostgreSQL driver psycopg2 # PostgreSQL driver (sync)
asyncpg # PostgreSQL driver (async)
# Auth & Security # Auth & Security
python-jose python-jose
passlib passlib
@@ -88,6 +89,7 @@
# Development tools # Development tools
git git
direnv direnv
tmux
]; ];
shellHook = '' shellHook = ''
@@ -105,6 +107,7 @@
echo " Status: ./scripts/dev-services.sh status" echo " Status: ./scripts/dev-services.sh status"
echo "" echo ""
echo "📚 Quick Commands:" echo "📚 Quick Commands:"
echo " Dev (tmux): nix run .#dev"
echo " Backend: cd backend && uvicorn app.main:app --reload" echo " Backend: cd backend && uvicorn app.main:app --reload"
echo " Frontend: cd frontend && npm run dev" echo " Frontend: cd frontend && npm run dev"
echo " Database: psql -h localhost -U webref webref" echo " Database: psql -h localhost -U webref webref"
@@ -131,6 +134,7 @@
type = "app"; type = "app";
program = "${pkgs.writeShellScript "help" '' program = "${pkgs.writeShellScript "help" ''
echo "Available commands:" echo "Available commands:"
echo " nix run .#dev - Start backend + frontend in tmux"
echo " nix run .#lint - Run all linting checks" echo " nix run .#lint - Run all linting checks"
echo " nix run .#lint-backend - Run backend linting only" echo " nix run .#lint-backend - Run backend linting only"
echo " nix run .#lint-frontend - Run frontend 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 # Unified linting - calls both backend and frontend lints
lint = { lint = {
type = "app"; type = "app";