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,5 +1,5 @@
from datetime import datetime
from typing import Optional, Any
from typing import Optional, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
@@ -25,6 +25,7 @@ class TreeCreate(TreeBase):
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
is_public: bool = Field(False, description="Make tree visible to all users")
is_default: bool = Field(False, description="Mark as a default/system tree (admin only)")
status: Literal['draft', 'published'] = Field('published', description="Status: draft or published")
category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table")
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign")
@@ -37,6 +38,7 @@ class TreeUpdate(BaseModel):
tree_structure: Optional[dict[str, Any]] = None
is_public: Optional[bool] = None
is_active: Optional[bool] = None
status: Optional[Literal['draft', 'published']] = None
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)")
@@ -70,6 +72,7 @@ class TreeResponse(TreeBase):
is_active: bool
is_public: bool
is_default: bool
status: str # draft or published
version: int
created_at: datetime
updated_at: datetime
@@ -92,6 +95,7 @@ class TreeListResponse(BaseModel):
is_active: bool
is_public: bool
is_default: bool
status: str # draft or published
version: int
usage_count: int
created_at: datetime
@@ -99,3 +103,60 @@ class TreeListResponse(BaseModel):
class Config:
from_attributes = True
# --- Tree Sharing Schemas ---
class TreeShareCreate(BaseModel):
"""Request to create a share token for a tree."""
allow_forking: bool = Field(True, description="Whether recipients can fork this tree")
expires_at: Optional[datetime] = Field(None, description="Optional expiration time for the share")
class TreeShareResponse(BaseModel):
"""Response containing share token and URL."""
id: UUID
tree_id: UUID
share_token: str
share_url: str
allow_forking: bool
created_by: UUID
created_at: datetime
expires_at: Optional[datetime] = None
class Config:
from_attributes = True
class TreeVisibilityUpdate(BaseModel):
"""Request to update tree visibility."""
visibility: Literal['private', 'team', 'link', 'public'] = Field(..., description="Visibility level")
class SharedTreeResponse(TreeBase):
"""Public response for shared trees (minimal info)."""
id: UUID
tree_structure: dict[str, Any]
category: Optional[str] = None
tags: list[str] = []
version: int
allow_forking: bool # From share token
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# --- Tree Validation Schemas ---
class ValidationError(BaseModel):
"""Individual validation error."""
field: str
message: str
class TreeValidationResponse(BaseModel):
"""Response for tree validation endpoint."""
can_publish: bool
errors: list[ValidationError] = []