feat: implement tree sharing, draft trees, and session-to-tree conversion (Issues #16, #25, #17)

Backend features:
- Tree sharing via secure tokens with expiration (Issue #16)
- Draft tree status with conditional validation (Issue #25)
- Save session as custom tree with fork tracking (Issue #17)
- Tree validation system for publish requirements
- Session-to-tree conversion preserving custom steps

Database migrations:
- 024: Tree sharing (tree_shares table, visibility field)
- 025: Tree status field (draft/published)
- 25b: Merge migration for indexes

New endpoints:
- POST /api/v1/trees/{id}/share - Generate share token
- GET /api/v1/shared/{token} - Public tree access
- POST /api/v1/trees/{id}/can-publish - Validate tree
- POST /api/v1/sessions/{id}/save-as-tree - Convert session

Test coverage:
- 20 tests for draft trees functionality
- 14 tests for session-to-tree conversion
- 15 tests for tree sharing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-07 23:06:13 -05:00
parent 9f92547309
commit c7b2c59ef6
16 changed files with 2141 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
@@ -14,10 +14,21 @@ if TYPE_CHECKING:
from app.models.category import TreeCategory
from app.models.tag import TreeTag
from app.models.folder import UserFolder
from app.models.tree_share import TreeShare
class Tree(Base):
__tablename__ = "trees"
__table_args__ = (
CheckConstraint(
"visibility IN ('private', 'team', 'link', 'public')",
name='ck_trees_visibility'
),
CheckConstraint(
"status IN ('draft', 'published')",
name='ck_trees_status'
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
@@ -57,6 +68,20 @@ class Tree(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
visibility: Mapped[str] = mapped_column(
String(20),
nullable=False,
default='team',
index=True,
comment="Visibility level: private (author only), team (account members), link (share token), public (all users)"
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default='published',
index=True,
comment="Status: draft (work in progress) or published (validated and available)"
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
@@ -136,6 +161,11 @@ class Tree(Base):
foreign_keys=[root_tree_id]
)
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")
shares: Mapped[list["TreeShare"]] = relationship(
"TreeShare",
back_populates="tree",
cascade="all, delete-orphan"
)
# New organization relationships
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")

View File

@@ -0,0 +1,60 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.tree import Tree
from app.models.user import User
class TreeShare(Base):
__tablename__ = "tree_shares"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
index=True
)
share_token: Mapped[str] = mapped_column(
String(64),
unique=True,
nullable=False,
index=True,
comment="URL-safe random token (48 bytes -> 64 base64 chars)"
)
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
allow_forking: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="Whether recipients can fork this tree"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
comment="Optional expiration for time-limited shares"
)
# Relationships
tree: Mapped["Tree"] = relationship("Tree", back_populates="shares")
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])