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")