From 948fe591dc01119adff22a8b301d3237ae725073 Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 14:48:03 -0600 Subject: [PATCH] phase 13 --- backend/alembic/env.py | 5 +- backend/app/api/groups.py | 216 +++++++++++++ backend/app/boards/repository.py | 211 +++++++++++++ backend/app/boards/schemas.py | 32 ++ backend/app/main.py | 3 +- backend/tests/api/test_auth.py | 1 - backend/tests/api/test_bulk_operations.py | 9 +- backend/tests/api/test_groups.py | 289 ++++++++++++++++++ backend/tests/api/test_image_delete.py | 7 +- backend/tests/api/test_image_position.py | 11 +- backend/tests/api/test_images.py | 2 +- backend/tests/api/test_z_order.py | 7 +- backend/tests/auth/test_jwt.py | 1 - backend/tests/auth/test_security.py | 1 - backend/tests/conftest.py | 3 +- backend/tests/images/test_processing.py | 1 - backend/tests/images/test_transformations.py | 6 +- backend/tests/images/test_validation.py | 3 +- frontend/src/lib/api/groups.ts | 69 +++++ frontend/src/lib/canvas/GroupVisual.svelte | 107 +++++++ .../src/lib/canvas/operations/group-move.ts | 118 +++++++ frontend/src/lib/canvas/operations/group.ts | 83 +++++ frontend/src/lib/canvas/operations/ungroup.ts | 58 ++++ .../lib/components/canvas/ColorPicker.svelte | 245 +++++++++++++++ .../components/canvas/GroupAnnotation.svelte | 266 ++++++++++++++++ frontend/src/lib/stores/groups.ts | 158 ++++++++++ frontend/tests/canvas/groups.test.ts | 219 +++++++++++++ specs/001-reference-board-viewer/tasks.md | 46 +-- 28 files changed, 2123 insertions(+), 54 deletions(-) create mode 100644 backend/app/api/groups.py create mode 100644 backend/tests/api/test_groups.py create mode 100644 frontend/src/lib/api/groups.ts create mode 100644 frontend/src/lib/canvas/GroupVisual.svelte create mode 100644 frontend/src/lib/canvas/operations/group-move.ts create mode 100644 frontend/src/lib/canvas/operations/group.ts create mode 100644 frontend/src/lib/canvas/operations/ungroup.ts create mode 100644 frontend/src/lib/components/canvas/ColorPicker.svelte create mode 100644 frontend/src/lib/components/canvas/GroupAnnotation.svelte create mode 100644 frontend/src/lib/stores/groups.ts create mode 100644 frontend/tests/canvas/groups.test.ts diff --git a/backend/alembic/env.py b/backend/alembic/env.py index e5f2087..61b887e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,10 +1,9 @@ -from logging.config import fileConfig import os import sys +from logging.config import fileConfig from pathlib import Path -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from alembic import context diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py new file mode 100644 index 0000000..0e452d4 --- /dev/null +++ b/backend/app/api/groups.py @@ -0,0 +1,216 @@ +"""Group management API endpoints.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.boards.repository import BoardRepository +from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate +from app.core.deps import get_current_user, get_db +from app.database.models.user import User + +router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"]) + + +@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +def create_group( + board_id: UUID, + group_data: GroupCreate, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + Create a new group on a board. + + Assigns the specified images to the group. + """ + repo = BoardRepository(db) + + # Verify board ownership + board = repo.get_board_by_id(board_id, current_user.id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + + # Create group + group = repo.create_group( + board_id=board_id, + name=group_data.name, + color=group_data.color, + annotation=group_data.annotation, + image_ids=group_data.image_ids, + ) + + # Calculate member count + response = GroupResponse.model_validate(group) + response.member_count = len(group_data.image_ids) + + return response + + +@router.get("", response_model=list[GroupResponse]) +def list_groups( + board_id: UUID, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + List all groups on a board. + + Returns groups with member counts. + """ + repo = BoardRepository(db) + + # Verify board ownership + board = repo.get_board_by_id(board_id, current_user.id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + + # Get groups + groups = repo.get_board_groups(board_id) + + # Convert to response with member counts + from sqlalchemy import func, select + + from app.database.models.board_image import BoardImage + + responses = [] + for group in groups: + # Count members + count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id) + member_count = db.execute(count_stmt).scalar_one() + + response = GroupResponse.model_validate(group) + response.member_count = member_count + responses.append(response) + + return responses + + +@router.get("/{group_id}", response_model=GroupResponse) +def get_group( + board_id: UUID, + group_id: UUID, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + Get group details by ID. + """ + repo = BoardRepository(db) + + # Verify board ownership + board = repo.get_board_by_id(board_id, current_user.id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + + # Get group + group = repo.get_group_by_id(group_id, board_id) + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + # Count members + from sqlalchemy import func, select + + from app.database.models.board_image import BoardImage + + count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id) + member_count = db.execute(count_stmt).scalar_one() + + response = GroupResponse.model_validate(group) + response.member_count = member_count + + return response + + +@router.patch("/{group_id}", response_model=GroupResponse) +def update_group( + board_id: UUID, + group_id: UUID, + group_data: GroupUpdate, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + Update group metadata (name, color, annotation). + """ + repo = BoardRepository(db) + + # Verify board ownership + board = repo.get_board_by_id(board_id, current_user.id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + + # Update group + group = repo.update_group( + group_id=group_id, + board_id=board_id, + name=group_data.name, + color=group_data.color, + annotation=group_data.annotation, + ) + + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + # Count members + from sqlalchemy import func, select + + from app.database.models.board_image import BoardImage + + count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id) + member_count = db.execute(count_stmt).scalar_one() + + response = GroupResponse.model_validate(group) + response.member_count = member_count + + return response + + +@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_group( + board_id: UUID, + group_id: UUID, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + Delete a group (ungroups all images). + """ + repo = BoardRepository(db) + + # Verify board ownership + board = repo.get_board_by_id(board_id, current_user.id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + + # Delete group + success = repo.delete_group(group_id, board_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) diff --git a/backend/app/boards/repository.py b/backend/app/boards/repository.py index f6484b0..2aca05f 100644 --- a/backend/app/boards/repository.py +++ b/backend/app/boards/repository.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app.database.models.board import Board from app.database.models.board_image import BoardImage +from app.database.models.group import Group class BoardRepository: @@ -195,3 +196,213 @@ class BoardRepository: count = self.db.execute(stmt).scalar_one() return count > 0 + + # Group operations + + def create_group( + self, + board_id: UUID, + name: str, + color: str, + annotation: str | None, + image_ids: list[UUID], + ) -> Group: + """ + Create a new group and assign images to it. + + Args: + board_id: Board UUID + name: Group name + color: Hex color code + annotation: Optional annotation text + image_ids: List of board_image IDs to include + + Returns: + Created Group instance + """ + group = Group( + board_id=board_id, + name=name, + color=color, + annotation=annotation, + ) + + self.db.add(group) + self.db.flush() # Get group ID + + # Assign images to group + for image_id in image_ids: + stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) + board_image = self.db.execute(stmt).scalar_one_or_none() + + if board_image: + board_image.group_id = group.id + + self.db.commit() + self.db.refresh(group) + + return group + + def get_board_groups(self, board_id: UUID) -> Sequence[Group]: + """ + Get all groups for a board with member counts. + + Args: + board_id: Board UUID + + Returns: + List of groups + """ + stmt = ( + select(Group, func.count(BoardImage.id).label("member_count")) + .outerjoin(BoardImage, Group.id == BoardImage.group_id) + .where(Group.board_id == board_id) + .group_by(Group.id) + .order_by(Group.created_at.desc()) + ) + + results = self.db.execute(stmt).all() + + # Add member_count as attribute + groups = [] + for row in results: + group = row[0] + # Note: member_count is dynamically calculated, not stored + groups.append(group) + + return groups + + def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None: + """ + Get group by ID. + + Args: + group_id: Group UUID + board_id: Board UUID (for verification) + + Returns: + Group if found, None otherwise + """ + stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id) + + return self.db.execute(stmt).scalar_one_or_none() + + def update_group( + self, + group_id: UUID, + board_id: UUID, + name: str | None = None, + color: str | None = None, + annotation: str | None = None, + ) -> Group | None: + """ + Update group metadata. + + Args: + group_id: Group UUID + board_id: Board UUID + name: New name (if provided) + color: New color (if provided) + annotation: New annotation (if provided) + + Returns: + Updated Group if found, None otherwise + """ + group = self.get_group_by_id(group_id, board_id) + + if not group: + return None + + if name is not None: + group.name = name + + if color is not None: + group.color = color + + if annotation is not None: + group.annotation = annotation + + self.db.commit() + self.db.refresh(group) + + return group + + def delete_group(self, group_id: UUID, board_id: UUID) -> bool: + """ + Delete a group and ungroup its members. + + Args: + group_id: Group UUID + board_id: Board UUID + + Returns: + True if deleted, False if not found + """ + group = self.get_group_by_id(group_id, board_id) + + if not group: + return False + + # Ungroup all members (set group_id to None) + stmt = select(BoardImage).where(BoardImage.group_id == group_id) + members = self.db.execute(stmt).scalars().all() + + for member in members: + member.group_id = None + + # Delete the group + self.db.delete(group) + self.db.commit() + + return True + + def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int: + """ + Add images to a group. + + Args: + group_id: Group UUID + board_id: Board UUID + image_ids: List of image IDs to add + + Returns: + Number of images added + """ + count = 0 + + for image_id in image_ids: + stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) + board_image = self.db.execute(stmt).scalar_one_or_none() + + if board_image: + board_image.group_id = group_id + count += 1 + + self.db.commit() + + return count + + def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int: + """ + Remove images from a group. + + Args: + group_id: Group UUID + image_ids: List of image IDs to remove + + Returns: + Number of images removed + """ + count = 0 + + for image_id in image_ids: + stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id) + board_image = self.db.execute(stmt).scalar_one_or_none() + + if board_image: + board_image.group_id = None + count += 1 + + self.db.commit() + + return count diff --git a/backend/app/boards/schemas.py b/backend/app/boards/schemas.py index 36211c1..3cea780 100644 --- a/backend/app/boards/schemas.py +++ b/backend/app/boards/schemas.py @@ -74,3 +74,35 @@ class BoardDetail(BaseModel): if isinstance(v, dict): return ViewportState(**v) return v + + +class GroupCreate(BaseModel): + """Schema for creating a new group.""" + + name: str = Field(..., min_length=1, max_length=255, description="Group name") + color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)") + annotation: str | None = Field(None, max_length=10000, description="Optional text annotation") + image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group") + + +class GroupUpdate(BaseModel): + """Schema for updating group metadata.""" + + name: str | None = Field(None, min_length=1, max_length=255, description="Group name") + color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code") + annotation: str | None = Field(None, max_length=10000, description="Text annotation") + + +class GroupResponse(BaseModel): + """Response schema for group with member count.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + board_id: UUID + name: str + color: str + annotation: str | None = None + member_count: int = Field(default=0, description="Number of images in group") + created_at: datetime + updated_at: datetime diff --git a/backend/app/main.py b/backend/app/main.py index 0bc2d23..97d88f6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ import logging from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from app.api import auth, boards, images +from app.api import auth, boards, groups, images from app.core.config import settings from app.core.errors import WebRefException from app.core.logging import setup_logging @@ -84,6 +84,7 @@ async def root(): # API routers app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}") +app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}") diff --git a/backend/tests/api/test_auth.py b/backend/tests/api/test_auth.py index 613c3a0..d837ab7 100644 --- a/backend/tests/api/test_auth.py +++ b/backend/tests/api/test_auth.py @@ -1,6 +1,5 @@ """Integration tests for authentication endpoints.""" -import pytest from fastapi import status from fastapi.testclient import TestClient diff --git a/backend/tests/api/test_bulk_operations.py b/backend/tests/api/test_bulk_operations.py index 789ee29..e90ef1e 100644 --- a/backend/tests/api/test_bulk_operations.py +++ b/backend/tests/api/test_bulk_operations.py @@ -1,14 +1,15 @@ """Integration tests for bulk image operations.""" +from uuid import uuid4 + import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from uuid import uuid4 -from app.database.models.user import User from app.database.models.board import Board -from app.database.models.image import Image from app.database.models.board_image import BoardImage +from app.database.models.image import Image +from app.database.models.user import User @pytest.mark.asyncio @@ -26,7 +27,7 @@ async def test_bulk_update_position_delta(client: AsyncClient, test_user: User, # Create images images = [] board_images = [] - + for i in range(3): image = Image( id=uuid4(), diff --git a/backend/tests/api/test_groups.py b/backend/tests/api/test_groups.py new file mode 100644 index 0000000..1af4d15 --- /dev/null +++ b/backend/tests/api/test_groups.py @@ -0,0 +1,289 @@ +"""Integration tests for group endpoints.""" + +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session + +from app.database.models.board import Board +from app.database.models.board_image import BoardImage +from app.database.models.image import Image +from app.database.models.user import User + +pytestmark = pytest.mark.asyncio + + +async def test_create_group(client: AsyncClient, test_user: User, db: Session): + """Test creating a group with images.""" + # Create board + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + # Create images + images = [] + for i in range(3): + image = Image( + id=uuid4(), + user_id=test_user.id, + filename=f"test{i}.jpg", + storage_path=f"{test_user.id}/test{i}.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": f"abc{i}"}, + ) + db.add(image) + images.append(image) + + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0}, + z_order=i, + ) + db.add(board_image) + + db.commit() + + # Create group + response = await client.post( + f"/api/boards/{board.id}/groups", + json={ + "name": "Test Group", + "color": "#FF5733", + "annotation": "Group annotation", + "image_ids": [str(img.id) for img in images[:2]], + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Test Group" + assert data["color"] == "#FF5733" + assert data["annotation"] == "Group annotation" + assert data["member_count"] == 2 + + +async def test_list_groups(client: AsyncClient, test_user: User, db: Session): + """Test listing groups on a board.""" + from app.database.models.group import Group + + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + # Create groups + for i in range(3): + group = Group( + id=uuid4(), + board_id=board.id, + name=f"Group {i}", + color=f"#FF573{i}", + annotation=f"Annotation {i}", + ) + db.add(group) + + db.commit() + + # List groups + response = await client.get(f"/api/boards/{board.id}/groups") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 3 + assert data[0]["name"] == "Group 2" # Most recent first + + +async def test_get_group(client: AsyncClient, test_user: User, db: Session): + """Test getting a specific group.""" + from app.database.models.group import Group + + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + group = Group( + id=uuid4(), + board_id=board.id, + name="Test Group", + color="#FF5733", + annotation="Test annotation", + ) + db.add(group) + db.commit() + + # Get group + response = await client.get(f"/api/boards/{board.id}/groups/{group.id}") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Group" + assert data["color"] == "#FF5733" + + +async def test_update_group(client: AsyncClient, test_user: User, db: Session): + """Test updating group metadata.""" + from app.database.models.group import Group + + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + group = Group( + id=uuid4(), + board_id=board.id, + name="Original Name", + color="#FF5733", + annotation="Original annotation", + ) + db.add(group) + db.commit() + + # Update group + response = await client.patch( + f"/api/boards/{board.id}/groups/{group.id}", + json={ + "name": "Updated Name", + "color": "#00FF00", + "annotation": "Updated annotation", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Updated Name" + assert data["color"] == "#00FF00" + assert data["annotation"] == "Updated annotation" + + +async def test_delete_group(client: AsyncClient, test_user: User, db: Session): + """Test deleting a group.""" + from app.database.models.group import Group + + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + # Create image + image = Image( + id=uuid4(), + user_id=test_user.id, + filename="test.jpg", + storage_path=f"{test_user.id}/test.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": "abc"}, + ) + db.add(image) + + # Create group + group = Group( + id=uuid4(), + board_id=board.id, + name="Test Group", + color="#FF5733", + ) + db.add(group) + + # Add image to board and group + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0}, + z_order=0, + group_id=group.id, + ) + db.add(board_image) + db.commit() + + # Delete group + response = await client.delete(f"/api/boards/{board.id}/groups/{group.id}") + + assert response.status_code == 204 + + # Verify image is ungrouped + db.refresh(board_image) + assert board_image.group_id is None + + +async def test_group_unauthorized_board(client: AsyncClient, test_user: User, db: Session): + """Test that users can't create groups on boards they don't own.""" + # Create another user + other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed") + db.add(other_user) + + # Create board owned by other user + board = Board( + id=uuid4(), + user_id=other_user.id, + title="Other Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + db.commit() + + # Try to create group + response = await client.post( + f"/api/boards/{board.id}/groups", + json={ + "name": "Test Group", + "color": "#FF5733", + "image_ids": [str(uuid4())], + }, + ) + + assert response.status_code == 404 # Board not found (for security) + + +async def test_invalid_color_format(client: AsyncClient, test_user: User, db: Session): + """Test that invalid color formats are rejected.""" + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + db.commit() + + # Try with invalid color + response = await client.post( + f"/api/boards/{board.id}/groups", + json={ + "name": "Test Group", + "color": "red", # Invalid: not hex + "image_ids": [str(uuid4())], + }, + ) + + assert response.status_code == 422 + diff --git a/backend/tests/api/test_image_delete.py b/backend/tests/api/test_image_delete.py index 41f5a38..8708259 100644 --- a/backend/tests/api/test_image_delete.py +++ b/backend/tests/api/test_image_delete.py @@ -1,14 +1,15 @@ """Integration tests for image deletion endpoints.""" +from uuid import uuid4 + import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from uuid import uuid4 -from app.database.models.user import User from app.database.models.board import Board -from app.database.models.image import Image from app.database.models.board_image import BoardImage +from app.database.models.image import Image +from app.database.models.user import User @pytest.mark.asyncio diff --git a/backend/tests/api/test_image_position.py b/backend/tests/api/test_image_position.py index a4bb4bd..4d3a92a 100644 --- a/backend/tests/api/test_image_position.py +++ b/backend/tests/api/test_image_position.py @@ -1,14 +1,15 @@ """Integration tests for image position update endpoint.""" +from uuid import uuid4 + import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from uuid import uuid4 -from app.database.models.user import User from app.database.models.board import Board -from app.database.models.image import Image from app.database.models.board_image import BoardImage +from app.database.models.image import Image +from app.database.models.user import User @pytest.mark.asyncio @@ -441,11 +442,11 @@ async def test_update_preserves_other_fields(client: AsyncClient, test_user: Use assert response.status_code == 200 data = response.json() - + # Position should be updated assert data["position"]["x"] == 200 assert data["position"]["y"] == 200 - + # Other fields should be preserved assert data["transformations"]["scale"] == 1.5 assert data["transformations"]["rotation"] == 45 diff --git a/backend/tests/api/test_images.py b/backend/tests/api/test_images.py index 57d42fb..086a231 100644 --- a/backend/tests/api/test_images.py +++ b/backend/tests/api/test_images.py @@ -1,7 +1,7 @@ """Integration tests for image upload endpoints.""" import io -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest from fastapi import status diff --git a/backend/tests/api/test_z_order.py b/backend/tests/api/test_z_order.py index 5ad9db3..23b784b 100644 --- a/backend/tests/api/test_z_order.py +++ b/backend/tests/api/test_z_order.py @@ -1,14 +1,15 @@ """Integration tests for Z-order persistence.""" +from uuid import uuid4 + import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from uuid import uuid4 -from app.database.models.user import User from app.database.models.board import Board -from app.database.models.image import Image from app.database.models.board_image import BoardImage +from app.database.models.image import Image +from app.database.models.user import User @pytest.mark.asyncio diff --git a/backend/tests/auth/test_jwt.py b/backend/tests/auth/test_jwt.py index 8a1b000..ffd40bf 100644 --- a/backend/tests/auth/test_jwt.py +++ b/backend/tests/auth/test_jwt.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta from uuid import UUID, uuid4 -import pytest from jose import jwt from app.auth.jwt import create_access_token, decode_access_token diff --git a/backend/tests/auth/test_security.py b/backend/tests/auth/test_security.py index 244ac22..cf02d71 100644 --- a/backend/tests/auth/test_security.py +++ b/backend/tests/auth/test_security.py @@ -1,6 +1,5 @@ """Unit tests for password hashing and validation.""" -import pytest from app.auth.security import hash_password, validate_password_strength, verify_password diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c509ec0..9f82efb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,6 @@ """Pytest configuration and fixtures for all tests.""" -import os -from typing import Generator +from collections.abc import Generator import pytest from fastapi.testclient import TestClient diff --git a/backend/tests/images/test_processing.py b/backend/tests/images/test_processing.py index 703cfd4..d2d5eee 100644 --- a/backend/tests/images/test_processing.py +++ b/backend/tests/images/test_processing.py @@ -3,7 +3,6 @@ import io from uuid import uuid4 -import pytest from PIL import Image as PILImage from app.images.processing import generate_thumbnails diff --git a/backend/tests/images/test_transformations.py b/backend/tests/images/test_transformations.py index 999092e..a7269a9 100644 --- a/backend/tests/images/test_transformations.py +++ b/backend/tests/images/test_transformations.py @@ -44,7 +44,7 @@ def test_transformation_scale_bounds(): """Test scale bounds validation.""" # Valid scales valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0] - + for scale in valid_scales: data = BoardImageUpdate(transformations={"scale": scale}) assert data.transformations["scale"] == scale @@ -54,7 +54,7 @@ def test_transformation_rotation_bounds(): """Test rotation bounds (any value allowed, normalized client-side).""" # Various rotation values rotations = [0, 45, 90, 180, 270, 360, 450, -90] - + for rotation in rotations: data = BoardImageUpdate(transformations={"rotation": rotation}) assert data.transformations["rotation"] == rotation @@ -64,7 +64,7 @@ def test_transformation_opacity_bounds(): """Test opacity bounds.""" # Valid opacity values valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0] - + for opacity in valid_opacities: data = BoardImageUpdate(transformations={"opacity": opacity}) assert data.transformations["opacity"] == opacity diff --git a/backend/tests/images/test_validation.py b/backend/tests/images/test_validation.py index dad7ca1..caed644 100644 --- a/backend/tests/images/test_validation.py +++ b/backend/tests/images/test_validation.py @@ -1,7 +1,6 @@ """Tests for file validation.""" -import io -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock import pytest from fastapi import HTTPException, UploadFile diff --git a/frontend/src/lib/api/groups.ts b/frontend/src/lib/api/groups.ts new file mode 100644 index 0000000..6cbac01 --- /dev/null +++ b/frontend/src/lib/api/groups.ts @@ -0,0 +1,69 @@ +/** + * Groups API client + * Handles group creation, update, deletion + */ + +import { apiClient } from './client'; + +export interface GroupCreateData { + name: string; + color: string; + annotation?: string; + image_ids: string[]; +} + +export interface GroupUpdateData { + name?: string; + color?: string; + annotation?: string; +} + +export interface Group { + id: string; + board_id: string; + name: string; + color: string; + annotation: string | null; + member_count: number; + created_at: string; + updated_at: string; +} + +/** + * Create a new group + */ +export async function createGroup(boardId: string, data: GroupCreateData): Promise { + return apiClient.post(`/api/boards/${boardId}/groups`, data); +} + +/** + * List all groups on a board + */ +export async function listGroups(boardId: string): Promise { + return apiClient.get(`/api/boards/${boardId}/groups`); +} + +/** + * Get a specific group + */ +export async function getGroup(boardId: string, groupId: string): Promise { + return apiClient.get(`/api/boards/${boardId}/groups/${groupId}`); +} + +/** + * Update group metadata + */ +export async function updateGroup( + boardId: string, + groupId: string, + data: GroupUpdateData +): Promise { + return apiClient.patch(`/api/boards/${boardId}/groups/${groupId}`, data); +} + +/** + * Delete a group (ungroups all members) + */ +export async function deleteGroup(boardId: string, groupId: string): Promise { + await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`); +} diff --git a/frontend/src/lib/canvas/GroupVisual.svelte b/frontend/src/lib/canvas/GroupVisual.svelte new file mode 100644 index 0000000..1a7629b --- /dev/null +++ b/frontend/src/lib/canvas/GroupVisual.svelte @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/lib/canvas/operations/group-move.ts b/frontend/src/lib/canvas/operations/group-move.ts new file mode 100644 index 0000000..b74efa4 --- /dev/null +++ b/frontend/src/lib/canvas/operations/group-move.ts @@ -0,0 +1,118 @@ +/** + * Group move operations + * Move all images in a group together as a unit + */ + +import type Konva from 'konva'; + +export interface GroupMoveOptions { + animate?: boolean; + onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void; +} + +/** + * Move all images in a group by delta + */ +export function moveGroupBy( + images: Map, + imageIdsInGroup: string[], + groupId: string, + deltaX: number, + deltaY: number, + options: GroupMoveOptions = {} +): void { + const { animate = false, onMoveComplete } = options; + + imageIdsInGroup.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const newX = image.x() + deltaX; + const newY = image.y() + deltaY; + + if (animate) { + image.to({ + x: newX, + y: newY, + duration: 0.3, + }); + } else { + image.position({ x: newX, y: newY }); + } + }); + + // Batch draw + const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (onMoveComplete) { + onMoveComplete(groupId, deltaX, deltaY); + } +} + +/** + * Move group to specific position (aligns top-left) + */ +export function moveGroupTo( + images: Map, + imageIdsInGroup: string[], + groupId: string, + targetX: number, + targetY: number, + options: GroupMoveOptions = {} +): void { + // Find current top-left + let minX = Infinity; + let minY = Infinity; + + imageIdsInGroup.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + minX = Math.min(minX, box.x); + minY = Math.min(minY, box.y); + }); + + if (!isFinite(minX) || !isFinite(minY)) return; + + const deltaX = targetX - minX; + const deltaY = targetY - minY; + + moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options); +} + +/** + * Get group bounding box + */ +export function getGroupBounds( + images: Map, + imageIdsInGroup: string[] +): { x: number; y: number; width: number; height: number } | null { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + imageIdsInGroup.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + minX = Math.min(minX, box.x); + minY = Math.min(minY, box.y); + maxX = Math.max(maxX, box.x + box.width); + maxY = Math.max(maxY, box.y + box.height); + }); + + if (!isFinite(minX) || !isFinite(minY)) return null; + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} diff --git a/frontend/src/lib/canvas/operations/group.ts b/frontend/src/lib/canvas/operations/group.ts new file mode 100644 index 0000000..53579ec --- /dev/null +++ b/frontend/src/lib/canvas/operations/group.ts @@ -0,0 +1,83 @@ +/** + * Group operations for canvas images + * Create groups from selected images + */ + +import type { Group } from '$lib/api/groups'; + +export interface CreateGroupOptions { + name: string; + color: string; + annotation?: string; + onGroupCreate?: (group: Group) => void; +} + +/** + * Create group from selected images + */ +export async function createGroupFromSelection( + selectedIds: string[], + boardId: string, + options: CreateGroupOptions +): Promise { + if (selectedIds.length === 0) { + return null; + } + + const { createGroup } = await import('$lib/api/groups'); + + try { + const group = await createGroup(boardId, { + name: options.name, + color: options.color, + annotation: options.annotation, + image_ids: selectedIds, + }); + + if (options.onGroupCreate) { + options.onGroupCreate(group); + } + + return group; + } catch (error) { + console.error('Failed to create group:', error); + return null; + } +} + +/** + * Check if all selected images can be grouped + */ +export function canCreateGroup(selectedIds: string[]): boolean { + return selectedIds.length >= 1; +} + +/** + * Get group color suggestions + */ +export function getGroupColorSuggestions(): string[] { + return [ + '#FF5733', // Red + '#3B82F6', // Blue + '#10B981', // Green + '#F59E0B', // Yellow + '#8B5CF6', // Purple + '#EC4899', // Pink + '#14B8A6', // Teal + '#F97316', // Orange + ]; +} + +/** + * Generate default group name + */ +export function generateDefaultGroupName(existingGroups: Group[]): string { + const baseName = 'Group'; + let counter = existingGroups.length + 1; + + while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) { + counter++; + } + + return `${baseName} ${counter}`; +} diff --git a/frontend/src/lib/canvas/operations/ungroup.ts b/frontend/src/lib/canvas/operations/ungroup.ts new file mode 100644 index 0000000..c98514f --- /dev/null +++ b/frontend/src/lib/canvas/operations/ungroup.ts @@ -0,0 +1,58 @@ +/** + * Ungroup operations + * Remove images from groups + */ + +export interface UngroupOptions { + onUngroupComplete?: (imageIds: string[], groupId: string) => void; +} + +/** + * Ungroup images (remove from group) + */ +export async function ungroupImages( + boardId: string, + groupId: string, + options: UngroupOptions = {} +): Promise { + const { deleteGroup } = await import('$lib/api/groups'); + + try { + await deleteGroup(boardId, groupId); + + if (options.onUngroupComplete) { + // Note: We'd need to track which images were in the group + options.onUngroupComplete([], groupId); + } + + return true; + } catch (error) { + console.error('Failed to ungroup:', error); + return false; + } +} + +/** + * Remove specific images from group + */ +export async function removeImagesFromGroup( + boardId: string, + groupId: string, + imageIds: string[] +): Promise { + // Update board images to remove group_id + const { apiClient } = await import('$lib/api/client'); + + try { + for (const imageId of imageIds) { + await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, { + group_id: null, + }); + } + + return true; + } catch (error) { + console.error('Failed to remove images from group:', error); + return false; + } +} diff --git a/frontend/src/lib/components/canvas/ColorPicker.svelte b/frontend/src/lib/components/canvas/ColorPicker.svelte new file mode 100644 index 0000000..d23dde2 --- /dev/null +++ b/frontend/src/lib/components/canvas/ColorPicker.svelte @@ -0,0 +1,245 @@ + + +{#if show} +
e.key === 'Escape' && handleClose()} + role="button" + tabindex="-1" + > + +
+{/if} + + diff --git a/frontend/src/lib/components/canvas/GroupAnnotation.svelte b/frontend/src/lib/components/canvas/GroupAnnotation.svelte new file mode 100644 index 0000000..0d27986 --- /dev/null +++ b/frontend/src/lib/components/canvas/GroupAnnotation.svelte @@ -0,0 +1,266 @@ + + +
+
+ + + {/if} +
+
+ +
+ {#if editing} +