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:
70
backend/app/api/endpoints/shared.py
Normal file
70
backend/app/api/endpoints/shared.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user