Initial commit: Backend API Phase 1a complete

- FastAPI backend with JWT auth
- PostgreSQL database schema
- Trees and Sessions CRUD APIs
- Export functionality (Markdown, Text, HTML)
- Docker setup for local development
- Alembic migrations
This commit is contained in:
Michael Chihlas
2026-01-22 14:38:53 -05:00
commit 52e8190211
42 changed files with 5385 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Troubleshooting Decision Tree Backend

View File

@@ -0,0 +1 @@
# API module

82
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,82 @@
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
"""Get current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None:
raise credentials_exception
token_type = payload.get("type")
if token_type != "access":
raise credentials_exception
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
try:
user_uuid = UUID(user_id)
except ValueError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_uuid))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
) -> User:
"""Ensure user is active (not disabled)."""
# For now, all users are considered active
# Add logic here if you add an is_active field to User
return current_user
async def require_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Require admin role."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
async def require_engineer_or_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Require engineer or admin role."""
if current_user.role not in ("admin", "engineer"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Engineer or admin access required"
)
return current_user

View File

@@ -0,0 +1 @@
# API endpoints

View File

@@ -0,0 +1,159 @@
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
decode_token
)
from app.models.user import User
from app.schemas.user import UserCreate, UserResponse, UserLogin
from app.schemas.token import Token
from app.api.deps import get_current_user
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Register a new user."""
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
new_user = User(
email=user_data.email,
password_hash=get_password_hash(user_data.password),
name=user_data.name,
role="engineer" # Default role
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Login and get access token."""
# Find user by email
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last login
user.last_login = datetime.now(timezone.utc)
await db.commit()
# Create tokens
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
@router.post("/login/json", response_model=Token)
async def login_json(
credentials: UserLogin,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Login with JSON body (alternative to form data)."""
result = await db.execute(select(User).where(User.email == credentials.email))
user = result.scalar_one_or_none()
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
user.last_login = datetime.now(timezone.utc)
await db.commit()
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)
@router.post("/refresh", response_model=Token)
async def refresh_token(
refresh_token: str,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Refresh access token using refresh token."""
payload = decode_token(refresh_token)
if payload is None or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return Token(
access_token=access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
@router.get("/me", response_model=UserResponse)
async def get_me(
current_user: Annotated[User, Depends(get_current_user)]
):
"""Get current authenticated user."""
return current_user
@router.post("/logout")
async def logout():
"""Logout user (client should discard tokens)."""
# JWT tokens are stateless, so logout is handled client-side
# In a production app, you might want to blacklist the token
return {"message": "Successfully logged out"}

View File

@@ -0,0 +1,358 @@
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport
from app.api.deps import get_current_user
router = APIRouter(prefix="/sessions", tags=["sessions"])
@router.get("", response_model=list[SessionResponse])
async def list_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
completed: Optional[bool] = Query(None, description="Filter by completion status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
"""List user's troubleshooting sessions."""
query = select(Session).where(Session.user_id == current_user.id)
if completed is not None:
if completed:
query = query.where(Session.completed_at.isnot(None))
else:
query = query.where(Session.completed_at.is_(None))
query = query.order_by(Session.started_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
sessions = result.scalars().all()
return sessions
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Get a specific session."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
)
return session
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
session_data: SessionCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Start a new troubleshooting session."""
# Get the tree
result = await db.execute(select(Tree).where(Tree.id == session_data.tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
if not tree.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot start session with inactive tree"
)
# Create session with tree snapshot
new_session = Session(
tree_id=tree.id,
user_id=current_user.id,
tree_snapshot=tree.tree_structure,
path_taken=[],
decisions=[],
ticket_number=session_data.ticket_number,
client_name=session_data.client_name
)
# Increment tree usage count
tree.usage_count += 1
db.add(new_session)
await db.commit()
await db.refresh(new_session)
return new_session
@router.put("/{session_id}", response_model=SessionResponse)
async def update_session(
session_id: UUID,
session_data: SessionUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Update session (add decisions, notes, etc.)."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
)
if session.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot update a completed session"
)
update_data = session_data.model_dump(exclude_unset=True)
# Convert DecisionRecord objects to dicts if present
if "decisions" in update_data and update_data["decisions"]:
update_data["decisions"] = [
d.model_dump() if hasattr(d, 'model_dump') else d
for d in update_data["decisions"]
]
for field, value in update_data.items():
setattr(session, field, value)
await db.commit()
await db.refresh(session)
return session
@router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Mark session as complete."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
)
if session.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already completed"
)
session.completed_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(session)
return session
@router.post("/{session_id}/export")
async def export_session(
session_id: UUID,
export_options: SessionExport,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Export session to formatted notes."""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if session.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session"
)
# Generate export based on format
if export_options.format == "markdown":
content = _generate_markdown_export(session, export_options)
media_type = "text/markdown"
elif export_options.format == "html":
content = _generate_html_export(session, export_options)
media_type = "text/html"
else: # text
content = _generate_text_export(session, export_options)
media_type = "text/plain"
# Mark as exported
session.exported = True
await db.commit()
return PlainTextResponse(content=content, media_type=media_type)
def _generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(f"# {tree_name}")
lines.append("")
if session.ticket_number:
lines.append(f"**Ticket:** {session.ticket_number}")
if session.client_name:
lines.append(f"**Client:** {session.client_name}")
if options.include_timestamps:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Troubleshooting Steps")
lines.append("")
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
return "\n".join(lines)
def _generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(tree_name)
lines.append("=" * len(tree_name))
if session.ticket_number:
lines.append(f"Ticket: {session.ticket_number}")
if session.client_name:
lines.append(f"Client: {session.client_name}")
if options.include_timestamps:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20)
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
return "\n".join(lines)
def _generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
html = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
f'<title>{tree_name}</title>',
'<style>',
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
'h1 { color: #333; }',
'.meta { color: #666; margin-bottom: 20px; }',
'.step { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }',
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'</style>',
'</head>', '<body>']
if options.include_tree_info:
html.append(f'<h1>{tree_name}</h1>')
html.append('<div class="meta">')
if session.ticket_number:
html.append(f'<p><strong>Ticket:</strong> {session.ticket_number}</p>')
if session.client_name:
html.append(f'<p><strong>Client:</strong> {session.client_name}</p>')
if options.include_timestamps:
html.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html.append('</div>')
html.append('<h2>Troubleshooting Steps</h2>')
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
html.append('<div class="step">')
html.append(f'<h3>Step {i}: {question}</h3>')
if answer:
html.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html.append(f'<p class="notes">Notes: {notes}</p>')
if options.include_timestamps and decision.get("timestamp"):
html.append(f'<p class="timestamp">{decision["timestamp"]}</p>')
html.append('</div>')
html.extend(['</body>', '</html>'])
return "\n".join(html)

