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>
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""Public endpoints for accessing shared content (no authentication required)."""
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.models.tree import Tree
|
|
from app.models.tree_share import TreeShare
|
|
from app.schemas.tree import SharedTreeResponse
|
|
|
|
router = APIRouter(prefix="/shared", tags=["shared"])
|
|
|
|
|
|
@router.get("/{share_token}", response_model=SharedTreeResponse)
|
|
async def get_shared_tree(
|
|
share_token: str,
|
|
db: Annotated[AsyncSession, Depends(get_db)]
|
|
):
|
|
"""Get a tree by its share token (PUBLIC endpoint - no auth required).
|
|
|
|
Returns 404 if:
|
|
- Share token doesn't exist
|
|
- Share token has expired
|
|
- Tree is not active
|
|
"""
|
|
# Look up share token
|
|
result = await db.execute(
|
|
select(TreeShare)
|
|
.options(selectinload(TreeShare.tree).selectinload(Tree.tags))
|
|
.where(TreeShare.share_token == share_token)
|
|
)
|
|
tree_share = result.scalar_one_or_none()
|
|
|
|
if not tree_share:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Share link not found or has been revoked"
|
|
)
|
|
|
|
# Check expiration
|
|
if tree_share.expires_at and tree_share.expires_at < datetime.now(timezone.utc):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Share link has expired"
|
|
)
|
|
|
|
# Check tree is active
|
|
tree = tree_share.tree
|
|
if not tree or not tree.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Tree not found"
|
|
)
|
|
|
|
# Build response (minimal info for public access)
|
|
return SharedTreeResponse(
|
|
id=tree.id,
|
|
name=tree.name,
|
|
description=tree.description,
|
|
category=tree.category,
|
|
tree_structure=tree.tree_structure,
|
|
tags=tree.tag_names,
|
|
version=tree.version,
|
|
allow_forking=tree_share.allow_forking,
|
|
created_at=tree.created_at,
|
|
updated_at=tree.updated_at
|
|
)
|