phase 3.2 & 4.1

This commit is contained in:
Danilo Reyes
2025-11-02 00:36:32 -06:00
parent cac1db0ed7
commit d40139822d
21 changed files with 2230 additions and 123 deletions

View File

@@ -1,35 +1,62 @@
"""Board model for reference boards."""
"""Board database model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.user import User
class Board(Base):
"""Board model representing a reference board."""
"""
Board model representing a reference board (canvas) containing images.
A board is owned by a user and contains images arranged on an infinite canvas
with a specific viewport state (zoom, pan, rotation).
"""
__tablename__ = "boards"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
viewport_state = Column(JSONB, nullable=False, default={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0})
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
is_deleted = Column(Boolean, nullable=False, default=False)
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
viewport_state: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Relationships
user = relationship("User", back_populates="boards")
board_images = relationship("BoardImage", back_populates="board", cascade="all, delete-orphan")
groups = relationship("Group", back_populates="board", cascade="all, delete-orphan")
share_links = relationship("ShareLink", back_populates="board", cascade="all, delete-orphan")
comments = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
user: Mapped["User"] = relationship("User", back_populates="boards")
board_images: Mapped[list["BoardImage"]] = relationship(
"BoardImage", back_populates="board", cascade="all, delete-orphan"
)
groups: Mapped[list["Group"]] = relationship("Group", back_populates="board", cascade="all, delete-orphan")
share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Board(id={self.id}, title={self.title})>"
"""String representation of Board."""
return f"<Board(id={self.id}, title='{self.title}', user_id={self.user_id})>"

View File

@@ -1,28 +1,44 @@
"""BoardImage junction model."""
"""BoardImage database model - junction table for boards and images."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
from app.database.models.group import Group
from app.database.models.image import Image
class BoardImage(Base):
"""Junction table connecting boards and images with position/transformation data."""
"""
BoardImage model - junction table connecting boards and images.
Stores position, transformations, and z-order for each image on a board.
"""
__tablename__ = "board_images"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
image_id = Column(UUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), nullable=False, index=True)
position = Column(JSONB, nullable=False)
transformations = Column(
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
image_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("images.id", ondelete="CASCADE"), nullable=False
)
position: Mapped[dict] = mapped_column(JSONB, nullable=False)
transformations: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default={
default=lambda: {
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
@@ -31,17 +47,21 @@ class BoardImage(Base):
"greyscale": False,
},
)
z_order = Column(Integer, nullable=False, default=0, index=True)
group_id = Column(UUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
z_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
group_id: Mapped[UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
)
__table_args__ = (UniqueConstraint("board_id", "image_id", name="uq_board_image"),)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
board = relationship("Board", back_populates="board_images")
image = relationship("Image", back_populates="board_images")
group = relationship("Group", back_populates="board_images")
board: Mapped["Board"] = relationship("Board", back_populates="board_images")
image: Mapped["Image"] = relationship("Image", back_populates="board_images")
group: Mapped["Group | None"] = relationship("Group", back_populates="board_images")
def __repr__(self) -> str:
return f"<BoardImage(board_id={self.board_id}, image_id={self.image_id})>"
"""String representation of BoardImage."""
return f"<BoardImage(id={self.id}, board_id={self.board_id}, image_id={self.image_id})>"

View File

@@ -1,31 +1,47 @@
"""Group model for image grouping."""
"""Group database model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
class Group(Base):
"""Group model for organizing images with annotations."""
"""
Group model for organizing images with labels and annotations.
Groups contain multiple images that can be moved together and have
shared visual indicators (color, annotation text).
"""
__tablename__ = "groups"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(255), nullable=False)
color = Column(String(7), nullable=False) # Hex color #RRGGBB
annotation = Column(Text, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
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)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
board = relationship("Board", back_populates="groups")
board_images = relationship("BoardImage", back_populates="group")
board: Mapped["Board"] = relationship("Board", back_populates="groups")
board_images: Mapped[list["BoardImage"]] = relationship("BoardImage", back_populates="group")
def __repr__(self) -> str:
return f"<Group(id={self.id}, name={self.name})>"
"""String representation of Group."""
return f"<Group(id={self.id}, name='{self.name}', board_id={self.board_id})>"

View File

@@ -1,35 +1,52 @@
"""Image model for uploaded images."""
"""Image database model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.user import User
class Image(Base):
"""Image model representing uploaded image files."""
"""
Image model representing uploaded image files.
Images are stored in MinIO and can be reused across multiple boards.
Reference counting tracks how many boards use each image.
"""
__tablename__ = "images"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
filename = Column(String(255), nullable=False, index=True)
storage_path = Column(String(512), nullable=False)
file_size = Column(BigInteger, nullable=False)
mime_type = Column(String(100), nullable=False)
width = Column(Integer, nullable=False)
height = Column(Integer, nullable=False)
image_metadata = Column(JSONB, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
reference_count = Column(Integer, nullable=False, default=0)
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
storage_path: Mapped[str] = mapped_column(String(512), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships
user = relationship("User", back_populates="images")
board_images = relationship("BoardImage", back_populates="image", cascade="all, delete-orphan")
user: Mapped["User"] = relationship("User", back_populates="images")
board_images: Mapped[list["BoardImage"]] = relationship(
"BoardImage", back_populates="image", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Image(id={self.id}, filename={self.filename})>"
"""String representation of Image."""
return f"<Image(id={self.id}, filename='{self.filename}', user_id={self.user_id})>"

View File

@@ -1,33 +1,45 @@
"""ShareLink model for board sharing."""
"""ShareLink database model."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base):
"""ShareLink model for sharing boards with permission control."""
"""
ShareLink model for sharing boards with configurable permissions.
Share links allow users to share boards with others without requiring
authentication, with permission levels controlling what actions are allowed.
"""
__tablename__ = "share_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), 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'
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0)
is_revoked = Column(Boolean, nullable=False, default=False, index=True)
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Relationships
board = relationship("Board", back_populates="share_links")
comments = relationship("Comment", back_populates="share_link")
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
def __repr__(self) -> str:
return f"<ShareLink(id={self.id}, token={self.token[:8]}...)>"
"""String representation of ShareLink."""
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"