View File

@@ -0,0 +1,193 @@
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.core.database import get_db
from app.models.tree import Tree
from app.models.user import User
from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
from app.api.deps import get_current_user, require_engineer_or_admin, require_admin
router = APIRouter(prefix="/trees", tags=["trees"])
@router.get("", response_model=list[TreeListResponse])
async def list_trees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
category: Optional[str] = Query(None, description="Filter by category"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100)
):
"""List all trees with optional filters."""
query = select(Tree)
# Apply filters
if category:
query = query.where(Tree.category == category)
if is_active is not None:
query = query.where(Tree.is_active == is_active)
# Only show active trees or trees owned by user (for now)
# Later, add team-based filtering
query = query.where(
or_(
Tree.is_active == True,
Tree.author_id == current_user.id
)
)
query = query.order_by(Tree.usage_count.desc(), Tree.updated_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
trees = result.scalars().all()
return trees
@router.get("/categories", response_model=list[str])
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""List all unique categories."""
query = select(Tree.category).where(
Tree.category.isnot(None),
Tree.is_active == True
).distinct()
result = await db.execute(query)
categories = [row[0] for row in result.all() if row[0]]
return sorted(categories)
@router.get("/search", response_model=list[TreeListResponse])
async def search_trees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
q: str = Query(..., min_length=2, description="Search query"),
limit: int = Query(20, ge=1, le=50)
):
"""Full-text search trees by name and description."""
# Using PostgreSQL full-text search
search_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, ''))
search_query = func.plainto_tsquery('english', q)
query = select(Tree).where(
Tree.is_active == True,
search_vector.op('@@')(search_query)
).order_by(
func.ts_rank(search_vector, search_query).desc()
).limit(limit)
result = await db.execute(query)
trees = result.scalars().all()
return trees
@router.get("/{tree_id}", response_model=TreeResponse)
async def get_tree(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Get a specific tree by ID."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Check access: tree must be active OR user is the author
if not tree.is_active and tree.author_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
return tree
@router.post("", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
async def create_tree(
tree_data: TreeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Create a new tree (engineers and admins only)."""
new_tree = Tree(
name=tree_data.name,
description=tree_data.description,
category=tree_data.category,
tree_structure=tree_data.tree_structure,
author_id=current_user.id,
team_id=current_user.team_id
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
return new_tree
@router.put("/{tree_id}", response_model=TreeResponse)
async def update_tree(
tree_id: UUID,
tree_data: TreeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Update an existing tree (engineers and admins only)."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Check if user can edit: must be author or admin
if tree.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
)
# Update fields
update_data = tree_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tree, field, value)
# Increment version if tree structure changed
if "tree_structure" in update_data:
tree.version += 1
await db.commit()
await db.refresh(tree)
return tree
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Soft delete a tree (admin only). Sets is_active to False."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
tree.is_active = False
await db.commit()
return None

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(trees.router)
api_router.include_router(sessions.router)

View File

@@ -0,0 +1 @@
# Core module

View File

@@ -0,0 +1,32 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# Application
APP_NAME: str = "Troubleshooting Decision Tree"
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/decision_tree"
DATABASE_URL_SYNC: str = "postgresql://postgres:postgres@localhost:5432/decision_tree"
# JWT Settings
SECRET_KEY: str = "your-secret-key-change-in-production-use-openssl-rand-hex-32"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Security
BCRYPT_ROUNDS: int = 12
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@@ -0,0 +1,37 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from .config import settings
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True
)
# Create async session factory
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
async def get_db() -> AsyncSession:
"""Dependency to get database session."""
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Initialize database tables."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,47 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""Create a JWT refresh token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT token."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import init_db
from app.api.router import api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
# Note: In production, use Alembic migrations instead of init_db
# await init_db()
yield
# Shutdown
pass
app = FastAPI(
title=settings.APP_NAME,
description="A troubleshooting decision tree application for MSP engineers",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/")
async def root():
"""Root endpoint."""
return {
"message": "Troubleshooting Decision Tree API",
"docs": "/api/docs",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}

View File

@@ -0,0 +1,7 @@
from .user import User
from .team import Team
from .tree import Tree
from .session import Session
from .attachment import Attachment
__all__ = ["User", "Team", "Tree", "Session", "Attachment"]

View File

@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id"),
nullable=False
)
node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
storage_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
uploaded_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow
)
# Relationships
session: Mapped["Session"] = relationship("Session", back_populates="attachments")

