fix part 3

This commit is contained in:
Danilo Reyes
2025-11-02 18:31:46 -06:00
parent 209b6d9f18
commit ff1c29c66a
22 changed files with 2226 additions and 74 deletions

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
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="/boards", tags=["boards"])
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
def create_board(
board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Create a new board.
@@ -45,7 +45,7 @@ def create_board(
@router.get("", response_model=dict)
def list_boards(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
):
@@ -77,7 +77,7 @@ def list_boards(
def get_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Get board details by ID.
@@ -111,7 +111,7 @@ def update_board(
board_id: UUID,
board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board metadata.
@@ -157,7 +157,7 @@ def update_viewport(
board_id: UUID,
viewport_data: ViewportStateUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board viewport state only (optimized for frequent updates).
@@ -198,7 +198,7 @@ def update_viewport(
def delete_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Delete a board (soft delete).

View File

@@ -1,6 +1,6 @@
"""Board sharing API endpoints."""
from datetime import datetime, timezone
from datetime import UTC, datetime
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.now(timezone.utc):
if share_link.expires_at and share_link.expires_at < datetime.now(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.now(timezone.utc)
share_link.last_accessed_at = datetime.now(UTC)
db.commit()
return share_link

View File

@@ -1,6 +1,6 @@
"""JWT token generation and validation."""
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
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.now(timezone.utc) + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(timezone.utc), "type": "access"}
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(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, timezone
from datetime import UTC, datetime
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.now(timezone.utc):
if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
return None
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.now(timezone.utc)
share_link.last_accessed_at = datetime.now(UTC)
db.commit()
return share_link

View File

@@ -5,18 +5,16 @@ 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
# For backwards compatibility with synchronous code
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, sessionmaker
from app.auth.jwt import decode_access_token
from app.core.config import settings
from app.database.models.user import User
from app.database.session import get_db
# Sync engine for synchronous endpoints
_sync_engine = create_engine(

View File

@@ -1,10 +1,9 @@
"""Base model for all database models."""
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import Column, DateTime
from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, declared_attr
@@ -22,7 +21,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=lambda: datetime.now(timezone.utc), nullable=False)
created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
def dict(self) -> dict[str, Any]:
"""Convert model to dictionary."""

View File

@@ -1,10 +1,10 @@
"""Board database model."""
from datetime import datetime, timezone
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -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=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -1,10 +1,10 @@
"""BoardImage database model - junction table for boards and images."""
from datetime import datetime, timezone
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -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=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -1,9 +1,8 @@
"""Comment model for board annotations."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
@@ -21,7 +20,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=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime, nullable=False, server_default=func.now())
is_deleted = Column(Boolean, nullable=False, default=False)
# Relationships

View File

@@ -1,10 +1,10 @@
"""Group database model."""
from datetime import datetime, timezone
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -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=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -1,10 +1,10 @@
"""Image database model."""
from datetime import datetime, timezone
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -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=lambda: datetime.now(timezone.utc))
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships

View File

@@ -1,9 +1,8 @@
"""ShareLink model for board sharing functionality."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -19,7 +18,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=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime, nullable=False, server_default=func.now())
expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0)

View File

@@ -1,9 +1,8 @@
"""User model for authentication and ownership."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy import Boolean, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -18,8 +17,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=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
is_active = Column(Boolean, nullable=False, default=True)
# Relationships

View File

@@ -49,25 +49,17 @@ class ImageRepository:
result = await self.db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none()
async def get_user_images(
self, user_id: UUID, limit: int = 50, offset: int = 0
) -> tuple[Sequence[Image], int]:
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."""
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)
)
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)
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
)
images = result.scalars().all()
return images, total
@@ -126,17 +118,14 @@ class ImageRepository:
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
"""Get all images for a board, ordered by z-order."""
result = await self.db.execute(
select(BoardImage)
.where(BoardImage.board_id == board_id)
.order_by(BoardImage.z_order.asc())
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
)
return result.scalars().all()
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)
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
)
return result.scalar_one_or_none()
@@ -151,7 +140,7 @@ class ImageRepository:
) -> 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