fix part 2
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
76
flake.nix
76
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";
|
||||
|
||||
Reference in New Issue
Block a user