View File

@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class Session(Base):
__tablename__ = "sessions"
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"),
nullable=False,
index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False,
index=True
)
tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
started_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
index=True
)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, index=True)
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
user: Mapped["User"] = relationship("User", back_populates="sessions")
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")

View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class Team(Base):
__tablename__ = "teams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow
)
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="team")
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="team")

View File

@@ -0,0 +1,51 @@
import uuid
from datetime import datetime
from typing import Optional, Any
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class Tree(Base):
__tablename__ = "trees"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
author_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id"),
nullable=True,
index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow
)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
author: Mapped[Optional["User"]] = relationship("User", back_populates="trees")
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")
# Full-text search index will be created in migration

View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") # admin, engineer, viewer
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationships
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author")
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user")

View File

@@ -0,0 +1,11 @@
from .user import UserCreate, UserUpdate, UserResponse, UserLogin
from .token import Token, TokenPayload
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord
__all__ = [
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
"Token", "TokenPayload",
"TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse",
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord"
]

View File

@@ -0,0 +1,51 @@
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, Field
class DecisionRecord(BaseModel):
node_id: str
question: Optional[str] = None
answer: Optional[str] = None
action_performed: Optional[str] = None
notes: Optional[str] = None
automation_used: Optional[bool] = False
timestamp: datetime
attachments: list[str] = Field(default_factory=list)
class SessionCreate(BaseModel):
tree_id: UUID
ticket_number: Optional[str] = Field(None, max_length=100)
client_name: Optional[str] = Field(None, max_length=255)
class SessionUpdate(BaseModel):
path_taken: Optional[list[str]] = None
decisions: Optional[list[DecisionRecord]] = None
ticket_number: Optional[str] = Field(None, max_length=100)
client_name: Optional[str] = Field(None, max_length=255)
class SessionResponse(BaseModel):
id: UUID
tree_id: UUID
user_id: UUID
tree_snapshot: dict[str, Any]
path_taken: list[str]
decisions: list[dict[str, Any]]
started_at: datetime
completed_at: Optional[datetime] = None
ticket_number: Optional[str] = None
client_name: Optional[str] = None
exported: bool
class Config:
from_attributes = True
class SessionExport(BaseModel):
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
include_timestamps: bool = True
include_tree_info: bool = True

View File

@@ -0,0 +1,14 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: Optional[str] = None
exp: Optional[int] = None
type: Optional[str] = None

View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, Field
class TreeBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
class TreeCreate(TreeBase):
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
class TreeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
tree_structure: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None
class TreeResponse(TreeBase):
id: UUID
tree_structure: dict[str, Any]
author_id: Optional[UUID] = None
team_id: Optional[UUID] = None
is_active: bool
version: int
created_at: datetime
updated_at: datetime
usage_count: int
class Config:
from_attributes = True
class TreeListResponse(BaseModel):
id: UUID
name: str
description: Optional[str] = None
category: Optional[str] = None
is_active: bool
version: int
usage_count: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=255)
class UserCreate(UserBase):
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
class UserUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
email: Optional[EmailStr] = None
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(UserBase):
id: UUID
role: str
team_id: Optional[UUID] = None
created_at: datetime
last_login: Optional[datetime] = None
class Config:
from_attributes = True